(null);
+
+ React.useEffect(() => {
+ if (!enabled || !nativeNotificationsEnabled || !isNativePushPlatform()) {
+ return;
+ }
+
+ let disposed = false;
+ const cleanup: Array<() => void> = [];
+
+ void import('@capacitor/push-notifications')
+ .then(async ({ PushNotifications }) => {
+ if (disposed) return;
+
+ let permission = await PushNotifications.checkPermissions().catch(() => null);
+ if (permission?.receive !== 'granted') {
+ permission = await PushNotifications.requestPermissions().catch(() => null);
+ }
+ if (permission?.receive !== 'granted') {
+ return;
+ }
+
+ const registrationHandle = await PushNotifications.addListener('registration', (token) => {
+ lastTokenRef.current = token.value;
+ const apis = getRegisteredRuntimeAPIs();
+ void apis?.push?.registerApnsToken?.({ token: token.value, platform: getClientPlatform() });
+ });
+
+ const registrationErrorHandle = await PushNotifications.addListener('registrationError', (error) => {
+ console.warn('[Push] APNs registration error:', error);
+ });
+
+ // Note: notification-tap handling lives in the deep-link layer (`useDeepLinkSource`
+ // in deepLinkNavigation), registered unconditionally so cold-launch taps aren't lost
+ // while disconnected.
+
+ await PushNotifications.register().catch(() => undefined);
+
+ if (disposed) {
+ void registrationHandle.remove();
+ void registrationErrorHandle.remove();
+ return;
+ }
+ cleanup.push(
+ () => void registrationHandle.remove(),
+ () => void registrationErrorHandle.remove(),
+ );
+ })
+ .catch(() => undefined);
+
+ return () => {
+ disposed = true;
+ cleanup.forEach((remove) => remove());
+ };
+ }, [enabled, nativeNotificationsEnabled]);
+
+ // When notifications are turned off, drop the token from the server so it stops
+ // pushing to this device. (Separate from the register effect so a transient
+ // disconnect doesn't unregister.)
+ React.useEffect(() => {
+ if (nativeNotificationsEnabled) return;
+ const token = lastTokenRef.current;
+ if (!token) return;
+ lastTokenRef.current = null;
+ const apis = getRegisteredRuntimeAPIs();
+ void apis?.push?.unregisterApnsToken?.({ token });
+ }, [nativeNotificationsEnabled]);
+};
diff --git a/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx b/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx
new file mode 100644
index 0000000000..871bc74cbe
--- /dev/null
+++ b/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx
@@ -0,0 +1,315 @@
+import { describe, expect, mock, test } from 'bun:test';
+
+type ComponentFn = Record> = (props: P) => unknown;
+
+type HookRecord = {
+ values: unknown[];
+ deps: Array;
+};
+
+type HookEffect = () => void | (() => void);
+type HookCallback = (...args: unknown[]) => unknown;
+type JSXProps = Record & { children?: unknown };
+type JSXElementType = Record> = ComponentFn | string | symbol;
+
+const hookRecords = new Map();
+let currentRecord: HookRecord | null = null;
+let hookIndex = 0;
+let pendingEffects: Array<() => void> = [];
+
+const resetHarness = () => {
+ hookRecords.clear();
+ currentRecord = null;
+ hookIndex = 0;
+ pendingEffects = [];
+};
+
+const shallowEqualDeps = (left?: unknown[], right?: unknown[]): boolean => {
+ if (!left || !right) return false;
+ if (left.length !== right.length) return false;
+ return left.every((value, index) => Object.is(value, right[index]));
+};
+
+const getRecord = (component: unknown): HookRecord => {
+ const existing = hookRecords.get(component);
+ if (existing) return existing;
+ const record: HookRecord = { values: [], deps: [] };
+ hookRecords.set(component, record);
+ return record;
+};
+
+const getHookRecord = (): HookRecord => {
+ if (!currentRecord) {
+ throw new Error('Hooks can only run during a render pass');
+ }
+ return currentRecord;
+};
+
+const renderComponent = >(component: ComponentFn
, props: P): unknown => {
+ const previousRecord = currentRecord;
+ const previousHookIndex = hookIndex;
+ currentRecord = getRecord(component);
+ hookIndex = 0;
+
+ try {
+ return component(props);
+ } finally {
+ currentRecord = previousRecord;
+ hookIndex = previousHookIndex;
+ }
+};
+
+function useCallback(callback: T, deps?: unknown[]): T {
+ const record = getHookRecord();
+ const index = hookIndex++;
+ const previousDeps = record.deps[index];
+ if (!shallowEqualDeps(previousDeps, deps)) {
+ record.values[index] = callback;
+ record.deps[index] = deps;
+ }
+ return record.values[index] as T;
+}
+
+function useEffect(effect: HookEffect, deps?: unknown[]): void {
+ const record = getHookRecord();
+ const index = hookIndex++;
+ const previousDeps = record.deps[index];
+ if (!shallowEqualDeps(previousDeps, deps)) {
+ record.deps[index] = deps;
+ pendingEffects.push(() => {
+ effect();
+ });
+ }
+}
+
+function useMemo(factory: () => T, deps?: unknown[]): T {
+ const record = getHookRecord();
+ const index = hookIndex++;
+ const previousDeps = record.deps[index];
+ if (!shallowEqualDeps(previousDeps, deps)) {
+ record.values[index] = factory();
+ record.deps[index] = deps;
+ }
+ return record.values[index] as T;
+}
+
+function useRef(initialValue: T): { current: T } {
+ const record = getHookRecord();
+ const index = hookIndex++;
+ if (record.values[index] === undefined) {
+ record.values[index] = { current: initialValue };
+ }
+ return record.values[index] as { current: T };
+}
+
+function useState(initialValue: T | (() => T)): readonly [T, (next: T | ((prev: T) => T)) => void] {
+ const record = getHookRecord();
+ const index = hookIndex++;
+ if (record.values[index] === undefined) {
+ record.values[index] = typeof initialValue === 'function'
+ ? (initialValue as () => T)()
+ : initialValue;
+ }
+
+ const setState = (next: T | ((prev: T) => T)) => {
+ record.values[index] = typeof next === 'function'
+ ? (next as (prev: T) => T)(record.values[index] as T)
+ : next;
+ };
+
+ return [record.values[index] as T, setState] as const;
+}
+
+function jsx>(type: JSXElementType
, props: JSXProps & P): unknown {
+ if (type === reactJsxRuntime.Fragment) {
+ return props.children ?? null;
+ }
+
+ if (typeof type === 'function') {
+ return renderComponent(type, props as P);
+ }
+
+ return { type, props };
+}
+
+const ReactMock = {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+};
+
+const reactJsxRuntime = {
+ Fragment: Symbol('Fragment'),
+ jsx,
+ jsxs: jsx,
+ jsxDEV: jsx,
+};
+
+let desktopShell = false;
+let runtimeFetchRejects = true;
+
+mock.module('react/jsx-runtime', () => reactJsxRuntime);
+mock.module('react/jsx-dev-runtime', () => reactJsxRuntime);
+
+mock.module('react', () => ({
+ __esModule: true,
+ default: ReactMock,
+ ...ReactMock,
+}));
+
+mock.module('@simplewebauthn/browser', () => ({
+ browserSupportsWebAuthn: mock(() => false),
+}));
+
+mock.module('@/components/ui/button', () => ({
+ Button: ({ children }: { children?: unknown }) => children ?? null,
+}));
+
+mock.module('@/components/ui/checkbox', () => ({
+ Checkbox: () => null,
+}));
+
+mock.module('@/components/ui/input', () => ({
+ Input: () => null,
+}));
+
+mock.module('@/components/ui', () => ({
+ toast: {
+ success: mock(() => undefined),
+ error: mock(() => undefined),
+ message: mock(() => undefined),
+ },
+}));
+
+mock.module('@/components/ui/OpenChamberLogo', () => ({
+ OpenChamberLogo: () => 'logo',
+}));
+
+mock.module('@/components/icon/Icon', () => ({
+ Icon: () => null,
+}));
+
+mock.module('@/components/desktop/DesktopHostSwitcher', () => ({
+ DesktopHostSwitcherInline: () => 'host-switcher',
+}));
+
+mock.module('@/lib/i18n', () => ({
+ useI18n: () => ({ t: (key: string) => key }),
+}));
+
+mock.module('@/lib/desktop', () => ({
+ invokeDesktop: mock(() => Promise.resolve(null)),
+ isDesktopShell: mock(() => desktopShell),
+ isVSCodeRuntime: mock(() => false),
+}));
+
+mock.module('@/lib/persistence', () => ({
+ initializeAppearancePreferences: mock(() => Promise.resolve()),
+ syncDesktopSettings: mock(() => Promise.resolve()),
+}));
+
+mock.module('@/lib/directoryPersistence', () => ({
+ applyPersistedDirectoryPreferences: mock(() => Promise.resolve()),
+}));
+
+mock.module('@/lib/runtime-fetch', () => ({
+ runtimeFetch: mock(async () => {
+ if (runtimeFetchRejects) {
+ throw new Error('offline');
+ }
+
+ return new Response(JSON.stringify({ authenticated: false }), {
+ status: 401,
+ headers: { 'content-type': 'application/json' },
+ });
+ }),
+}));
+
+mock.module('@/lib/runtime-auth', () => ({
+ getRuntimeExtraHeadersSync: mock(() => ({})),
+}));
+
+mock.module('@/lib/runtime-switch', () => ({
+ getRuntimeApiBaseUrl: mock(() => ''),
+ subscribeRuntimeEndpointChanged: mock(() => () => {}),
+ switchRuntimeEndpoint: mock(() => undefined),
+}));
+
+mock.module('@/lib/desktopHosts', () => ({
+ desktopHostsGet: mock(() => Promise.resolve(null)),
+ desktopHostsSet: mock(() => Promise.resolve()),
+ getDesktopHostApiUrl: mock(() => ''),
+ normalizeHostUrl: mock(() => ''),
+}));
+
+mock.module('@/lib/passkeys', () => ({
+ authenticateWithPasskey: mock(() => Promise.resolve(null)),
+ cancelPasskeyCeremony: mock(() => undefined),
+ defaultPasskeyStatus: { enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null },
+ fetchPasskeyStatus: mock(() => Promise.resolve({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null })),
+ isPasskeyCeremonyAbort: mock(() => false),
+ registerCurrentDevicePasskey: mock(() => Promise.resolve(null)),
+}));
+
+const { SessionAuthGate } = await import('./SessionAuthGate');
+
+const flushEffects = async () => {
+ while (pendingEffects.length > 0) {
+ const effects = pendingEffects;
+ pendingEffects = [];
+ for (const effect of effects) {
+ effect();
+ }
+ await Promise.resolve();
+ }
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await Promise.resolve();
+};
+
+const renderGate = async () => {
+ const firstPass = renderComponent(SessionAuthGate, { children: 'child' });
+ await flushEffects();
+ const secondPass = renderComponent(SessionAuthGate, { children: 'child' });
+ await flushEffects();
+ return secondPass ?? firstPass;
+};
+
+const collectText = (node: unknown): string => {
+ if (node === null || node === undefined || typeof node === 'boolean') return '';
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
+ if (Array.isArray(node)) return node.map((child) => collectText(child)).join(' ');
+ if (typeof node === 'object') {
+ const element = node as { props?: { children?: unknown } };
+ return collectText(element.props?.children);
+ }
+ return '';
+};
+
+describe('SessionAuthGate status-check failure behavior', () => {
+ test('keeps non-desktop status-check rejection on the error screen', async () => {
+ resetHarness();
+ desktopShell = false;
+ runtimeFetchRejects = true;
+
+ const tree = await renderGate();
+ const text = collectText(tree);
+
+ expect(text).toContain('sessionAuth.error.networkTitle');
+ expect(text).not.toContain('sessionAuth.locked.unlockTitle');
+ });
+
+ test('keeps desktop-shell status-check rejection on the locked password prompt', async () => {
+ resetHarness();
+ desktopShell = true;
+ runtimeFetchRejects = true;
+
+ const tree = await renderGate();
+ const text = collectText(tree);
+
+ expect(text).toContain('sessionAuth.locked.unlockTitle');
+ expect(text).not.toContain('sessionAuth.error.networkTitle');
+ });
+});
diff --git a/packages/ui/src/components/auth/SessionAuthGate.test.ts b/packages/ui/src/components/auth/SessionAuthGate.test.ts
new file mode 100644
index 0000000000..543a6fbefd
--- /dev/null
+++ b/packages/ui/src/components/auth/SessionAuthGate.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, test } from 'bun:test';
+
+import { resolveStatusCheckFailureState } from './sessionAuthGateState';
+
+describe('resolveStatusCheckFailureState', () => {
+ test('keeps the desktop-shell password login fallback intact', () => {
+ expect(resolveStatusCheckFailureState({ shouldUseDesktopShellPasswordLogin: true })).toBe('locked');
+ });
+
+ test('uses the network error screen for non-desktop status-check failures', () => {
+ expect(resolveStatusCheckFailureState({})).toBe('error');
+ });
+});
diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx
index 9f4e23b123..558e77c812 100644
--- a/packages/ui/src/components/auth/SessionAuthGate.tsx
+++ b/packages/ui/src/components/auth/SessionAuthGate.tsx
@@ -12,8 +12,10 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
+import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
+import { resolveStatusCheckFailureState, type GateState } from './sessionAuthGateState';
import {
authenticateWithPasskey,
cancelPasskeyCeremony,
@@ -50,11 +52,30 @@ const shouldIssueDesktopClientToken = (): boolean => {
return isDesktopShell();
};
+const isLoopbackHostname = (hostname: string): boolean => {
+ const clean = hostname.replace(/^\[|\]$/g, '');
+ return clean === 'localhost' || clean === '127.0.0.1' || clean === '::1';
+};
+
const isLocalDesktopRuntime = (): boolean => {
if (!isDesktopShell()) return false;
- const apiBaseUrl = getRuntimeApiBaseUrl();
const localOrigin = readLocalOrigin();
- return Boolean(localOrigin && sameOrigin(localOrigin, apiBaseUrl));
+ if (!localOrigin) return false;
+ // An empty api base means same-origin requests against the page itself —
+ // which on desktop IS the embedded local server. Requiring an exact origin
+ // match here used to leave local client tokens untagged (no desktop-local
+ // clientKind), and the server's client-create gate then 403'd them.
+ const apiBaseUrl = getRuntimeApiBaseUrl();
+ const effectiveTarget = apiBaseUrl || (typeof window !== 'undefined' ? window.location.origin : '');
+ if (sameOrigin(localOrigin, effectiveTarget)) return true;
+ // Loopback aliases (localhost vs 127.0.0.1) still address this machine's
+ // own server.
+ try {
+ const normalized = normalizeHostUrl(effectiveTarget);
+ return Boolean(normalized && isLoopbackHostname(new URL(normalized).hostname));
+ } catch {
+ return false;
+ }
};
const desktopClientAuthMetadata = (): { clientKind?: string; dedupeKey?: string } => {
@@ -129,20 +150,30 @@ const shouldUseDesktopShellPasswordLogin = (): boolean => {
return isDesktopShell() && !isLocalDesktopRuntime();
};
-const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise => {
+type DesktopPasswordLoginResult = {
+ token: string;
+ status?: number;
+};
+
+const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise => {
if (!isDesktopShell() || typeof window === 'undefined') {
- return '';
+ return null;
}
const response = await invokeDesktop('desktop_remote_password_login', {
url: getRuntimeApiBaseUrl(),
password,
trustDevice,
+ requestHeaders: getRuntimeExtraHeadersSync(),
}).catch(() => null);
if (!response || typeof response !== 'object') {
- return '';
+ return null;
}
const token = (response as { token?: unknown }).token;
- return typeof token === 'string' ? token.trim() : '';
+ const status = (response as { status?: unknown }).status;
+ return {
+ token: typeof token === 'string' ? token.trim() : '',
+ ...(typeof status === 'number' ? { status } : {}),
+ };
};
const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string): Promise => {
@@ -180,8 +211,13 @@ const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string
const applyDesktopClientToken = async (clientToken: string): Promise => {
if (!clientToken) return;
const apiBaseUrl = getRuntimeApiBaseUrl();
+ const requestHeaders = getRuntimeExtraHeadersSync();
await persistDesktopClientToken(apiBaseUrl, clientToken);
- switchRuntimeEndpoint({ apiBaseUrl, clientToken });
+ switchRuntimeEndpoint({
+ apiBaseUrl,
+ clientToken,
+ requestHeaders: Object.keys(requestHeaders).length > 0 ? requestHeaders : null,
+ });
};
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -257,8 +293,6 @@ interface SessionAuthGateProps {
children: React.ReactNode;
}
-type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited';
-
interface ErrorScreenProps {
onRetry: () => void;
errorType?: 'network' | 'rate-limit';
@@ -266,7 +300,9 @@ interface ErrorScreenProps {
children?: React.ReactNode;
}
-export const SessionAuthGate: React.FC = ({ children }) => {
+export const SessionAuthGate: React.FC = ({
+ children,
+}) => {
const { t } = useI18n();
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
const skipAuth = vscodeRuntime;
@@ -387,7 +423,7 @@ export const SessionAuthGate: React.FC = ({ children }) =>
setIsTunnelLocked(false);
} catch (error) {
console.warn('Failed to check session status:', error);
- if (shouldUseDesktopShellPasswordLogin()) {
+ if (resolveStatusCheckFailureState({ shouldUseDesktopShellPasswordLogin: shouldUseDesktopShellPasswordLogin() }) === 'locked') {
setState('locked');
setRetryAfter(undefined);
setIsTunnelLocked(false);
@@ -486,15 +522,43 @@ export const SessionAuthGate: React.FC = ({ children }) =>
setErrorMessage('');
try {
+ if (shouldUseDesktopShellPasswordLogin()) {
+ const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice);
+ if (shellLogin?.token) {
+ setPassword('');
+ setIsTunnelLocked(false);
+ await applyDesktopClientToken(shellLogin.token);
+ setState('authenticated');
+ return;
+ }
+ if (shellLogin?.status === 401) {
+ setErrorMessage(t('sessionAuth.error.incorrectPassword'));
+ setIsTunnelLocked(false);
+ setState('locked');
+ return;
+ }
+ if (shellLogin?.status === 429) {
+ setRetryAfter(undefined);
+ setIsTunnelLocked(false);
+ setState('rate-limited');
+ return;
+ }
+ }
+
const response = await submitPassword(password, trustDevice);
if (response.ok) {
const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null;
const shouldUseClientToken = shouldIssueDesktopClientToken();
- const clientToken = shouldUseClientToken
- ? (typeof payload?.clientToken === 'string' && payload.clientToken.trim()
+ let clientToken = '';
+ if (shouldUseClientToken) {
+ clientToken = typeof payload?.clientToken === 'string' && payload.clientToken.trim()
? payload.clientToken.trim()
- : await issueDesktopClientTokenViaShell(password, trustDevice) || await issueDesktopClientToken())
- : '';
+ : '';
+ if (!clientToken) {
+ const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice);
+ clientToken = shellLogin?.token || await issueDesktopClientToken();
+ }
+ }
setPassword('');
setIsTunnelLocked(false);
if (clientToken) {
@@ -541,16 +605,28 @@ export const SessionAuthGate: React.FC = ({ children }) =>
setState('error');
} catch (error) {
console.warn('Failed to submit UI password:', error);
- const clientToken = shouldUseDesktopShellPasswordLogin()
+ const shellLogin = shouldUseDesktopShellPasswordLogin()
? await issueDesktopClientTokenViaShell(password, trustDevice)
- : '';
- if (clientToken) {
+ : null;
+ if (shellLogin?.token) {
setPassword('');
setIsTunnelLocked(false);
- await applyDesktopClientToken(clientToken);
+ await applyDesktopClientToken(shellLogin.token);
setState('authenticated');
return;
}
+ if (shellLogin?.status === 401) {
+ setErrorMessage(t('sessionAuth.error.incorrectPassword'));
+ setIsTunnelLocked(false);
+ setState('locked');
+ return;
+ }
+ if (shellLogin?.status === 429) {
+ setRetryAfter(undefined);
+ setIsTunnelLocked(false);
+ setState('rate-limited');
+ return;
+ }
setErrorMessage(t('sessionAuth.error.networkRetry'));
setIsTunnelLocked(false);
setState('error');
diff --git a/packages/ui/src/components/auth/sessionAuthGateState.ts b/packages/ui/src/components/auth/sessionAuthGateState.ts
new file mode 100644
index 0000000000..258d4acc76
--- /dev/null
+++ b/packages/ui/src/components/auth/sessionAuthGateState.ts
@@ -0,0 +1,11 @@
+export type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited';
+
+export const resolveStatusCheckFailureState = (options: {
+ shouldUseDesktopShellPasswordLogin?: boolean;
+}): Exclude => {
+ if (options.shouldUseDesktopShellPasswordLogin) {
+ return 'locked';
+ }
+
+ return 'error';
+};
diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx
index b73c52ee35..883b832e9a 100644
--- a/packages/ui/src/components/chat/ChatContainer.tsx
+++ b/packages/ui/src/components/chat/ChatContainer.tsx
@@ -14,6 +14,7 @@ import MessageList, { type MessageListHandle } from './MessageList';
import { PermissionCard } from './PermissionCard';
import { QuestionCard } from './QuestionCard';
import { StatusRowContainer } from './StatusRowContainer';
+import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
@@ -46,6 +47,7 @@ import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-pre
import { getSessionMaterializationStatus } from '@/sync/materialization';
import { usePlanDetection } from '@/hooks/usePlanDetection';
import { useI18n } from '@/lib/i18n';
+import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { isVSCodeRuntime } from '@/lib/desktop';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
@@ -157,6 +159,8 @@ type ChatViewportProps = {
sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[];
isProgrammaticFollowActive: boolean;
+ showLoadOlderButton: boolean;
+ onLoadOlder: () => void;
};
const ChatViewport = React.memo(({
@@ -181,7 +185,10 @@ const ChatViewport = React.memo(({
sessionQuestions,
sessionPermissions,
isProgrammaticFollowActive,
+ showLoadOlderButton,
+ onLoadOlder,
}: ChatViewportProps) => {
+ const { t } = useI18n();
const focusScrollContainer = React.useCallback((event: React.MouseEvent) => {
if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) {
return;
@@ -218,6 +225,21 @@ const ChatViewport = React.memo(({
data-scrollbar="chat"
>
+ {showLoadOlderButton && (
+
+
+ {isLoadingOlder && (
+
+ )}
+ {t('chat.history.loadOlder')}
+
+
+ )}
)}
+
+
@@ -277,7 +301,9 @@ const ChatViewport = React.memo(({
&& prev.scrollToBottom === next.scrollToBottom
&& prev.sessionQuestions === next.sessionQuestions
&& prev.sessionPermissions === next.sessionPermissions
- && prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive;
+ && prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
+ && prev.showLoadOlderButton === next.showLoadOlderButton
+ && prev.onLoadOlder === next.onLoadOlder;
});
ChatViewport.displayName = 'ChatViewport';
@@ -499,10 +525,17 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr
// History metadata — use sync's hasMore/isLoading
const historyMeta = React.useMemo(() => {
if (!currentSessionId) return null;
- const prefetchHasMore = Boolean(sessionPrefetchInfo?.cursor) && sessionPrefetchInfo?.complete !== true;
+ // Sync's meta is authoritative once a fetch has confirmed the history
+ // is fully loaded — a stale prefetch-cache entry (cursor recorded at
+ // the initial page) must not keep the "load older" affordance alive
+ // after the user has already reached the top.
+ const syncComplete = sync.isComplete(currentSessionId);
+ const prefetchHasMore = !syncComplete
+ && Boolean(sessionPrefetchInfo?.cursor)
+ && sessionPrefetchInfo?.complete !== true;
return {
limit: sessionMessages.length,
- complete: !(sync.hasMore(currentSessionId) || prefetchHasMore),
+ complete: syncComplete || !(sync.hasMore(currentSessionId) || prefetchHasMore),
loading: sync.isLoading(currentSessionId),
};
}, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]);
@@ -512,7 +545,9 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr
const chatSurfaceMode = useChatSurfaceMode();
const draftOpen = Boolean(newSessionDraft?.open);
const initError = useGlobalSyncStore((s) => s.error);
- const isDesktopExpandedInput = isExpandedInput && !isMobile;
+ // Despite the historical name, this now covers mobile too: the mobile
+ // composer enters the same fullscreen-input mode via its drag handle.
+ const isDesktopExpandedInput = isExpandedInput;
const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat';
const messageListRef = React.useRef(null);
const draftProjectLabel = React.useMemo(() => {
@@ -599,6 +634,14 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr
const resumeToLatestInstant = React.useCallback(() => {
goToBottom('instant');
}, [goToBottom]);
+ // Mobile loads older history via an explicit top button instead of a
+ // scroll-position trigger (see handleHistoryScroll in the controller).
+ const showLoadOlderButton = isMobileSurfaceRuntime()
+ && timelineController.historySignals.canLoadEarlier;
+ const timelineLoadEarlier = timelineController.loadEarlier;
+ const handleLoadOlderClick = React.useCallback(() => {
+ void timelineLoadEarlier({ userInitiated: true });
+ }, [timelineLoadEarlier]);
React.useEffect(() => {
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
@@ -766,9 +809,12 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr
if (!currentSessionId && draftOpen) {
return (
-
+ // No transform on this root: it would become the containing block for
+ // the fullscreen composer's position:fixed visual-viewport pinning in
+ // mobile browsers (see ChatInput's composerFormRef effect).
+
{useCompactDraftLayout && !isDesktopExpandedInput ? (
-
+
{renderDraftTitle(
draftProjectLabel
@@ -779,7 +825,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr
useInputStore.getState().requestPresetSubmit(text)}
- className="mt-8 max-w-md"
+ className="oc-draft-starters mt-8 max-w-md"
/>
) : null}
@@ -861,7 +907,9 @@ export const ChatContainer: React.FC
= ({ autoOpenDraft = tr
if (sessionMessages.length === 0 && !sessionIsWorking) {
return (
-
+ // No transform here either — same fixed-positioning constraint as the
+ // draft branch above.
+
{returnToParentButton}
= ({ autoOpenDraft = tr
sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions}
isProgrammaticFollowActive={isFollowingProgrammatically}
+ showLoadOlderButton={showLoadOlderButton}
+ onLoadOlder={handleLoadOlderClick}
/>
= ({ autoOpenDraft = tr
onScrollToMessage={timelineController.scrollToMessage}
onScrollByTurnOffset={navigation.scrollByTurnOffset}
onResumeToLatest={resumeToLatestInstant}
+ canLoadEarlier={timelineController.historySignals.canLoadEarlier}
+ isLoadingEarlier={timelineController.isLoadingOlder}
+ onLoadEarlier={handleLoadOlderClick}
/>
);
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx
index 91e972c693..f7c8eae211 100644
--- a/packages/ui/src/components/chat/ChatInput.tsx
+++ b/packages/ui/src/components/chat/ChatInput.tsx
@@ -1,6 +1,8 @@
import React from 'react';
+import { flushSync } from 'react-dom';
+import { isCapacitorApp } from '@/lib/platform';
import { Textarea } from '@/components/ui/textarea';
-import { BrowserVoiceButton } from '@/components/voice';
+import { ComposerDictation } from '@/components/dictation/ComposerDictation';
// sessionStore removed — currentSessionId comes from useSessionUIStore
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -53,6 +55,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
@@ -94,6 +98,7 @@ import {
findAttachmentCitationRanges,
} from './attachmentCitations';
import { getFileMentionAutocompleteQuery, type FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState';
+import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
import type { Part } from '@opencode-ai/sdk/v2/client';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
@@ -381,7 +386,7 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
};
const MemoModelControls = React.memo(ModelControls);
-const MemoBrowserVoiceButton = React.memo(BrowserVoiceButton);
+const MemoComposerDictation = React.memo(ComposerDictation);
const MemoMobileAgentButton = React.memo(MobileAgentButton);
const MemoMobileModelButton = React.memo(MobileModelButton);
const MemoStatusRow = React.memo(StatusRow);
@@ -528,6 +533,9 @@ type ComposerAttachmentControlsProps = {
openIssuePicker: () => void;
openPrPicker: () => void;
onOpenSettings?: () => void;
+ onMenuOpenChange?: (open: boolean) => void;
+ /** Mobile: open the attachment bottom sheet instead of the dropdown menu. */
+ onOpenMobileSheet?: () => void;
};
const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) {
@@ -556,7 +564,17 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
/>
- {isVSCode ? (
+ {props.onOpenMobileSheet ? (
+
+
+
+ ) : isVSCode ? (
) : (
-
+
= ({ onOpenSettings, scrollTo
const [snippetQuery, setSnippetQuery] = React.useState('');
const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
const [mobileControlsPanel, setMobileControlsPanel] = React.useState(null);
+ // Mobile pill composer: when the keyboard is closed the composer collapses
+ // into a narrow pill (+ / placeholder / mic) with a round new-session button
+ // beside it. Any interaction expands back into the full composer. The swap
+ // is deliberately INSTANT and synchronized with the keyboard choreography,
+ // so the chat compensates keyboard + composer height in a single motion.
+ const [mobileComposerExpanded, setMobileComposerExpanded] = React.useState(false);
+ const [mobileTextareaFocused, setMobileTextareaFocused] = React.useState(false);
+ const [mobileDictationActive, setMobileDictationActive] = React.useState(false);
+ const [mobileAttachMenuOpen, setMobileAttachMenuOpen] = React.useState(false);
+ const [mobileDraftPicker, setMobileDraftPicker] = React.useState<'project' | 'branch' | null>(null);
+ const [mobileDraftPickerQuery, setMobileDraftPickerQuery] = React.useState('');
+ // True while ANY MobileOverlayPanel is open (sessions sheet, model/agent
+ // panels, pickers...). Opening one closes the keyboard, which must not
+ // collapse the composer into the pill under the overlay.
+ const [mobileOverlayHostBusy, setMobileOverlayHostBusy] = React.useState(false);
+ // Set while an expansion is settling (focus/dictation not yet active) so the
+ // collapse watcher doesn't immediately fold the composer back into the pill.
+ const mobileExpandIntentRef = React.useRef<'focus' | null>(null);
+ // Keyboard restore across overlays: opening an overlay closes the keyboard;
+ // if it was open at that moment, reopen it when the overlay closes.
+ const lastMobileBlurAtRef = React.useRef(0);
+ const restoreKeyboardAfterOverlayRef = React.useRef(false);
+ // Pill ↔ full composer morph: the wrapper FLIP-animates its height between
+ // the two shapes while the swapped content fades in.
+ const composerHandleTouchRef = React.useRef<{ startY: number; fired: boolean } | null>(null);
// Message history navigation state (up/down arrow to recall previous messages)
const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent
const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
@@ -1029,6 +1074,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
+ const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const availableWorktreesByProject = useSessionUIStore((s) => s.availableWorktreesByProject);
const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId);
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
@@ -1146,7 +1192,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
}, [currentSessionId, currentDirectory, t]);
const isDesktopExpanded = isExpandedInput && !isMobile;
- const chatInputRadius = 'var(--radius-xl)';
+ // Mobile fullscreen composer (entered via the drag handle's swipe-up).
+ const isMobileExpanded = isExpandedInput && isMobile;
+ const isComposerExpanded = isDesktopExpanded || isMobileExpanded;
+ // Rounder composer on mobile (touch UI reads better with a softer corner).
+ const chatInputRadius = isMobile ? '1.5rem' : 'var(--radius-xl)';
const useCompactChatPlaceholder = isMobile || isNarrowComposer;
React.useEffect(() => {
@@ -1420,7 +1470,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
} | null>(null);
// Message queue
- const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
+ const followUpBehavior = useMessageQueueStore((state) => state.followUpBehavior);
const queuedMessages = useMessageQueueStore(
React.useCallback(
(state) => {
@@ -1649,10 +1699,12 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
if (!isMobile) {
return;
}
+ // Set the panel state BEFORE blurring: the collapse watcher and the
+ // overlay-host observer must already see the overlay as open when the
+ // keyboard-close lands, otherwise the composer folds into the pill
+ // under the sheet.
+ setMobileControlsPanel(panel);
textareaRef.current?.blur();
- requestAnimationFrame(() => {
- setMobileControlsPanel(panel);
- });
}, [isMobile]);
// Consume pending input text (e.g., from revert action)
@@ -1697,6 +1749,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
type SubmitOptions = {
queuedOnly?: boolean;
queuedMessageId?: string;
+ delivery?: 'steer';
+ /** Submit this text instead of the composer input. Used by preset
+ starter chips: on mobile the collapsed pill has no mounted textarea,
+ so the DOM-first input snapshot would read empty content. */
+ presetText?: string;
};
const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise>(async () => {});
@@ -1746,7 +1803,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
}, []);
const handleQueuedMessageSend = React.useCallback((messageId: string) => {
- void handleSubmitRef.current({ queuedOnly: true, queuedMessageId: messageId });
+ // Force-sending from the queue during a busy session counts as steer
+ void handleSubmitRef.current({ queuedOnly: true, queuedMessageId: messageId, delivery: 'steer' });
}, []);
const handleOpenAgentPanel = React.useCallback(() => {
@@ -1768,7 +1826,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
const handleSubmit = async (options?: SubmitOptions) => {
const queuedOnly = options?.queuedOnly ?? false;
const queuedMessageId = options?.queuedMessageId;
- const inputSnapshot = getCurrentInputSnapshot();
+ const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
+ const inputSnapshot = options?.presetText != null
+ ? {
+ message: options.presetText,
+ hasContent: options.presetText.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts,
+ }
+ : getCurrentInputSnapshot();
const queuedMessagesToSend = queuedMessageId
? queuedMessages.filter((message) => message.id === queuedMessageId)
: queuedMessages;
@@ -1816,6 +1880,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
}
}
+ const sendMessageOptions = delivery ? { delivery } : undefined;
+
// Build the primary message (first part) and additional parts
let primaryText = '';
let primaryAttachments: AttachedFile[] = [];
@@ -2024,6 +2090,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
[{ text: instructionsText, synthetic: true }],
variantToSend,
inputMode,
+ sendMessageOptions,
);
scrollToBottom?.();
} catch (error) {
@@ -2046,6 +2113,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
[{ text: instructionsText, synthetic: true }],
variantToSend,
inputMode,
+ sendMessageOptions,
);
scrollToBottom?.();
} catch (error) {
@@ -2072,6 +2140,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
[{ text: instructionsText, synthetic: true }],
variantToSend,
inputMode,
+ sendMessageOptions,
);
scrollToBottom?.();
} catch (error) {
@@ -2094,6 +2163,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
[{ text: instructionsText, synthetic: true }],
variantToSend,
inputMode,
+ sendMessageOptions,
);
scrollToBottom?.();
} catch (error) {
@@ -2116,6 +2186,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
[{ text: instructionsText, synthetic: true }],
variantToSend,
inputMode,
+ sendMessageOptions,
);
scrollToBottom?.();
} catch (error) {
@@ -2138,6 +2209,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
[{ text: instructionsText, synthetic: true }],
variantToSend,
inputMode,
+ sendMessageOptions,
);
scrollToBottom?.();
} catch (error) {
@@ -2160,6 +2232,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
[{ text: instructionsText, synthetic: true }],
variantToSend,
inputMode,
+ sendMessageOptions,
);
scrollToBottom?.();
} catch (error) {
@@ -2208,7 +2281,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
agentMentionName,
additionalParts.length > 0 ? additionalParts : undefined,
variantToSend,
- inputMode
+ inputMode,
+ sendMessageOptions,
);
if (typeof window === 'undefined') {
@@ -2279,27 +2353,50 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
// Update ref with latest handleSubmit on every render
handleSubmitRef.current = handleSubmit;
- // Primary action for send button - respects queue mode setting
+ // Primary action for send/queue button — respects selected follow-up behavior
const handlePrimaryAction = React.useCallback(() => {
const inputSnapshot = getCurrentInputSnapshot();
const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
- if (queueModeEnabled && canQueue) {
+ if (followUpBehavior === 'queue' && canQueue) {
handleQueueMessage();
+ } else if (followUpBehavior === 'steer' && canQueue) {
+ void handleSubmitRef.current({ delivery: 'steer' });
} else {
void handleSubmitRef.current();
}
- }, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, queueModeEnabled, handleQueueMessage]);
+ }, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage]);
- // Draft welcome presets: populate the composer and submit immediately.
- // getCurrentInputSnapshot reads textareaRef.current.value first, so setting it
- // synchronously lets handleSubmit pick up the preset text in the same tick.
+ // Draft welcome presets: submit immediately.
const submitPresetPrompt = React.useCallback((text: string) => {
- const textarea = textareaRef.current;
- if (textarea) {
- textarea.value = text;
- }
- setMessage(text);
- void handleSubmitRef.current();
+ // The text goes straight into the submit (see SubmitOptions.presetText)
+ // instead of through the composer input — the collapsed mobile pill has
+ // no mounted textarea to stage it in.
+ void handleSubmitRef.current({ presetText: text });
+ }, []);
+
+ // Dictation: insert the transcript inline; optionally submit immediately.
+ // getCurrentInputSnapshot reads textareaRef.current.value first, so setting
+ // it synchronously lets handleSubmit pick up the text in the same tick.
+ const handleDictationInsert = React.useCallback((text: string) => {
+ setMessage((prev) => {
+ const next = appendInlineText(prev, text);
+ const textarea = textareaRef.current;
+ if (textarea) {
+ textarea.value = next;
+ }
+ return next;
+ });
+ setTimeout(() => {
+ textareaRef.current?.focus();
+ }, 0);
+ }, []);
+
+ const handleDictationInsertAndSend = React.useCallback((text: string) => {
+ // Same as preset chips: the composed text goes into the submit as an
+ // explicit override instead of being staged in the textarea, which may
+ // not be mounted (collapsed mobile pill).
+ const next = appendInlineText(textareaRef.current?.value ?? messageRef.current, text);
+ void handleSubmitRef.current({ presetText: next });
}, []);
// Preset chips rendered outside this component (e.g. under the welcome
@@ -2527,33 +2624,28 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
return;
}
- // Handle Enter/Ctrl+Enter based on queue mode
+ // Handle Enter/Ctrl+Enter based on selected follow-up behavior.
if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) {
e.preventDefault();
const isCtrlEnter = e.ctrlKey || e.metaKey;
- // Queue mode: Enter queues, Ctrl+Enter sends
- // Normal mode: Enter sends, Ctrl+Enter queues
- // Note: Queueing only works when there's an existing session (currentSessionId)
- // For new sessions (draft), always send immediately
+ // Queueing / steering only works when there's an existing busy
+ // session (or an active auto-review run).
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
- if (queueModeEnabled) {
+ if (followUpBehavior === 'queue') {
if (isCtrlEnter || !canQueue) {
- // Ctrl+Enter sends, or Enter when can't queue (new session)
handleSubmit();
} else {
- // Enter queues when we have a session
handleQueueMessage();
}
} else {
- if (isCtrlEnter && canQueue) {
- // Ctrl+Enter queues when we have a session
- handleQueueMessage();
- } else {
- // Enter sends
+ // steer: Enter steers into the running turn, Ctrl+Enter sends now.
+ if (isCtrlEnter || !canQueue) {
handleSubmit();
+ } else {
+ handleSubmit({ delivery: 'steer' });
}
}
}
@@ -2722,7 +2814,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
const previousScrollTop = textarea.scrollTop;
- if (isDesktopExpanded) {
+ if (isComposerExpanded) {
textarea.style.height = '100%';
textarea.style.maxHeight = 'none';
setTextareaSize(null);
@@ -2763,7 +2855,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
}
return { height: nextHeight, maxHeight };
});
- }, [isDesktopExpanded]);
+ }, [isComposerExpanded]);
React.useLayoutEffect(() => {
const allowShrink = message.length < previousMessageLengthRef.current;
@@ -3945,6 +4037,347 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
});
}, [draftBranchItems, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, newSessionDraft?.preserveDirectoryOverride, selectedDraftDirectory, selectedDraftProject, setNewSessionDraftTarget, showDraftTargetSelectors]);
+ // ── Mobile pill composer state machine ─────────────────────────────────
+ const expandMobileComposer = React.useCallback((intent: 'focus') => {
+ mobileExpandIntentRef.current = intent;
+ // flushSync so the textarea exists NOW and focus() still runs inside
+ // the user gesture's call stack: mobile browsers only open the soft
+ // keyboard for focus calls made synchronously from the tap (an rAF
+ // here worked in the Capacitor WebView but not in Safari/Chrome).
+ flushSync(() => {
+ setMobileComposerExpanded(true);
+ });
+ // Capacitor: our keyboard choreography positions everything, so the
+ // browser's own scroll-into-view must stay off. Mobile BROWSERS have no
+ // choreography — the native reveal (viewport pan that lifts the focused
+ // field above the keyboard) is the only thing that moves the composer.
+ textareaRef.current?.focus({ preventScroll: isCapacitorApp() });
+ }, []);
+
+ const applyAssistSuggestion = React.useCallback((text: string) => {
+ setMessage(text);
+ if (isMobile && !mobileComposerExpanded) {
+ expandMobileComposer('focus');
+ } else {
+ requestAnimationFrame(() => textareaRef.current?.focus());
+ }
+ }, [expandMobileComposer, isMobile, mobileComposerExpanded]);
+
+
+ const handleMobileNewSession = React.useCallback(() => {
+ if (newSessionDraftOpen) return;
+ openNewSessionDraft(currentDirectory ? { directoryOverride: currentDirectory } : undefined);
+ }, [newSessionDraftOpen, openNewSessionDraft, currentDirectory]);
+
+ const openMobileAttachSheet = React.useCallback(() => {
+ setMobileAttachMenuOpen(true);
+ }, []);
+
+ const mobileComposerExpandedRef = React.useRef(mobileComposerExpanded);
+ React.useEffect(() => {
+ mobileComposerExpandedRef.current = mobileComposerExpanded;
+ });
+
+ const handleMobileDictationActiveChange = React.useCallback((active: boolean) => {
+ setMobileDictationActive(active);
+ if (active) {
+ mobileExpandIntentRef.current = null;
+ // Dictation engine went live (possibly started from the pill):
+ // switch straight into the voice variant of the full composer.
+ if (!mobileComposerExpandedRef.current) {
+ setMobileComposerExpanded(true);
+ }
+ return;
+ }
+ // Dictation ended. The insert flow hands focus back to the textarea a
+ // tick later — if that happened, stay expanded; otherwise (cancel,
+ // discard, insert-and-send) collapse straight back to the pill without
+ // parking on the normal composer for the usual grace period.
+ window.setTimeout(() => {
+ if (!mobileComposerExpandedRef.current) return;
+ if (document.activeElement === textareaRef.current) return;
+ setMobileComposerExpanded(false);
+ setExpandedInput(false);
+ }, 30);
+ }, [setExpandedInput]);
+
+ // Watch the shared overlay portal root: any mounted MobileOverlayPanel
+ // (sessions sheet, model/agent panels, draft pickers, ...) counts as busy.
+ // Observing the host catches overlays whose open-state lives in other
+ // components without threading their state here.
+ React.useEffect(() => {
+ if (!isMobile || typeof document === 'undefined') return;
+ let host = document.getElementById('mobile-overlay-root');
+ if (!host) {
+ // Same lazy-create contract as MobileOverlayPanel's ensureOverlayRoot.
+ host = document.createElement('div');
+ host.id = 'mobile-overlay-root';
+ document.body.appendChild(host);
+ }
+ const hostEl = host;
+ const update = () => setMobileOverlayHostBusy(hostEl.childElementCount > 0);
+ update();
+ const observer = new MutationObserver(update);
+ observer.observe(hostEl, { childList: true });
+ return () => observer.disconnect();
+ }, [isMobile]);
+
+ // If the keyboard was open (or closed just moments ago by the overlay's own
+ // blur) when an overlay appeared, bring it back once every overlay is gone.
+ // The attach dropdown and the GitHub issue/PR pickers join the same chain,
+ // so menu → picker → close restores the keyboard at the end of the flow.
+ const mobileOverlayOpen = mobileOverlayHostBusy
+ || Boolean(mobileControlsPanel)
+ || mobileAttachMenuOpen
+ || issuePickerOpen
+ || prPickerOpen;
+ React.useEffect(() => {
+ if (!isMobile) return;
+ if (mobileOverlayOpen) {
+ if (mobileTextareaFocused || Date.now() - lastMobileBlurAtRef.current < 800) {
+ restoreKeyboardAfterOverlayRef.current = true;
+ }
+ return;
+ }
+ if (!restoreKeyboardAfterOverlayRef.current) return;
+ // Debounced: overlay chains hand off with a frame of "nothing open"
+ // between steps (attach sheet closes → issue/PR picker opens a frame
+ // later). Restoring instantly in that gap would pop the keyboard open
+ // inside the next overlay — wait out the gap and cancel if another
+ // overlay appears.
+ const timer = window.setTimeout(() => {
+ restoreKeyboardAfterOverlayRef.current = false;
+ // Browsers need their native scroll-into-view (see expandMobileComposer).
+ textareaRef.current?.focus({ preventScroll: isCapacitorApp() });
+ }, 180);
+ return () => window.clearTimeout(timer);
+ }, [isMobile, mobileOverlayOpen, mobileTextareaFocused]);
+
+ // Fold the full composer back into the pill once nothing keeps it open:
+ // keyboard closed (textarea blurred), no dictation, no sheet/menu/dialog.
+ // The short delay bridges focus moving between composer controls.
+ const mobileComposerBusy = mobileTextareaFocused
+ || mobileOverlayHostBusy
+ || mobileDictationActive
+ || Boolean(mobileControlsPanel)
+ || mobileAttachMenuOpen
+ || mobileDraftPicker !== null
+ || issuePickerOpen
+ || prPickerOpen
+ || isDragging;
+ React.useEffect(() => {
+ if (!isMobile || !mobileComposerExpanded || mobileComposerBusy) return;
+ const timer = window.setTimeout(() => {
+ mobileExpandIntentRef.current = null;
+ setMobileComposerExpanded(false);
+ setExpandedInput(false);
+ }, 250);
+ return () => window.clearTimeout(timer);
+ }, [isMobile, mobileComposerExpanded, mobileComposerBusy, setExpandedInput]);
+
+ const mobileComposerBusyRef = React.useRef(false);
+ mobileComposerBusyRef.current = mobileComposerBusy;
+
+ // Browser counterpart of Capacitor's oc-keyboard-open root class (which is
+ // driven by native keyboard events): the focused composer textarea is the
+ // best keyboard proxy a browser has. CSS keyed on it hides the draft
+ // starters while typing, mirroring the native app.
+ React.useEffect(() => {
+ if (!isMobile || isCapacitorApp() || typeof document === 'undefined') return;
+ const root = document.documentElement;
+ if (mobileTextareaFocused) {
+ root.classList.add('oc-browser-keyboard-open');
+ } else {
+ root.classList.remove('oc-browser-keyboard-open');
+ }
+ return () => root.classList.remove('oc-browser-keyboard-open');
+ }, [isMobile, mobileTextareaFocused]);
+
+ // Capacitor: collapse in the SAME frame the keyboard starts hiding. The
+ // hide choreography dispatches oc:keyboard-intent BEFORE restoring the
+ // shell layout and measuring the chat compensation; flushSync commits the
+ // pill swap first, so keyboard land + composer shrink are measured (and
+ // compensated) as ONE motion instead of a two-step staircase. The delayed
+ // effect above stays as the fallback for non-Capacitor and for overlays
+ // closing without a keyboard transition.
+ React.useEffect(() => {
+ if (!isMobile || typeof window === 'undefined') return;
+ const handleIntent = (event: Event) => {
+ const detail = (event as CustomEvent<{ open?: boolean }>).detail;
+ if (!detail || detail.open !== false) return;
+ if (!mobileComposerExpandedRef.current) return;
+ // Something still holds the composer open (dictation, an overlay
+ // that closed the keyboard, drag) — the fallback path handles it.
+ if (mobileComposerBusyRef.current) return;
+ mobileExpandIntentRef.current = null;
+ flushSync(() => {
+ setMobileComposerExpanded(false);
+ setExpandedInput(false);
+ });
+ };
+ window.addEventListener('oc:keyboard-intent', handleIntent);
+ return () => window.removeEventListener('oc:keyboard-intent', handleIntent);
+ }, [isMobile, setExpandedInput]);
+
+ // Reset the picker search whenever a draft picker sheet opens/closes.
+ React.useEffect(() => {
+ setMobileDraftPickerQuery('');
+ }, [mobileDraftPicker]);
+
+
+ // ── Composer drag handle (mobile): swipe up = fullscreen, swipe down =
+ // leave fullscreen or dismiss the keyboard. ────────────────────────────
+ const handleComposerHandleTouchStart = React.useCallback((event: React.TouchEvent) => {
+ const touch = event.touches.item(0);
+ composerHandleTouchRef.current = touch ? { startY: touch.clientY, fired: false } : null;
+ }, []);
+ const handleComposerHandleTouchMove = React.useCallback((event: React.TouchEvent) => {
+ const state = composerHandleTouchRef.current;
+ if (!state || state.fired) return;
+ const touch = event.touches.item(0);
+ if (!touch) return;
+ const dy = touch.clientY - state.startY;
+ if (dy <= -28) {
+ state.fired = true;
+ if (!isExpandedInput) setExpandedInput(true);
+ } else if (dy >= 28) {
+ state.fired = true;
+ if (isExpandedInput) {
+ setExpandedInput(false);
+ } else {
+ textareaRef.current?.blur();
+ }
+ }
+ }, [isExpandedInput, setExpandedInput]);
+ const handleComposerHandleTouchEnd = React.useCallback(() => {
+ composerHandleTouchRef.current = null;
+ }, []);
+
+ // Fullscreen composer in a mobile BROWSER: the page layout doesn't shrink
+ // for the keyboard there — Safari pans/scrolls instead, so any flow-based
+ // sizing ends up partly off-screen or under the keyboard (the chat page is
+ // usually already panned when fullscreen is entered). Pin the form to the
+ // VISUAL viewport directly: fixed at its offset with its height, updated
+ // as the browser pans. Capacitor is excluded — its shell already resizes
+ // via the keyboard choreography.
+ const composerFormRef = React.useRef(null);
+ React.useLayoutEffect(() => {
+ if (!isMobile || !isMobileExpanded || isCapacitorApp()) return;
+ const vv = window.visualViewport;
+ const form = composerFormRef.current;
+ const textarea = textareaRef.current;
+ if (!vv || !form) return;
+ // The form is trapped inside lower stacking contexts (the composer
+ // wrapper's z-10), so it cannot out-stack the app header with z-index
+ // alone — hide the header for the duration via a root class instead.
+ document.documentElement.classList.add('oc-browser-kb-fullscreen');
+ const apply = () => {
+ form.style.position = 'fixed';
+ form.style.left = '0';
+ form.style.right = '0';
+ form.style.top = `${Math.max(0, Math.floor(vv.offsetTop))}px`;
+ form.style.height = `${Math.floor(vv.height)}px`;
+ form.style.zIndex = '40';
+ form.style.background = 'var(--background)';
+ };
+ apply();
+ vv.addEventListener('resize', apply);
+ vv.addEventListener('scroll', apply);
+ window.addEventListener('scroll', apply, true);
+ return () => {
+ vv.removeEventListener('resize', apply);
+ vv.removeEventListener('scroll', apply);
+ window.removeEventListener('scroll', apply, true);
+ document.documentElement.classList.remove('oc-browser-kb-fullscreen');
+ form.style.position = '';
+ form.style.left = '';
+ form.style.right = '';
+ form.style.top = '';
+ form.style.height = '';
+ form.style.zIndex = '';
+ form.style.background = '';
+ // Back in flow: the browser panned/scrolled for the fullscreen
+ // session and won't re-reveal the (still focused) field on its own,
+ // which left the composer parked behind the keyboard.
+ requestAnimationFrame(() => {
+ if (textarea && document.activeElement === textarea) {
+ textarea.scrollIntoView({ block: 'nearest' });
+ }
+ });
+ };
+ }, [isMobile, isMobileExpanded]);
+
+ // Draft screen in a mobile BROWSER with the keyboard open: Safari's own
+ // focused-field reveal is unreliable there (leaving the composer behind
+ // the keyboard, e.g. after collapsing from fullscreen), so the NORMAL
+ // composer is pinned to the visual viewport too — anchored to its visible
+ // bottom at its natural height. The chat screen doesn't need this (its
+ // reveal works) and Capacitor has the keyboard choreography.
+ React.useLayoutEffect(() => {
+ if (!isMobile || isCapacitorApp()) return;
+ if (!newSessionDraftOpen || isMobileExpanded || !mobileTextareaFocused) return;
+ const vv = window.visualViewport;
+ const form = composerFormRef.current;
+ if (!vv || !form) return;
+ // Keep the in-flow horizontal geometry (page paddings) while fixed.
+ const rect = form.getBoundingClientRect();
+ form.style.position = 'fixed';
+ form.style.left = `${Math.floor(rect.left)}px`;
+ form.style.width = `${Math.floor(rect.width)}px`;
+ form.style.zIndex = '40';
+ form.style.background = 'var(--background)';
+ // Safari's visualViewport events are unreliable mid keyboard pan (they
+ // can simply not fire), so track the pan with a rAF loop instead —
+ // cheap math per frame, a style write only when the value changes.
+ let lastTop = Number.NaN;
+ let frame = 0;
+ const track = () => {
+ const top = Math.max(0, Math.floor(vv.offsetTop + vv.height - form.offsetHeight));
+ if (top !== lastTop) {
+ lastTop = top;
+ form.style.top = `${top}px`;
+ }
+ frame = requestAnimationFrame(track);
+ };
+ track();
+ return () => {
+ cancelAnimationFrame(frame);
+ form.style.position = '';
+ form.style.left = '';
+ form.style.width = '';
+ form.style.top = '';
+ form.style.zIndex = '';
+ form.style.background = '';
+ };
+ }, [isMobile, isMobileExpanded, newSessionDraftOpen, mobileTextareaFocused]);
+
+ // Shared drag handle: rendered at the top of the full composer AND inside
+ // the dictation overlay, so swipe-expand/collapse works in Listening mode.
+ // Memoized so the always-mounted dictation instance's memo stays effective.
+ const mobileComposerHandle = React.useMemo(() => isMobile ? (
+
+ ) : null, [
+ isMobile,
+ handleComposerHandleTouchStart,
+ handleComposerHandleTouchMove,
+ handleComposerHandleTouchEnd,
+ currentTheme.colors.interactive.border,
+ ]);
+
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
const buttonSizeClass = isMobile ? 'h-8 w-8' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6');
const sendIconSizeClass = isMobile ? 'h-4 w-4' : (isVSCode ? 'h-3.5 w-3.5' : 'h-4 w-4');
@@ -4002,11 +4435,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
return (
<>
) : null}
-
+
= ({ onOpenSettings, scrollTo
showTodos
leftAccessory={newSessionDraftOpen || !hasPendingChanges ? null : }
/>
- {showDraftTargetSelectors && selectedDraftProject ? (
+ {!isMobile && showDraftTargetSelectors && selectedDraftProject ? (
= ({ onOpenSettings, scrollTo
) : null}
) : null}
+ {isMobile && showDraftTargetSelectors && selectedDraftProject ? (
+
+ setMobileDraftPicker('project')}
+ >
+ {renderProjectLabelWithIcon(selectedDraftProject)}
+
+
+ {shouldShowDraftBranchSelector ? (
+ setMobileDraftPicker('branch')}
+ >
+ {selectedDraftBranchLabel ?? t('chat.chatInput.branch')}
+
+
+ ) : null}
+
+ ) : null}
+
+ {isMobile && !mobileComposerExpanded ? (
+
+
+
+
+
+
+ expandMobileComposer('focus')}
+ >
+
+ {message.trim()
+ ? message
+ : currentSessionId || newSessionDraftOpen
+ ? t('chat.chatInput.placeholder.chatCompact')
+ : t('chat.chatInput.placeholder.selectSession')}
+
+
+ {
+ // Start recording in place; the composer morphs
+ // into the voice variant once dictation is live
+ // (handleMobileDictationActiveChange).
+ window.dispatchEvent(new CustomEvent('openchamber:dictation-toggle'));
+ }}
+ title={t('chat.dictation.start')}
+ aria-label={t('chat.dictation.start')}
+ >
+
+
+
+ {/* New-session button: fades/shrinks away when the draft is
+ already open, letting the pill expand into its place. */}
+
+
+
+
+
+
+
+ ) : (
+ <>
+
= ({ onOpenSettings, scrollTo
: undefined}
/>
)}
-
+ {/* Positioning context for the dictation overlay: covers the
+ text area + footer exactly, excluding MobileSessionStatusBar. */}
+
+
+ {mobileComposerHandle}
+ {isMobile ? (
+
+ handleOpenMobilePanel('model')} className="flex-shrink-0" />
+
+
+ ) : null}
-
- {highlightedComposerContent && (
+
+ {/* No highlight mirror on mobile: over wrapped text its
+ layout drifts from the real textarea, which visually
+ misplaces the caret. Plain textarea text keeps caret
+ and text in the same layout. */}
+ {highlightedComposerContent && !isMobile && (
= ({ onOpenSettings, scrollTo
cursorPosRef.current = ta.selectionStart ?? 0;
updateAutocompleteOverlayPosition();
}}
+ onFocus={() => {
+ if (!isMobile) return;
+ mobileExpandIntentRef.current = null;
+ setMobileTextareaFocused(true);
+ }}
+ onBlur={() => {
+ if (!isMobile) return;
+ lastMobileBlurAtRef.current = Date.now();
+ setMobileTextareaFocused(false);
+ }}
placeholder={currentSessionId || newSessionDraftOpen
? inputMode === 'shell'
? t('chat.chatInput.placeholder.shell')
@@ -4476,22 +5061,22 @@ const ChatInputComponent: React.FC
= ({ onOpenSettings, scrollTo
autoCorrect={isMobile ? "on" : "off"}
autoCapitalize={isMobile ? "sentences" : "off"}
spellCheck={isMobile || inputSpellcheckEnabled}
- fillContainer={isDesktopExpanded}
- outerClassName={cn('ring-0 bg-transparent shadow-none hover:bg-transparent focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
+ fillContainer={isComposerExpanded}
+ outerClassName={cn('ring-0 bg-transparent shadow-none hover:bg-transparent focus-within:ring-0', isComposerExpanded && 'flex-1 min-h-0')}
className={cn(
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent relative z-10',
- isDesktopExpanded
- ? 'h-full min-h-0 py-4'
+ isComposerExpanded
+ ? cn('h-full min-h-0', isMobile ? 'py-2.5' : 'py-4')
: isMobile
? 'py-2.5'
: 'pt-4 pb-2',
inputMode === 'shell' && 'font-mono',
- highlightedComposerContent && 'text-transparent caret-[var(--surface-foreground)]',
+ highlightedComposerContent && !isMobile && 'text-transparent caret-[var(--surface-foreground)]',
)}
style={{
- flex: isDesktopExpanded ? '1 1 auto' : 'none',
- height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
- maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
+ flex: isComposerExpanded ? '1 1 auto' : 'none',
+ height: !isComposerExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
+ maxHeight: !isComposerExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
borderTopLeftRadius: chatInputRadius,
borderTopRightRadius: chatInputRadius,
}}
@@ -4529,6 +5114,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo
openIssuePicker={openIssuePicker}
openPrPicker={openPrPicker}
onOpenSettings={onOpenSettings}
+ onOpenMobileSheet={openMobileAttachSheet}
/>
= ({ onOpenSettings, scrollTo
/>
-
- handleOpenMobilePanel('model')} className="min-w-0 flex-shrink" />
-
-
-
+ event.preventDefault()}
+ onPointerDownCapture={(event) => {
+ if (event.pointerType === 'touch') {
+ event.preventDefault();
+ }
+ }}
+ onClick={() => {
+ window.dispatchEvent(new CustomEvent('openchamber:dictation-toggle'));
+ }}
+ disabled={mobileDictationActive}
+ title={t('chat.dictation.start')}
+ aria-label={t('chat.dictation.start')}
+ >
+
+
= ({ onOpenSettings, scrollTo
-
>
) : (
<>
@@ -4603,7 +5197,16 @@ const ChatInputComponent: React.FC
= ({ onOpenSettings, scrollTo
-
+
= ({ onOpenSettings, scrollTo
>
)}
+
- {/* Mobile session panel: slide-up overlay toggled by MobileSessionPanelTrigger. */}
- {isMobile &&
}
+ >
+ )}
+ {/* Wrapper-level dictation engine + overlay: stays mounted across
+ the pill ↔ composer swap so a recording started from the pill
+ survives the morph. Its absolute overlay covers whichever
+ shape the wrapper currently has. */}
+ {isMobile ? (
+
+ ) : null}
+
+ {/* Mobile session panel: slide-up overlay toggled by
+ MobileSessionPanelTrigger. Mounted outside the pill
+ conditional so the pill's trigger works too. */}
+ {isMobile &&
}
+ {/* Hidden host for the model/agent/variant bottom sheets. Kept
+ outside the pill conditional so an open panel survives (and
+ stays visible over) the collapsed composer. */}
+ {isMobile ? (
+
+ ) : null}
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
@@ -4662,6 +5300,172 @@ const ChatInputComponent: React.FC
= ({ onOpenSettings, scrollTo
onOpenChange={handleAttachmentPreviewOpenChange}
isMobile={isMobile}
/>
+
+ {/* Mobile attachment sheet: replaces the dropdown (which stole focus and
+ dismissed the keyboard) and leaves room for more actions later. */}
+ {isMobile ? (
+ setMobileAttachMenuOpen(false)}
+ >
+
+ {
+ // The native file/photo picker takes over next — restoring
+ // the keyboard in between would flash it open and shut.
+ restoreKeyboardAfterOverlayRef.current = false;
+ setMobileAttachMenuOpen(false);
+ requestAnimationFrame(handlePickLocalFiles);
+ }}
+ >
+
+ {t('chat.chatInput.actions.attachFiles')}
+
+ {
+ setMobileAttachMenuOpen(false);
+ requestAnimationFrame(openIssuePicker);
+ }}
+ >
+
+ {t('chat.chatInput.actions.linkGithubIssue')}
+
+ {
+ setMobileAttachMenuOpen(false);
+ requestAnimationFrame(openPrPicker);
+ }}
+ >
+
+ {t('chat.chatInput.actions.linkGithubPr')}
+
+
+
+ ) : null}
+
+ {/* Mobile draft target pickers: bottom sheets replacing the inline
+ project/branch Selects (which desktop keeps). */}
+ {isMobile && showDraftTargetSelectors && selectedDraftProject ? (
+ <>
+ setMobileDraftPicker(null)}
+ >
+
+
setMobileDraftPickerQuery(event.target.value)}
+ placeholder={t('chat.chatInput.draftPicker.searchProjects')}
+ className="h-9"
+ />
+
+ {projects
+ .filter((project) => {
+ const query = mobileDraftPickerQuery.trim().toLowerCase();
+ if (!query) return true;
+ return getProjectDisplayLabel(project).toLowerCase().includes(query)
+ || project.path.toLowerCase().includes(query);
+ })
+ .map((project) => (
+ {
+ handleDraftProjectChange(project.id);
+ setMobileDraftPicker(null);
+ }}
+ >
+ {renderProjectLabelWithIcon(project)}
+ {project.id === selectedDraftProject.id ? (
+
+ ) : null}
+
+ ))}
+
+
+
+ setMobileDraftPicker(null)}
+ >
+
+
setMobileDraftPickerQuery(event.target.value)}
+ placeholder={t('chat.chatInput.draftPicker.searchBranches')}
+ className="h-9"
+ />
+
+ {(() => {
+ const query = mobileDraftPickerQuery.trim().toLowerCase();
+ const matches = (label: string) => !query || label.toLowerCase().includes(query);
+ const selectedValue = selectedDraftDirectory
+ ?? draftBranchItems[0]?.value
+ ?? normalizePath(selectedDraftProject.path)
+ ?? '';
+ const renderRow = (value: string, label: React.ReactNode, key?: string) => (
+
{
+ handleDraftDirectoryChange(value);
+ setMobileDraftPicker(null);
+ }}
+ >
+ {label}
+ {value === selectedValue ? (
+
+ ) : null}
+
+ );
+ return (
+ <>
+ {projectRootBranchOption && matches(projectRootBranchOption.label) ? (
+ <>
+
+ {t('chat.chatInput.projectRoot')}
+
+ {renderRow(projectRootBranchOption.value, projectRootBranchOption.label)}
+ >
+ ) : null}
+
+ {t('chat.chatInput.worktrees')}
+ {
+ setMobileDraftPicker(null);
+ void createWorktreeDraft();
+ }}
+ >
+ {t('chat.chatInput.worktreeNew')}
+
+
+ {worktreeBranchOptions
+ .filter((option) => matches(option.label))
+ .map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))}
+ {selectedDraftDirectory && !selectedDraftBranchIsKnown && matches(selectedDraftBranchLabel ?? '')
+ ? renderRow(selectedDraftDirectory, selectedDraftBranchLabel, 'unknown-current')
+ : null}
+ >
+ );
+ })()}
+
+
+
+ >
+ ) : null}
>
);
};
diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx
index 7985563b6f..95abb07b41 100644
--- a/packages/ui/src/components/chat/CommandAutocomplete.tsx
+++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx
@@ -9,6 +9,7 @@ import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { isVSCodeRuntime } from '@/lib/desktop';
+import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
type CommandSource = 'openchamber' | 'opencode' | 'skill';
@@ -49,7 +50,7 @@ const NEUTRAL_BADGE_CLASS = cn(
interface CommandAutocompleteProps {
searchQuery: string;
- onCommandSelect: (command: CommandInfo, options?: { dismissKeyboard?: boolean }) => void;
+ onCommandSelect: (command: CommandInfo) => void;
onClose: () => void;
style?: React.CSSProperties;
}
@@ -81,6 +82,7 @@ export const CommandAutocomplete = React.forwardRef([]);
const containerRef = React.useRef(null);
+ const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const ignoreClickRef = React.useRef(false);
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
const pointerMovedRef = React.useRef(false);
@@ -346,9 +348,9 @@ export const CommandAutocomplete = React.forwardRef
-
+
{loading ? (
@@ -363,9 +365,15 @@ export const CommandAutocomplete = React.forwardRef
{ itemRefs.current[index] = el; }}
className={cn(
- "flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
+ "flex gap-2 px-3 py-2 cursor-pointer rounded-lg",
+ isMobile ? "items-center" : "items-start",
index === selectedIndex && "bg-interactive-selection"
)}
+ // Block the focus transfer the tap would perform: the textarea
+ // must stay focused so selecting a command doesn't dismiss the
+ // soft keyboard (the blur raced the keyboard-hide trigger and
+ // won against the deferred refocus).
+ onMouseDown={(event) => event.preventDefault()}
onPointerDown={(event) => {
if (event.pointerType !== 'touch') {
return;
@@ -396,7 +404,7 @@ export const CommandAutocomplete = React.forwardRef {
pointerStartRef.current = null;
@@ -414,7 +422,7 @@ export const CommandAutocomplete = React.forwardRef
-
+
{getCommandIcon(command)}
@@ -448,7 +456,7 @@ export const CommandAutocomplete = React.forwardRef
)}
- {command.description && (
+ {command.description && !isMobile && (
{command.description}
@@ -465,9 +473,11 @@ export const CommandAutocomplete = React.forwardRef
)}
-
- {t('chat.autocomplete.keyboardHint')}
-
+ {!isMobile && (
+
+ {t('chat.autocomplete.keyboardHint')}
+
+ )}
);
});
diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx
index b8afcecf99..cef5520988 100644
--- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx
+++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx
@@ -12,6 +12,8 @@ import { Icon } from "@/components/icon/Icon";
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
+import { useUIStore } from '@/stores/useUIStore';
+import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
type FileInfo = ProjectFileSearchHit;
type AgentInfo = {
@@ -77,6 +79,8 @@ export const FileMentionAutocomplete = React.forwardRef([]);
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const containerRef = React.useRef(null);
+ const isMobile = useUIStore((state) => state.isMobile);
+ const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const normalizedSearchQuery = (searchQuery ?? '').trim();
const recentFiles = React.useMemo(() => {
if (!projectRoot || !projectTabs) {
@@ -442,9 +446,9 @@ export const FileMentionAutocomplete = React.forwardRef
-
+
{loading ? (
@@ -466,7 +470,7 @@ export const FileMentionAutocomplete = React.forwardRef
@{agent.name}
- {agent.description ? (
+ {agent.description && !isMobile ? (
{agent.description}
) : null}
@@ -622,9 +626,11 @@ export const FileMentionAutocomplete = React.forwardRef
)}
-
- {t('chat.autocomplete.keyboardHint')}
-
+ {!isMobile && (
+
+ {t('chat.autocomplete.keyboardHint')}
+
+ )}
);
});
diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx
index 4d56736bf6..c294bd6f6f 100644
--- a/packages/ui/src/components/chat/MarkdownRenderer.tsx
+++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx
@@ -1,5 +1,8 @@
import React from 'react';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
+import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
+import { cn } from '@/lib/utils';
+import { loadMarkdownRendererModule } from './markdownRendererLoader';
// Thin lazy wrapper around the MarkdownRenderer implementation.
// The full implementation (marked + Shiki highlighting + KaTeX + morphdom
@@ -7,23 +10,41 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
// initial bundle lean.
const MarkdownRendererLazy = lazyWithChunkRecovery(() =>
- import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer }))
+ loadMarkdownRendererModule().then((m) => ({ default: m.MarkdownRenderer }))
);
const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
- import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer }))
+ loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer }))
);
const fallback =
;
+const fallbackContentClassName = (variant: unknown): string => {
+ if (variant === 'tool') return 'markdown-content markdown-tool';
+ if (variant === 'reasoning') return 'markdown-content markdown-reasoning';
+ return 'markdown-content leading-relaxed';
+};
+
+const MobileMarkdownFallback = (props: { content?: unknown; className?: unknown; variant?: unknown }) => {
+ if (!isMobileSurfaceRuntime() || typeof props.content !== 'string' || props.content.length === 0) {
+ return fallback;
+ }
+
+ return (
+
+ {props.content}
+
+ );
+};
+
export const MarkdownRenderer: React.FC> = (props) => (
-
+ }>
);
export const SimpleMarkdownRenderer: React.FC> = (props) => (
-
+ }>
);
diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts
new file mode 100644
index 0000000000..eb8b0e5499
--- /dev/null
+++ b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, test } from 'bun:test';
+
+import { parseFileReference, type ParsedFileReference } from './fileReferenceParser';
+
+const parse = (value: string): ParsedFileReference | null => parseFileReference(value);
+
+describe('parseFileReference', () => {
+ test('returns null for empty or whitespace input', () => {
+ expect(parse('')).toBeNull();
+ expect(parse(' ')).toBeNull();
+ });
+
+ test('parses bare path', () => {
+ expect(parse('src/foo.ts')).toEqual({ path: 'src/foo.ts' });
+ });
+
+ test('parses path with single line', () => {
+ expect(parse('src/foo.ts:42')).toEqual({ path: 'src/foo.ts', line: 42 });
+ });
+
+ test('parses path with line and column', () => {
+ expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
+ });
+
+ test('parses path with line range', () => {
+ expect(parse('src/foo.ts:42-58')).toEqual({
+ path: 'src/foo.ts',
+ line: 42,
+ endLine: 58,
+ });
+ });
+
+ test('parses path with single-line range (start equals end)', () => {
+ expect(parse('src/foo.ts:10-10')).toEqual({
+ path: 'src/foo.ts',
+ line: 10,
+ endLine: 10,
+ });
+ });
+
+ test('rejects range with end before start', () => {
+ expect(parse('src/foo.ts:20-10')).toBeNull();
+ });
+
+ test('falls back to path-only when range endpoint is non-numeric', () => {
+ // `src/foo.ts:10-abc` and `src/foo.ts:abc-20` are malformed; the
+ // line info is discarded and only the path is returned (the trailing
+ // `:`-suffix is stripped).
+ expect(parse('src/foo.ts:10-abc')).toEqual({ path: 'src/foo.ts' });
+ expect(parse('src/foo.ts:abc-20')).toEqual({ path: 'src/foo.ts' });
+ });
+
+ test('strips backtick and quote wrapping from range forms', () => {
+ expect(parse('`src/foo.ts:10-20`')).toEqual({
+ path: 'src/foo.ts',
+ line: 10,
+ endLine: 20,
+ });
+ expect(parse('"src/foo.ts:1-3"')).toEqual({
+ path: 'src/foo.ts',
+ line: 1,
+ endLine: 3,
+ });
+ });
+
+ test('parses absolute Windows path with line range', () => {
+ expect(parse('C:/repo/src/foo.ts:5-9')).toEqual({
+ path: 'C:/repo/src/foo.ts',
+ line: 5,
+ endLine: 9,
+ });
+ });
+
+ test('preserves line:col form (does not interpret as range)', () => {
+ expect(parse('src/foo.ts:42:8')).toEqual({
+ path: 'src/foo.ts',
+ line: 42,
+ column: 8,
+ });
+ });
+
+ test('preserves hash form', () => {
+ expect(parse('src/foo.ts#L42C8')).toEqual({
+ path: 'src/foo.ts',
+ line: 42,
+ column: 8,
+ });
+ expect(parse('src/foo.ts#L42')).toEqual({
+ path: 'src/foo.ts',
+ line: 42,
+ });
+ });
+
+ test('range form takes precedence over line-only when suffix matches digits-dash-digits', () => {
+ const result = parse('src/foo.ts:42-58');
+ expect(result).toEqual({ path: 'src/foo.ts', line: 42, endLine: 58 });
+ });
+});
diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
index b050fd25eb..6e71125c47 100644
--- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
+++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
@@ -16,8 +16,9 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { EditorAPI } from '@/lib/api/types';
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
+import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
-import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
+import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
import {
@@ -27,6 +28,13 @@ import {
type DecorateLabels,
type MermaidRender,
} from './markdown/decorate';
+import {
+ BLOCK_PATH_TOKEN_RE,
+ isAbsoluteReferencePath,
+ normalizeReferencePath,
+ parseFileReference,
+ type ParsedFileReference,
+} from './fileReferenceParser';
const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem();
@@ -147,16 +155,9 @@ const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
const BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token';
const BLOCK_PATH_TOKEN_SELECTOR = `[${BLOCK_PATH_TOKEN_ATTR}]`;
const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
-// Matches `path[:line[:col]]` inside shell/grep-style output. Requires a file
-// extension (1-8 alphanumerics) so plain words don't qualify; the path itself
-// must contain at least one extension-bearing segment.
-//
-// Known limitation: backslash-separated Windows paths (e.g.
-// `C:\Users\test\file.ts:12`) are not matched because the path character class
-// does not include `\`. Compiler output inside fenced code blocks predominantly
-// uses forward slashes, so this is a niche gap. The inline-code pipeline is not
-// affected — it reads full text content rather than matching with a regex.
-const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+){0,2}/g;
+// Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style
+// output. The regex is defined in `./fileReferenceParser`; the inline-code
+// pipeline reads full text content rather than using this regex.
const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
@@ -176,12 +177,6 @@ const getFileReferenceLinkLimit = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
);
-type ParsedFileReference = {
- path: string;
- line?: number;
- column?: number;
-};
-
const KNOWN_FILE_BASENAMES = new Set([
'dockerfile',
'makefile',
@@ -191,126 +186,19 @@ const KNOWN_FILE_BASENAMES = new Set([
'.gitignore',
'.npmrc',
]);
-const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
- .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
- .join('|');
const normalizePath = (value: string): string => {
- return normalizeFilePath(value);
+ return normalizeReferencePath(value);
};
const isAbsolutePath = (value: string): boolean => {
- return isAbsoluteFilePath(value);
+ return isAbsoluteReferencePath(value);
};
const toAbsolutePath = (basePath: string, targetPath: string): string => {
return toAbsoluteFilePath(basePath, targetPath);
};
-const trimPathCandidate = (value: string): string => {
- let next = (value || '').trim();
- if (!next) {
- return '';
- }
-
- if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) {
- next = next.slice(1, -1).trim();
- }
-
- next = next.replace(/[.,;!?]+$/g, '');
-
- if (next.endsWith(')') && !next.includes('(')) {
- next = next.slice(0, -1);
- }
- if (next.endsWith(']') && !next.includes('[')) {
- next = next.slice(0, -1);
- }
-
- return next;
-};
-
-const stripTrailingReference = (value: string): string => {
- let next = trimPathCandidate(value);
- if (!next) {
- return '';
- }
-
- const semicolonIndex = next.indexOf(';');
- if (semicolonIndex >= 0) {
- next = next.slice(0, semicolonIndex);
- }
-
- next = next.replace(/#.*$/, '');
-
- const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/);
- if (extensionSuffixMatch) {
- next = extensionSuffixMatch[1] ?? next;
- }
-
- const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0
- ? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i'))
- : null;
- if (basenameSuffixMatch) {
- next = basenameSuffixMatch[1] ?? next;
- }
-
- return trimPathCandidate(next);
-};
-
-const parseFileReference = (value: string): ParsedFileReference | null => {
- const trimmed = trimPathCandidate(value);
- if (!trimmed) {
- return null;
- }
-
- const semicolonIndex = trimmed.indexOf(';');
- const withoutSemicolonSuffix = semicolonIndex >= 0
- ? trimPathCandidate(trimmed.slice(0, semicolonIndex))
- : trimmed;
- if (!withoutSemicolonSuffix) {
- return null;
- }
-
- const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i);
- if (hashMatch) {
- const path = stripTrailingReference(hashMatch[1] ?? '');
- const line = Number.parseInt(hashMatch[2] ?? '', 10);
- const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined;
- if (!path || !Number.isFinite(line)) {
- return null;
- }
-
- return {
- path,
- line,
- column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
- };
- }
-
- const colonMatch = withoutSemicolonSuffix.match(/^(.*):(\d+)(?::(\d+))?$/);
- if (colonMatch) {
- const path = stripTrailingReference(colonMatch[1] ?? '');
- const line = Number.parseInt(colonMatch[2] ?? '', 10);
- const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined;
- if (!path || !Number.isFinite(line)) {
- return null;
- }
-
- return {
- path,
- line,
- column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
- };
- }
-
- const pathOnly = stripTrailingReference(withoutSemicolonSuffix);
- if (!pathOnly) {
- return null;
- }
-
- return { path: pathOnly };
-};
-
const hasFileExtension = (path: string): boolean => {
const base = path.split('/').filter(Boolean).pop() ?? '';
if (!base || base.endsWith('.')) {
@@ -570,6 +458,11 @@ const useFileReferenceInteractions = ({
}
let cancelled = false;
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
+ // On mobile surfaces, file-reference highlighting is disabled entirely — not
+ // just visually. The annotation pass is what issues the filesystem `stat`
+ // probes (fileReferenceExists → /api/fs/stat), so skipping it here guarantees
+ // no probe requests are ever sent from a mobile runtime.
+ const fileReferencesEnabled = enabled && !isMobileSurfaceRuntime();
const clearFileLinkAttributes = (candidate: HTMLElement) => {
candidate.removeAttribute('data-openchamber-file-link');
@@ -592,7 +485,7 @@ const useFileReferenceInteractions = ({
unwrapBlockCodePathTokens(container);
};
- if (!enabled) {
+ if (!fileReferencesEnabled) {
clearAnnotatedFileLinks();
return;
}
@@ -616,7 +509,7 @@ const useFileReferenceInteractions = ({
};
const annotateFileLinks = () => {
- if (enabled) {
+ if (fileReferencesEnabled) {
wrapBlockCodePathTokens(container);
}
const candidates = container.querySelectorAll(
@@ -959,7 +852,7 @@ const mermaidColorsFromTheme = (theme: Theme) => ({
surface: theme.colors.surface.muted,
border: theme.colors.interactive.border,
transparent: true,
- font: 'IBM Plex Sans, sans-serif',
+ font: 'system-ui, sans-serif',
});
const useDecorateContext = (
diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx
index 653f370d32..dbac2f5089 100644
--- a/packages/ui/src/components/chat/MessageList.tsx
+++ b/packages/ui/src/components/chat/MessageList.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
-import { Virtualizer, type CacheSnapshot, type VirtualizerHandle } from 'virtua';
+import { elementScroll, useVirtualizer as useTanstackVirtualizer, type ReactVirtualizer, type VirtualItem } from '@tanstack/react-virtual';
import ChatMessage from './ChatMessage';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
@@ -18,21 +18,12 @@ import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
import type { StreamPhase } from './message/types';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useSessionParts } from '@/sync/sync-context';
-import type { ReviewTransferDirection } from '@/lib/reviewFlow';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
+import type { ReviewTransferDirection } from '@/lib/reviewFlow';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
const EMPTY_UNGROUPED_MESSAGE_IDS = new Set();
-const MESSAGE_LIST_BUFFER_SIZE = 900;
-// Touch surfaces fling-scroll natively and dispatch scroll events less often
-// than the virtualizer can repaint, so a desktop-sized buffer leaves blank gaps
-// during momentum that only fill once measurement catches up. A larger overscan
-// keeps more rows mounted around the viewport so fast flings stay populated.
-const MOBILE_MESSAGE_LIST_BUFFER_SIZE = 2400;
-const resolveMessageListBufferSize = (): number => (
- isMobileSurfaceRuntime() ? MOBILE_MESSAGE_LIST_BUFFER_SIZE : MESSAGE_LIST_BUFFER_SIZE
-);
const TIMELINE_CACHE_LIMIT = 16;
const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean => {
@@ -42,28 +33,83 @@ const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undef
return a.every((key, index) => key === b[index]);
};
-const timelineCache = new Map();
+// --- History virtualization (@tanstack/react-virtual) ----------------------
+// The history list virtualizes with @tanstack/react-virtual on all surfaces:
+// its core has bottom anchoring (anchorTo: 'end'), key-stable prepend
+// preservation, and native iOS touch/momentum deferral for scroll
+// adjustments — the failure modes that historically forced virtua off on
+// mobile and required manual prepend compensation on desktop.
+type TanstackVirtualizerInstance = ReactVirtualizer;
+type HistoryEngine = 'none' | 'tanstack';
+
+const TANSTACK_ESTIMATED_ENTRY_SIZE = 320;
+const TANSTACK_OVERSCAN = 8;
+// Touch flings cover more distance between paints than desktop wheels; a
+// larger window keeps fast mobile scrolling over mounted rows.
+const TANSTACK_MOBILE_OVERSCAN = 16;
+const resolveTanstackOverscan = (): number => (
+ isMobileSurfaceRuntime() ? TANSTACK_MOBILE_OVERSCAN : TANSTACK_OVERSCAN
+);
+// Post-prepend anchor hold: measurements of freshly
+// prepended rows settle over multiple frames, so a single restore can be
+// invalidated by the next measurement pass. Re-assert the anchor until it
+// holds still for STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES.
+const ANCHOR_HOLD_STABLE_FRAMES = 30;
+const ANCHOR_HOLD_MAX_FRAMES = 180;
+// Adaptive estimate bounds: only trust the session average once a few rows
+// are measured, and keep it inside sane turn-height bounds.
+const TANSTACK_ESTIMATE_MIN_SAMPLES = 5;
+const TANSTACK_ESTIMATE_MIN = 120;
+const TANSTACK_ESTIMATE_MAX = 1200;
+// "At bottom" tolerance for resize-adjustment decisions.
+const TANSTACK_AT_END_THRESHOLD_PX = 80;
+
+// Quiet-window prepend on mobile: while a touch drag or momentum scroll is
+// active, iOS owns the scroll position and ANY geometry change above the
+// viewport races against the native animation — a race that compensation
+// logic can only lose sometimes. So freshly loaded older history is held
+// (data already fetched, store already updated) and inserted into the
+// rendered list only once the gesture goes quiet. Safety valves: flush when
+// the user gets close to the top (a blank top is worse than a small hop) or
+// after MAX_HOLD_MS.
+const HISTORY_PREPEND_QUIET_MS = 160;
+const HISTORY_PREPEND_MAX_HOLD_MS = 1500;
+const HISTORY_PREPEND_NEAR_TOP_VIEWPORTS = 1.5;
+const HISTORY_PREPEND_MONITOR_INTERVAL_MS = 90;
+
+// A commit is a deferable prepend when older entries were inserted strictly
+// above the known content: the previous first key still exists deeper in the
+// list and the tail is unchanged. Anything else renders immediately.
+const isPrependAboveCommit = (previous: RenderEntry[], next: RenderEntry[]): boolean => {
+ if (previous.length === 0 || next.length <= previous.length) return false;
+ if (previous[previous.length - 1]?.key !== next[next.length - 1]?.key) return false;
+ const previousFirstKey = previous[0]?.key;
+ const insertedIndex = next.findIndex((entry) => entry.key === previousFirstKey);
+ return insertedIndex > 0;
+};
+
+const tanstackTimelineCache = new Map();
-const readTimelineCache = (sessionKey: string, keys: readonly string[]): CacheSnapshot | undefined => {
- const entry = timelineCache.get(sessionKey);
+const readTanstackTimelineCache = (sessionKey: string, keys: readonly string[]): VirtualItem[] | undefined => {
+ const entry = tanstackTimelineCache.get(sessionKey);
if (!entry) return undefined;
- if (sameKeys(entry.keys, keys)) return entry.cache;
- timelineCache.delete(sessionKey);
+ if (sameKeys(entry.keys, keys)) return entry.items;
+ tanstackTimelineCache.delete(sessionKey);
return undefined;
};
-const writeTimelineCache = (
+const writeTanstackTimelineCache = (
sessionKey: string,
keys: readonly string[],
- handle: VirtualizerHandle | null | undefined,
+ virtualizer: TanstackVirtualizerInstance | null | undefined,
): void => {
- if (!handle || keys.length === 0) return;
- timelineCache.delete(sessionKey);
- timelineCache.set(sessionKey, { keys: keys.slice(), cache: handle.cache });
- while (timelineCache.size > TIMELINE_CACHE_LIMIT) {
- const oldest = timelineCache.keys().next().value;
+ if (!virtualizer || keys.length === 0) return;
+ tanstackTimelineCache.delete(sessionKey);
+ tanstackTimelineCache.set(sessionKey, { keys: keys.slice(), items: virtualizer.takeSnapshot() });
+ while (tanstackTimelineCache.size > TIMELINE_CACHE_LIMIT) {
+ const oldest = tanstackTimelineCache.keys().next().value;
if (typeof oldest !== 'string') break;
- timelineCache.delete(oldest);
+ tanstackTimelineCache.delete(oldest);
}
};
@@ -155,6 +201,21 @@ const getMessageParentId = (message: ChatMessageEntry): string | null => {
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
};
+const isInsideStuckSticky = (node: HTMLElement, container: HTMLElement, containerTop: number): boolean => {
+ if (typeof window === 'undefined') return false;
+
+ let current: HTMLElement | null = node;
+ while (current && current !== container) {
+ const computed = window.getComputedStyle(current);
+ if (computed.position === 'sticky' && current.getBoundingClientRect().top <= containerTop + 1) {
+ return true;
+ }
+ current = current.parentElement;
+ }
+
+ return false;
+};
+
const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
if (!message) return false;
if (resolveMessageRole(message) !== 'user') return false;
@@ -373,6 +434,8 @@ export interface MessageListHandle {
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
+ holdViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => void;
+ isHistoryVirtualized: () => boolean;
scrollToBottom: () => void;
}
@@ -914,13 +977,11 @@ MessageListEntry.displayName = 'MessageListEntry';
// Inner component that renders staged turn entries.
type StaticHistoryListProps = {
entries: RenderEntry[];
- shouldVirtualize: boolean;
+ engine: HistoryEngine;
contentRef: React.RefObject;
scrollRef?: React.RefObject;
- virtualizerRef: React.Ref;
+ registerTanstackVirtualizer?: (virtualizer: TanstackVirtualizerInstance | null) => void;
virtualizerKey: string;
- virtualCache?: CacheSnapshot;
- shift: boolean;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void;
@@ -934,7 +995,162 @@ type StaticHistoryListProps = {
reviewTransferDirection?: ReviewTransferDirection | null;
};
-const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
+const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, registerTanstackVirtualizer, virtualizerKey, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
+ const isTanstack = engine === 'tanstack';
+
+ // --- Quiet-window prepend (mobile) --------------------------------------
+ // Gesture tracking for the deferred-prepend decision. Refs only: reading
+ // them never re-renders, and the render-phase reconcile below needs them.
+ const touchActiveRef = React.useRef(false);
+ const lastScrollAtRef = React.useRef(0);
+ const holdSinceRef = React.useRef(null);
+ const deferPrepends = isTanstack && isMobileSurfaceRuntime();
+
+ React.useEffect(() => {
+ if (!deferPrepends) return;
+ const element = scrollRef?.current;
+ if (!element) return;
+ const onTouchStart = () => { touchActiveRef.current = true; };
+ const onTouchEnd = () => { touchActiveRef.current = false; };
+ const onScroll = () => { lastScrollAtRef.current = performance.now(); };
+ element.addEventListener('touchstart', onTouchStart, { passive: true });
+ element.addEventListener('touchend', onTouchEnd, { passive: true });
+ element.addEventListener('touchcancel', onTouchEnd, { passive: true });
+ element.addEventListener('scroll', onScroll, { passive: true });
+ return () => {
+ element.removeEventListener('touchstart', onTouchStart);
+ element.removeEventListener('touchend', onTouchEnd);
+ element.removeEventListener('touchcancel', onTouchEnd);
+ element.removeEventListener('scroll', onScroll);
+ };
+ }, [deferPrepends, scrollRef]);
+
+ const isGestureActive = React.useCallback(() => (
+ touchActiveRef.current
+ || performance.now() - lastScrollAtRef.current < HISTORY_PREPEND_QUIET_MS
+ ), []);
+
+ const isNearTop = React.useCallback(() => {
+ const element = scrollRef?.current;
+ if (!element) return true;
+ return element.scrollTop < element.clientHeight * HISTORY_PREPEND_NEAR_TOP_VIEWPORTS;
+ }, [scrollRef]);
+
+ const [displayEntries, setDisplayEntries] = React.useState(entries);
+ // Render-phase reconcile (official derived-state pattern): adopt the new
+ // entries immediately unless this commit is a pure prepend-above landing
+ // in the middle of an active touch gesture — those wait for quiet.
+ let renderEntries = displayEntries;
+ if (entries !== displayEntries) {
+ const shouldHold = deferPrepends
+ && isPrependAboveCommit(displayEntries, entries)
+ && isGestureActive()
+ && !isNearTop()
+ && (holdSinceRef.current === null
+ || performance.now() - holdSinceRef.current < HISTORY_PREPEND_MAX_HOLD_MS);
+ if (shouldHold) {
+ if (holdSinceRef.current === null) holdSinceRef.current = performance.now();
+ } else {
+ holdSinceRef.current = null;
+ setDisplayEntries(entries);
+ renderEntries = entries;
+ }
+ } else if (holdSinceRef.current !== null) {
+ holdSinceRef.current = null;
+ }
+
+ // While a prepend is held, poll for the quiet window (touch/momentum have
+ // no completion event we can await) and flush by re-rendering.
+ const [, forceFlushTick] = React.useReducer((tick: number) => tick + 1, 0);
+ React.useEffect(() => {
+ if (!deferPrepends) return;
+ const timer = window.setInterval(() => {
+ if (holdSinceRef.current === null) return;
+ const expired = performance.now() - holdSinceRef.current >= HISTORY_PREPEND_MAX_HOLD_MS;
+ if (!isGestureActive() || isNearTop() || expired) {
+ forceFlushTick();
+ }
+ }, HISTORY_PREPEND_MONITOR_INTERVAL_MS);
+ return () => window.clearInterval(timer);
+ }, [deferPrepends, isGestureActive, isNearTop]);
+
+ const entriesRef = React.useRef(renderEntries);
+ entriesRef.current = renderEntries;
+ // Initial-only read: measurement cache restore is a mount-time concern;
+ // afterwards the live virtualizer owns measurements.
+ const [initialMeasurements] = React.useState(() => (
+ isTanstack
+ ? readTanstackTimelineCache(virtualizerKey, entries.map((entry) => entry.key))
+ : undefined
+ ));
+
+ const sizeContainerRef = React.useRef(null);
+ // Adaptive estimate: rows this session has actually measured are a far
+ // better predictor for the still-unmeasured ones than a fixed constant.
+ // Smaller estimate error → smaller anchor corrections when prepended rows
+ // measure in → less visible drift. The ref keeps estimateSize's identity
+ // stable so updating the average never triggers a global remeasure.
+ const estimatedEntrySizeRef = React.useRef(TANSTACK_ESTIMATED_ENTRY_SIZE);
+ const tanstackVirtualizer = useTanstackVirtualizer({
+ count: renderEntries.length,
+ enabled: isTanstack,
+ getScrollElement: () => scrollRef?.current ?? null,
+ estimateSize: () => estimatedEntrySizeRef.current,
+ overscan: resolveTanstackOverscan(),
+ scrollToFn: (offset, options, instance) => {
+ // Expose the new total height before core writes an anchor
+ // correction so the browser does not clamp the offset to the old
+ // height.
+ const sizeElement = sizeContainerRef.current;
+ if (sizeElement) sizeElement.style.height = `${instance.getTotalSize()}px`;
+ elementScroll(offset, options, instance);
+ },
+ getItemKey: (index) => entriesRef.current[index]?.key ?? `index:${index}`,
+ // Bottom-anchored chat semantics: prepending older entries above the
+ // viewport must not move what the user is reading, and iOS-specific
+ // touch/momentum deferral for those adjustments lives in the core.
+ anchorTo: 'end',
+ initialOffset: () => Number.MAX_SAFE_INTEGER,
+ initialMeasurementsCache: initialMeasurements,
+ });
+ // Only compensate scroll for rows growing ABOVE the viewport (history
+ // remeasures, prepended pages). A row growing inside the viewport —
+ // expanding a tool call or thinking block — must grow DOWNWARD naturally;
+ // the end-anchored default made it expand upward. At the bottom,
+ // app-level auto-follow owns pinning, so skip there too instead of
+ // double-writing. (This is an instance field, not a constructor option.)
+ tanstackVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
+ if (instance.isAtEnd(TANSTACK_AT_END_THRESHOLD_PX)) return false;
+ const firstVisibleIndex = instance.range?.startIndex;
+ return firstVisibleIndex !== undefined && item.index < firstVisibleIndex;
+ };
+
+ React.useEffect(() => {
+ if (!isTanstack) return;
+ const sizes = tanstackVirtualizer.itemSizeCache;
+ if (sizes.size >= TANSTACK_ESTIMATE_MIN_SAMPLES) {
+ let total = 0;
+ for (const size of sizes.values()) total += size;
+ estimatedEntrySizeRef.current = Math.min(
+ TANSTACK_ESTIMATE_MAX,
+ Math.max(TANSTACK_ESTIMATE_MIN, Math.round(total / sizes.size)),
+ );
+ }
+ });
+
+ React.useEffect(() => {
+ if (!isTanstack) return;
+ registerTanstackVirtualizer?.(tanstackVirtualizer);
+ return () => {
+ writeTanstackTimelineCache(
+ virtualizerKey,
+ entriesRef.current.map((entry) => entry.key),
+ tanstackVirtualizer,
+ );
+ registerTanstackVirtualizer?.(null);
+ };
+ }, [isTanstack, registerTanstackVirtualizer, tanstackVirtualizer, virtualizerKey]);
+
const renderEntry = React.useCallback((entry: RenderEntry) => {
return (
- {entries.map((entry) => (
+ {renderEntries.map((entry) => (
- {(entry) => (
-
- {renderEntry(entry)}
+ if (engine === 'tanstack') {
+ const virtualItems = tanstackVirtualizer.getVirtualItems();
+ const startOffset = virtualItems[0]?.start ?? 0;
+ // Rendered rows stay in normal flow inside a single translated wrapper
+ // (not per-row absolute positioning) so per-turn sticky user headers
+ // keep working against the scroll container.
+ return (
+
+
+ {virtualItems.map((item) => {
+ const entry = renderEntries[item.index];
+ if (!entry) return null;
+ return (
+
+ {renderEntry(entry)}
+
+ );
+ })}
- )}
-
- );
+
+ );
+ }
+
+ return null;
});
StaticHistoryList.displayName = 'StaticHistoryList';
@@ -1062,9 +1290,8 @@ const StreamingTailContent: React.FC<{
StreamingTailContent.displayName = 'StreamingTailContent';
-const MessageList = React.forwardRef
(({
+const MessageList = React.forwardRef(({
sessionKey,
- disableStaging = false,
messages,
sessionIsWorking = false,
activeStreamingMessageId = null,
@@ -1072,7 +1299,6 @@ const MessageList = React.forwardRef(({
retryOverlay = null,
onMessageContentChange,
getAnimationHandlers,
- isLoadingOlder,
scrollToBottom,
scrollRef,
directory,
@@ -1160,7 +1386,6 @@ const MessageList = React.forwardRef(({
}), [messages]);
const historyContentRef = React.useRef(null);
- const historyVirtualizerRef = React.useRef(null);
const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => {
if (scrollRef?.current) {
return scrollRef.current;
@@ -1262,38 +1487,14 @@ const MessageList = React.forwardRef(({
}
const historyEntries = staticRenderEntries;
+ // All surfaces virtualize with @tanstack/react-virtual (see the engine
+ // note at the top of the file). An unvirtualized list is kept only for
+ // tiny histories where windowing overhead is not worth it.
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
- const historyEntryKeys = React.useMemo(() => historyEntries.map((entry) => entry.key), [historyEntries]);
- const virtualCache = React.useMemo(
- () => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined),
- [historyEntryKeys, sessionKey, shouldVirtualizeHistory],
- );
- const virtualCacheSessionRef = React.useRef(sessionKey);
- const virtualCacheKeysRef = React.useRef(historyEntryKeys);
- const setHistoryVirtualizer = React.useCallback((handle: VirtualizerHandle | null) => {
- if (!handle) {
- writeTimelineCache(
- virtualCacheSessionRef.current,
- virtualCacheKeysRef.current,
- historyVirtualizerRef.current,
- );
- historyVirtualizerRef.current = null;
- return;
- }
-
- historyVirtualizerRef.current = handle;
- }, []);
-
- React.useEffect(() => {
- virtualCacheSessionRef.current = sessionKey;
- virtualCacheKeysRef.current = historyEntryKeys;
- }, [historyEntryKeys, sessionKey]);
-
- React.useEffect(() => {
- const virtualizerForCleanup = historyVirtualizerRef.current;
- return () => {
- writeTimelineCache(virtualCacheSessionRef.current, virtualCacheKeysRef.current, virtualizerForCleanup);
- };
+ const historyEngine: HistoryEngine = shouldVirtualizeHistory ? 'tanstack' : 'none';
+ const tanstackVirtualizerRef = React.useRef(null);
+ const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => {
+ tanstackVirtualizerRef.current = virtualizer;
}, []);
const allEntries = React.useMemo(() => {
@@ -1391,16 +1592,20 @@ const MessageList = React.forwardRef(({
}, [resolveScrollContainer]);
const scrollHistoryIndexIntoView = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => {
- if (!shouldVirtualizeHistory || index < 0 || index >= historyEntries.length) {
+ if (index < 0 || index >= historyEntries.length) {
return false;
}
- const virtualizer = historyVirtualizerRef.current;
+ if (!shouldVirtualizeHistory) {
+ return false;
+ }
+
+ const virtualizer = tanstackVirtualizerRef.current;
if (!virtualizer) {
return false;
}
- virtualizer.scrollToIndex(index, { align: 'start', smooth: behavior === 'smooth' });
+ virtualizer.scrollToIndex(index, { align: 'start', behavior: behavior === 'smooth' ? 'smooth' : 'auto' });
return true;
}, [historyEntries.length, shouldVirtualizeHistory]);
@@ -1468,6 +1673,49 @@ const MessageList = React.forwardRef(({
);
},
+ holdViewportAnchor: (anchor) => {
+ const container = resolveScrollContainer();
+ if (!container || typeof window === 'undefined') {
+ return;
+ }
+
+ let frames = 0;
+ let stable = 0;
+ let cancelled = false;
+ const cancelOnUserInput = () => {
+ cancelled = true;
+ container.removeEventListener('touchstart', cancelOnUserInput);
+ container.removeEventListener('wheel', cancelOnUserInput);
+ };
+ container.addEventListener('touchstart', cancelOnUserInput, { passive: true });
+ container.addEventListener('wheel', cancelOnUserInput, { passive: true });
+ const step = () => {
+ if (cancelled) return;
+ const element = findMessageElement(anchor.messageId);
+ if (element) {
+ const delta = element.getBoundingClientRect().top
+ - container.getBoundingClientRect().top
+ - anchor.offsetTop;
+ if (Math.abs(delta) > 0.5) {
+ container.scrollTop += delta;
+ stable = 0;
+ } else {
+ stable += 1;
+ }
+ }
+ frames += 1;
+ if (stable >= ANCHOR_HOLD_STABLE_FRAMES || frames >= ANCHOR_HOLD_MAX_FRAMES) {
+ container.removeEventListener('touchstart', cancelOnUserInput);
+ container.removeEventListener('wheel', cancelOnUserInput);
+ return;
+ }
+ window.requestAnimationFrame(step);
+ };
+ window.requestAnimationFrame(step);
+ },
+
+ isHistoryVirtualized: () => shouldVirtualizeHistory,
+
captureViewportAnchor: () => {
const container = resolveScrollContainer();
if (!container) {
@@ -1486,9 +1734,7 @@ const MessageList = React.forwardRef(({
return true;
}
- const computed = window.getComputedStyle(node);
- const isStuckSticky = computed.position === 'sticky' && rect.top <= containerRect.top + 1;
- return !isStuckSticky;
+ return !isInsideStuckSticky(node, container, containerRect.top);
}) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1);
if (!firstVisible) {
return null;
@@ -1540,8 +1786,8 @@ const MessageList = React.forwardRef(({
},
scrollToBottom: () => {
- if (shouldVirtualizeHistory && historyEntries.length > 0) {
- historyVirtualizerRef.current?.scrollToIndex(historyEntries.length - 1, { align: 'end' });
+ if (shouldVirtualizeHistory && historyEntries.length > 0 && tanstackVirtualizerRef.current) {
+ tanstackVirtualizerRef.current.scrollToEnd();
return;
}
const container = resolveScrollContainer();
@@ -1570,27 +1816,32 @@ const MessageList = React.forwardRef(({
-
+ {/* Virtualized history rows unmount/remount during scroll;
+ re-running the reveal fade on every remount reads as
+ blinking. History content is never "new", so fade-in
+ is disabled there — the streaming tail keeps it. */}
+
+
+
{trailingStreamingEntry ? (
= ({ onCycleAge
const longPressTimerRef = React.useRef | null>(null);
const isLongPressRef = React.useRef(false);
- const handlePointerDown = () => {
+ const handlePointerDown = (event: React.PointerEvent) => {
+ // Same pattern as PermissionAutoAcceptButton: block the focus transfer
+ // iOS performs on touch so cycling the agent keeps the keyboard open.
+ if (event.pointerType === 'touch') {
+ event.preventDefault();
+ }
isLongPressRef.current = false;
longPressTimerRef.current = setTimeout(() => {
isLongPressRef.current = true;
@@ -72,6 +77,7 @@ export const MobileAgentButton: React.FC = ({ onCycleAge
onPointerUp={handlePointerUp} // Don't use onClick - it closes mobile keyboard
onPointerLeave={handlePointerLeave}
onContextMenu={(e) => e.preventDefault()}
+ onMouseDown={(e) => e.preventDefault()}
className={cn(
'inline-flex min-w-0 items-stretch select-none',
'rounded-lg',
diff --git a/packages/ui/src/components/chat/SessionRecapSpacer.tsx b/packages/ui/src/components/chat/SessionRecapSpacer.tsx
new file mode 100644
index 0000000000..7d60234d64
--- /dev/null
+++ b/packages/ui/src/components/chat/SessionRecapSpacer.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { useSessionAssistState } from '@/hooks/useSessionAssist';
+import { useI18n } from '@/lib/i18n';
+
+interface SessionRecapNoteProps {
+ sessionId: string;
+ isMobile: boolean;
+}
+
+// Quiet one-paragraph recap of the agent's last reply, rendered right under
+// the last message (above the reserved bottom gap). Appears only after the
+// 5-minute quiet window, so the layout shift happens off-screen in practice.
+export const SessionRecapNote: React.FC = React.memo(({ sessionId, isMobile }) => {
+ const { visibleRecap } = useSessionAssistState(sessionId);
+ const { t } = useI18n();
+
+ if (!visibleRecap) {
+ return null;
+ }
+
+ return (
+
+ {/* The last assistant turn carries pb-8 — pull the recap up into that gap. */}
+
+
+ {t('chat.recap.label')}
+ {visibleRecap}
+
+
+
+ );
+});
+
+SessionRecapNote.displayName = 'SessionRecapNote';
diff --git a/packages/ui/src/components/chat/SessionSuggestionChip.tsx b/packages/ui/src/components/chat/SessionSuggestionChip.tsx
new file mode 100644
index 0000000000..524d11d84d
--- /dev/null
+++ b/packages/ui/src/components/chat/SessionSuggestionChip.tsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { useSessionAssistState } from '@/hooks/useSessionAssist';
+import { useThemeSystem } from '@/contexts/useThemeSystem';
+import { patchSessionMetadata } from '@/sync/session-actions';
+import { useI18n } from '@/lib/i18n';
+
+interface SessionSuggestionChipProps {
+ sessionId: string | null;
+ /** The composer already has content — the suggestion must stay out of the way. */
+ hidden: boolean;
+ onApply: (text: string) => void;
+ className?: string;
+}
+
+const isRecord = (value: unknown): value is Record =>
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+
+// One small-model-suggested follow-up message, styled like the draft starter
+// chips. Tapping it fills the composer (no auto-send); the X patches the
+// suggestion out of the session metadata so it stays dismissed everywhere.
+export const SessionSuggestionChip: React.FC = React.memo(({ sessionId, hidden, onApply, className }) => {
+ const { suggestion } = useSessionAssistState(sessionId ?? '');
+ const { t } = useI18n();
+ const { currentTheme } = useThemeSystem();
+ const [dismissing, setDismissing] = React.useState(false);
+
+ const handleDismiss = React.useCallback(async (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (!sessionId || dismissing) return;
+ setDismissing(true);
+ try {
+ await patchSessionMetadata(sessionId, undefined, (metadata) => {
+ const namespace = isRecord(metadata.openchamber) ? metadata.openchamber : {};
+ const assist = isRecord(namespace.assist) ? namespace.assist : {};
+ const nextAssist = { ...assist };
+ delete nextAssist.suggestion;
+ return { ...metadata, openchamber: { ...namespace, assist: nextAssist } };
+ });
+ } catch (error) {
+ console.warn('Failed to dismiss suggestion:', error);
+ } finally {
+ setDismissing(false);
+ }
+ }, [sessionId, dismissing]);
+
+ if (!suggestion || hidden) {
+ return null;
+ }
+
+ const chipStyle: React.CSSProperties = {
+ backgroundColor: currentTheme?.colors?.surface?.elevated,
+ borderColor: currentTheme?.colors?.interactive?.border,
+ };
+
+ return (
+
+
+
+
+ onApply(suggestion)}
+ onMouseDown={(event) => event.preventDefault()}
+ aria-label={t('chat.suggestion.applyAria')}
+ className="group flex w-full min-w-0 select-none items-center gap-1.5 rounded-full border py-1.5 pl-3 pr-8 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
+ style={chipStyle}
+ >
+
+ {suggestion}
+
+
+
+ {suggestion}
+
+
+ void handleDismiss(event)}
+ onMouseDown={(event) => event.preventDefault()}
+ aria-label={t('chat.suggestion.dismissAria')}
+ title={t('chat.suggestion.dismissAria')}
+ className="absolute right-1.5 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
+ >
+
+
+
+
+ );
+});
+
+SessionSuggestionChip.displayName = 'SessionSuggestionChip';
diff --git a/packages/ui/src/components/chat/SkillAutocomplete.tsx b/packages/ui/src/components/chat/SkillAutocomplete.tsx
index 7dae335950..b88fd3f3c6 100644
--- a/packages/ui/src/components/chat/SkillAutocomplete.tsx
+++ b/packages/ui/src/components/chat/SkillAutocomplete.tsx
@@ -1,7 +1,9 @@
import React from 'react';
import { cn, fuzzyMatch } from '@/lib/utils';
import { useSkillsStore } from '@/stores/useSkillsStore';
+import { useUIStore } from '@/stores/useUIStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
interface SkillInfo {
name: string;
@@ -28,6 +30,8 @@ export const SkillAutocomplete = React.forwardRef {
const containerRef = React.useRef(null);
+ const isMobile = useUIStore((state) => state.isMobile);
+ const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const keyboardNavigationRef = React.useRef(false);
@@ -128,7 +132,8 @@ export const SkillAutocomplete = React.forwardRef onSkillSelect(skill.name)}
@@ -152,7 +157,7 @@ export const SkillAutocomplete = React.forwardRef
- {skill.description && (
+ {skill.description && !isMobile && (
{skill.description}
@@ -166,9 +171,9 @@ export const SkillAutocomplete = React.forwardRef
-
+
{filteredSkills.length ? (
{filteredSkills.map((skill, index) => renderSkill(skill, index))}
@@ -179,9 +184,11 @@ export const SkillAutocomplete = React.forwardRef
)}
-
- ↑↓ navigate • Enter select • Esc close
-
+ {!isMobile && (
+
+ ↑↓ navigate • Enter select • Esc close
+
+ )}
);
});
diff --git a/packages/ui/src/components/chat/SnippetAutocomplete.tsx b/packages/ui/src/components/chat/SnippetAutocomplete.tsx
index b85165dbce..dbba82fd7a 100644
--- a/packages/ui/src/components/chat/SnippetAutocomplete.tsx
+++ b/packages/ui/src/components/chat/SnippetAutocomplete.tsx
@@ -6,6 +6,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import type { Snippet } from '@/types/snippet';
+import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
export interface SnippetAutocompleteHandle {
handleKeyDown: (key: string) => void;
@@ -30,6 +31,8 @@ export const SnippetAutocomplete = React.forwardRef {
const { t } = useI18n();
const containerRef = React.useRef(null);
+ const isMobile = useUIStore((state) => state.isMobile);
+ const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const [filteredSnippets, setFilteredSnippets] = React.useState([]);
@@ -120,8 +123,8 @@ export const SnippetAutocomplete = React.forwardRef
-
+
+
{ itemRefs.current[0] = el; }}
className={cn('flex items-center gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', selectedIndex === 0 && 'bg-interactive-selection')}
@@ -135,7 +138,7 @@ export const SnippetAutocomplete = React.forwardRef { itemRefs.current[index + 1] = el; }}
- className={cn('flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', index + 1 === selectedIndex && 'bg-interactive-selection')}
+ className={cn('flex gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', isMobile ? 'items-center' : 'items-start', index + 1 === selectedIndex && 'bg-interactive-selection')}
onClick={() => chooseSnippet(snippet)}
onMouseMove={() => setSelectedIndex(index + 1)}
>
@@ -144,14 +147,18 @@ export const SnippetAutocomplete = React.forwardRef#{snippet.name}
{t(`snippets.source.${snippet.source}`)}
- {snippetPreview(snippet)}
+ {!isMobile && (
+ {snippetPreview(snippet)}
+ )}
)) : (
{t('chat.snippetAutocomplete.empty')}
)}
- {t('chat.snippetAutocomplete.footer')}
+ {!isMobile && (
+ {t('chat.snippetAutocomplete.footer')}
+ )}
);
});
diff --git a/packages/ui/src/components/chat/TimelineDialog.tsx b/packages/ui/src/components/chat/TimelineDialog.tsx
index 4800c27b97..bc16b00534 100644
--- a/packages/ui/src/components/chat/TimelineDialog.tsx
+++ b/packages/ui/src/components/chat/TimelineDialog.tsx
@@ -7,6 +7,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessageRecords } from '@/sync/sync-context';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -22,6 +23,9 @@ interface TimelineDialogProps {
onScrollToMessage?: (messageId: string) => void | Promise
;
onScrollByTurnOffset?: (offset: number) => void;
onResumeToLatest?: () => void;
+ canLoadEarlier?: boolean;
+ isLoadingEarlier?: boolean;
+ onLoadEarlier?: () => void;
}
export const TimelineDialog: React.FC = ({
@@ -30,6 +34,9 @@ export const TimelineDialog: React.FC = ({
onScrollToMessage,
onScrollByTurnOffset,
onResumeToLatest,
+ canLoadEarlier = false,
+ isLoadingEarlier = false,
+ onLoadEarlier,
}) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
@@ -43,31 +50,31 @@ export const TimelineDialog: React.FC = ({
const [searchQuery, setSearchQuery] = React.useState('');
const [selectedIndex, setSelectedIndex] = React.useState(0);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
+ const listRef = React.useRef(null);
+ const pendingLoadAnchorRef = React.useRef<{ messageId: string; top: number } | null>(null);
+ const preservingLoadPositionRef = React.useRef(false);
+ const wasOpenRef = React.useRef(open);
- const formatRelativeTime = React.useCallback((timestamp: number): string => {
- const now = Date.now();
- const diffMs = now - timestamp;
- const diffSecs = Math.floor(diffMs / 1000);
- const diffMins = Math.floor(diffSecs / 60);
- const diffHours = Math.floor(diffMins / 60);
- const diffDays = Math.floor(diffHours / 24);
-
- if (diffSecs < 60) return t('chat.timeline.relative.justNow');
- if (diffMins < 60) return t('chat.timeline.relative.minutesAgo', { count: diffMins });
- if (diffHours < 24) return t('chat.timeline.relative.hoursAgo', { count: diffHours });
- if (diffDays < 7) return t('chat.timeline.relative.daysAgo', { count: diffDays });
- return new Date(timestamp).toLocaleDateString(getCurrentIntlLocale());
- }, [t]);
+ const formatDateGroup = React.useCallback((timestamp: number): string => {
+ return new Date(timestamp).toLocaleDateString(getCurrentIntlLocale(), {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ }, []);
+
+ const formatMessageTime = React.useCallback((timestamp: number): string => {
+ return new Date(timestamp).toLocaleTimeString(getCurrentIntlLocale(), {
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+ }, []);
// Timeline actions are only valid for user messages.
const userMessages = React.useMemo(() => {
return messages
.filter((message) => message.info.role === 'user')
- .map((message, index) => ({
- message,
- messageNumber: index + 1,
- }))
- .reverse();
+ .map((message) => ({ message }));
}, [messages]);
// Filter by search query using all text parts in each user message.
@@ -83,19 +90,89 @@ export const TimelineDialog: React.FC = ({
}, [userMessages, searchQuery]);
React.useEffect(() => {
- setSelectedIndex(0);
- }, [filteredMessages]);
+ if (preservingLoadPositionRef.current) {
+ return;
+ }
+
+ setSelectedIndex(searchQuery.trim() ? 0 : Math.max(0, filteredMessages.length - 1));
+ }, [filteredMessages, searchQuery]);
React.useEffect(() => {
itemRefs.current = itemRefs.current.slice(0, filteredMessages.length);
}, [filteredMessages.length]);
React.useEffect(() => {
+ if (preservingLoadPositionRef.current) {
+ return;
+ }
+
itemRefs.current[selectedIndex]?.scrollIntoView({
block: 'nearest',
});
}, [selectedIndex]);
+ React.useEffect(() => {
+ if (!preservingLoadPositionRef.current || pendingLoadAnchorRef.current || isLoadingEarlier) {
+ return;
+ }
+
+ preservingLoadPositionRef.current = false;
+ }, [filteredMessages.length, isLoadingEarlier]);
+
+ React.useLayoutEffect(() => {
+ const wasOpen = wasOpenRef.current;
+ wasOpenRef.current = open;
+
+ if (!open || wasOpen || preservingLoadPositionRef.current || searchQuery.trim()) {
+ return;
+ }
+
+ const container = listRef.current;
+ if (!container) {
+ return;
+ }
+
+ container.scrollTop = container.scrollHeight;
+ }, [open, searchQuery]);
+
+ React.useLayoutEffect(() => {
+ const anchor = pendingLoadAnchorRef.current;
+ const container = listRef.current;
+ if (!anchor || !container || isLoadingEarlier) {
+ return;
+ }
+
+ pendingLoadAnchorRef.current = null;
+ const anchoredRow = itemRefs.current.find((row) => row?.dataset.timelineMessageId === anchor.messageId);
+ if (!anchoredRow) {
+ return;
+ }
+
+ const nextTop = anchoredRow.getBoundingClientRect().top - container.getBoundingClientRect().top;
+ container.scrollTop += nextTop - anchor.top;
+ }, [filteredMessages.length, isLoadingEarlier]);
+
+ const handleLoadEarlier = React.useCallback(() => {
+ const container = listRef.current;
+ if (container) {
+ const containerTop = container.getBoundingClientRect().top;
+ const firstVisibleRow = itemRefs.current.find((row) => {
+ if (!row) return false;
+ return row.getBoundingClientRect().bottom >= containerTop;
+ });
+
+ if (firstVisibleRow?.dataset.timelineMessageId) {
+ pendingLoadAnchorRef.current = {
+ messageId: firstVisibleRow.dataset.timelineMessageId,
+ top: firstVisibleRow.getBoundingClientRect().top - containerTop,
+ };
+ }
+ }
+
+ preservingLoadPositionRef.current = true;
+ onLoadEarlier?.();
+ }, [onLoadEarlier]);
+
const navigateToMessage = React.useCallback(async (messageId: string) => {
const didNavigate = await onScrollToMessage?.(messageId);
if (didNavigate === false) {
@@ -171,16 +248,40 @@ export const TimelineDialog: React.FC = ({
/>
-
+ {canLoadEarlier && onLoadEarlier && (
+
+
+ {isLoadingEarlier && (
+
+ )}
+ {t('chat.history.loadOlder')}
+
+
+ )}
+
+
{filteredMessages.length === 0 ? (
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
) : (
- filteredMessages.map(({ message, messageNumber }, index) => {
+ filteredMessages.map(({ message }, index) => {
const preview = getMessagePreview(message.parts);
const timestamp = message.info.time.created;
- const relativeTime = formatRelativeTime(timestamp);
+ const dateGroup = formatDateGroup(timestamp);
+ const previous = filteredMessages[index - 1];
+ const previousDateGroup = previous
+ ? formatDateGroup(previous.message.info.time.created)
+ : null;
+ const showDateGroup = dateGroup !== previousDateGroup;
+ const messageTime = formatMessageTime(timestamp);
const isSelected = index === selectedIndex;
const snippet = searchQuery.trim()
@@ -188,82 +289,85 @@ export const TimelineDialog: React.FC
= ({
: null;
return (
- {
- itemRefs.current[index] = element;
- }}
- className={cn(
- "group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer",
- isSelected && "bg-interactive-selection text-interactive-selection-foreground"
+
+ {showDateGroup && (
+
)}
- onClick={() => void navigateToMessage(message.info.id)}
- onMouseEnter={() => setSelectedIndex(index)}
- >
-
- {messageNumber}.
-
-
- {snippet ?? (preview || t('chat.timeline.noTextContent'))}
- {!snippet && preview && preview.length >= 80 && '…'}
-
-
-
+
{
+ itemRefs.current[index] = element;
+ }}
+ data-timeline-message-id={message.info.id}
+ className={cn(
+ "group flex items-center gap-3 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer",
+ isSelected && "bg-interactive-selection text-interactive-selection-foreground"
+ )}
+ onClick={() => void navigateToMessage(message.info.id)}
+ onMouseEnter={() => setSelectedIndex(index)}
+ >
- {relativeTime}
+ {messageTime}
+
+ {snippet ?? (preview || t('chat.timeline.noTextContent'))}
+ {!snippet && preview && preview.length >= 80 && '…'}
+
+
+
+
+
+
+ {
+ e.stopPropagation();
+ await revertToMessage(currentSessionId, message.info.id);
+ onOpenChange(false);
+ }}
+ >
+
+
+
+ {t('chat.timeline.actions.revertFromHere')}
+
-
-
-
- {
- e.stopPropagation();
- await revertToMessage(currentSessionId, message.info.id);
- onOpenChange(false);
- }}
- >
-
-
-
- {t('chat.timeline.actions.revertFromHere')}
-
-
-
-
- {
- e.stopPropagation();
- handleFork(message.info.id);
- }}
- disabled={forkingMessageId === message.info.id}
- >
- {forkingMessageId === message.info.id ? (
-
- ) : (
-
- )}
-
-
- {t('chat.timeline.actions.forkFromHere')}
-
+
+
+ {
+ e.stopPropagation();
+ handleFork(message.info.id);
+ }}
+ disabled={forkingMessageId === message.info.id}
+ >
+ {forkingMessageId === message.info.id ? (
+
+ ) : (
+
+ )}
+
+
+ {t('chat.timeline.actions.forkFromHere')}
+
+
-
+
);
})
)}
diff --git a/packages/ui/src/components/chat/fileReferenceParser.ts b/packages/ui/src/components/chat/fileReferenceParser.ts
new file mode 100644
index 0000000000..b2c6b91e28
--- /dev/null
+++ b/packages/ui/src/components/chat/fileReferenceParser.ts
@@ -0,0 +1,157 @@
+import { isAbsoluteFilePath, normalizeFilePath } from '@/lib/path-utils';
+
+export type ParsedFileReference = {
+ path: string;
+ line?: number;
+ column?: number;
+ endLine?: number;
+};
+
+const KNOWN_FILE_BASENAMES = new Set([
+ 'dockerfile',
+ 'makefile',
+ 'readme',
+ 'license',
+ '.env',
+ '.gitignore',
+ '.npmrc',
+]);
+const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
+ .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ .join('|');
+
+export const normalizeReferencePath = (value: string): string => normalizeFilePath(value);
+
+export const isAbsoluteReferencePath = (value: string): boolean => isAbsoluteFilePath(value);
+
+const trimPathCandidate = (value: string): string => {
+ let next = (value || '').trim();
+ if (!next) {
+ return '';
+ }
+
+ if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) {
+ next = next.slice(1, -1).trim();
+ }
+
+ next = next.replace(/[.,;!?]+$/g, '');
+
+ if (next.endsWith(')') && !next.includes('(')) {
+ next = next.slice(0, -1);
+ }
+ if (next.endsWith(']') && !next.includes('[')) {
+ next = next.slice(0, -1);
+ }
+
+ return next;
+};
+
+const stripTrailingReference = (value: string): string => {
+ let next = trimPathCandidate(value);
+ if (!next) {
+ return '';
+ }
+
+ const semicolonIndex = next.indexOf(';');
+ if (semicolonIndex >= 0) {
+ next = next.slice(0, semicolonIndex);
+ }
+
+ next = next.replace(/#.*$/, '');
+
+ const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/);
+ if (extensionSuffixMatch) {
+ next = extensionSuffixMatch[1] ?? next;
+ }
+
+ const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0
+ ? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i'))
+ : null;
+ if (basenameSuffixMatch) {
+ next = basenameSuffixMatch[1] ?? next;
+ }
+
+ return trimPathCandidate(next);
+};
+
+export const parseFileReference = (value: string): ParsedFileReference | null => {
+ const trimmed = trimPathCandidate(value);
+ if (!trimmed) {
+ return null;
+ }
+
+ const semicolonIndex = trimmed.indexOf(';');
+ const withoutSemicolonSuffix = semicolonIndex >= 0
+ ? trimPathCandidate(trimmed.slice(0, semicolonIndex))
+ : trimmed;
+ if (!withoutSemicolonSuffix) {
+ return null;
+ }
+
+ // Range form: `path:start-end`. Tried before the colon form so a suffix
+ // like `:10-20` is consumed as a range rather than truncated to a line
+ // number. Range and col (`:line:col`) are mutually exclusive.
+ const rangeMatch = withoutSemicolonSuffix.match(/^(.*?):(\d+)-(\d+)$/);
+ if (rangeMatch) {
+ const path = stripTrailingReference(rangeMatch[1] ?? '');
+ const line = Number.parseInt(rangeMatch[2] ?? '', 10);
+ const endLine = Number.parseInt(rangeMatch[3] ?? '', 10);
+ if (!path || !Number.isFinite(line) || !Number.isFinite(endLine) || endLine < line) {
+ return null;
+ }
+
+ return { path, line, endLine };
+ }
+
+ const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i);
+ if (hashMatch) {
+ const path = stripTrailingReference(hashMatch[1] ?? '');
+ const line = Number.parseInt(hashMatch[2] ?? '', 10);
+ const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined;
+ if (!path || !Number.isFinite(line)) {
+ return null;
+ }
+
+ return {
+ path,
+ line,
+ column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
+ };
+ }
+
+ const colonMatch = withoutSemicolonSuffix.match(/^(.*?):(\d+)(?::(\d+))?$/);
+ if (colonMatch) {
+ const path = stripTrailingReference(colonMatch[1] ?? '');
+ const line = Number.parseInt(colonMatch[2] ?? '', 10);
+ const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined;
+ if (!path || !Number.isFinite(line)) {
+ return null;
+ }
+
+ return {
+ path,
+ line,
+ column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
+ };
+ }
+
+ const pathOnly = stripTrailingReference(withoutSemicolonSuffix);
+ if (!pathOnly) {
+ return null;
+ }
+
+ return { path: pathOnly };
+};
+
+// Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style
+// output. Requires a file extension (1-8 alphanumerics) so plain words don't
+// qualify; the path itself must contain at least one extension-bearing
+// segment. The line suffix is either `:N`, `:N:M`, or `:N-M` (range); col and
+// range are mutually exclusive.
+//
+// Known limitation: backslash-separated Windows paths (e.g.
+// `C:\Users\test\file.ts:12`) are not matched because the path character class
+// does not include `\`. Compiler output inside fenced code blocks predominantly
+// uses forward slashes, so this is a niche gap. The inline-code pipeline is not
+// affected — it reads full text content rather than matching with a regex.
+export const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?(?::\d+)?)?/g;
diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts
index 7c45cd4fc1..4eb0a72ec7 100644
--- a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts
+++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, test } from 'bun:test';
-import { shouldAutoLoadEarlierForUnderfilledPinnedViewport } from './useChatTimelineController';
+import {
+ isOlderHistoryPrependCommit,
+ shouldAutoLoadEarlierForUnderfilledPinnedViewport,
+} from './useChatTimelineController';
const baseInput = {
sessionId: 'ses_1',
@@ -39,3 +42,29 @@ describe('shouldAutoLoadEarlierForUnderfilledPinnedViewport', () => {
})).toBe(false);
});
});
+
+describe('isOlderHistoryPrependCommit', () => {
+ test('detects older messages inserted above the existing timeline', () => {
+ expect(isOlderHistoryPrependCommit({
+ previousOldestId: 'msg_2',
+ previousNewestId: 'msg_4',
+ currentOldestId: 'msg_1',
+ currentNewestId: 'msg_4',
+ })).toBe(true);
+ });
+
+ test('does not treat appends or replacements as prepends', () => {
+ expect(isOlderHistoryPrependCommit({
+ previousOldestId: 'msg_2',
+ previousNewestId: 'msg_4',
+ currentOldestId: 'msg_2',
+ currentNewestId: 'msg_5',
+ })).toBe(false);
+ expect(isOlderHistoryPrependCommit({
+ previousOldestId: 'msg_2',
+ previousNewestId: 'msg_4',
+ currentOldestId: 'msg_1',
+ currentNewestId: 'msg_5',
+ })).toBe(false);
+ });
+});
diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts
index 13a0793cc4..2926ca6679 100644
--- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts
+++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts
@@ -59,25 +59,17 @@ export interface UseChatTimelineControllerResult {
}
const TURN_MODEL_CACHE_MAX = 30
-const HISTORY_SCROLL_THRESHOLD = 200
-// On touch surfaces the user can drag continuously toward the top, and
-// loadEarlier is an async (network) fetch. A 200px lead is enough on desktop
-// (wheel + fast render) but the finger can outrun an in-flight fetch on mobile
-// and hit the very top before history lands. Give touch a much larger,
-// viewport-relative head start so the fetch completes before the top is
-// reached, regardless of how fast the user drags.
-const MOBILE_HISTORY_SCROLL_THRESHOLD_MIN = 1200
-const MOBILE_HISTORY_SCROLL_VIEWPORT_FACTOR = 2
-
-const resolveHistoryScrollThreshold = (clientHeight: number): number => {
- if (!isMobileSurfaceRuntime()) {
- return HISTORY_SCROLL_THRESHOLD
- }
- return Math.max(
- MOBILE_HISTORY_SCROLL_THRESHOLD_MIN,
- clientHeight * MOBILE_HISTORY_SCROLL_VIEWPORT_FACTOR,
- )
-}
+// Desktop load-older lead distance. Trigger well before the top: the fetch
+// then completes and the prepend lands ABOVE the viewport, where key-anchored
+// compensation is exact and invisible. A short lead (the old 200px) let the
+// user reach the estimated-height region near the absolute top mid-fetch,
+// where the post-insert restore is least precise and reads as a small jump.
+const HISTORY_SCROLL_THRESHOLD_MIN_PX = 1200
+const HISTORY_SCROLL_VIEWPORT_FACTOR = 1.5
+const resolveHistoryScrollThreshold = (clientHeight: number): number => Math.max(
+ HISTORY_SCROLL_THRESHOLD_MIN_PX,
+ clientHeight * HISTORY_SCROLL_VIEWPORT_FACTOR,
+)
const VSCODE_TURN_MODEL_CACHE_MAX = 4
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
const MOBILE_TURN_MODEL_CACHE_MAX = 4
@@ -126,6 +118,75 @@ export const shouldAutoLoadEarlierForUnderfilledPinnedViewport = (input: {
return input.scrollHeight <= input.clientHeight + 1;
};
+export const isOlderHistoryPrependCommit = (input: {
+ previousOldestId: string | null;
+ previousNewestId: string | null;
+ currentOldestId: string | null;
+ currentNewestId: string | null;
+}): boolean => Boolean(
+ input.previousOldestId
+ && input.currentOldestId
+ && input.currentOldestId !== input.previousOldestId
+ && input.previousNewestId
+ && input.currentNewestId
+ && input.currentNewestId === input.previousNewestId,
+);
+
+// iOS WKWebView ignores programmatic scrollTop writes while a touch drag or
+// momentum (fling) scroll is active: the native scroll animation keeps running
+// and overwrites the value on the next frame. The mobile history threshold is
+// large enough that the prepend commit almost always lands mid-fling, so a
+// plain `container.scrollTop = target` never sticks. Toggling overflow kills
+// the native scroll synchronously (pre-paint, invisible inside a layout
+// effect); a short post-paint watchdog re-asserts the target if residual
+// momentum still drags the viewport upward.
+const MOMENTUM_WATCHDOG_FRAMES = 20;
+const MOMENTUM_WATCHDOG_TOLERANCE_PX = 4;
+
+const setScrollTopDefeatingMomentum = (container: HTMLElement, target: number) => {
+ const previousOverflow = container.style.overflow;
+ container.style.overflow = 'hidden';
+ container.scrollTop = target;
+ void container.scrollHeight;
+ container.style.overflow = previousOverflow;
+ container.scrollTop = target;
+
+ if (typeof window === 'undefined') return;
+ let cancelled = false;
+ let frames = 0;
+ const cancelOnUserTouch = () => {
+ cancelled = true;
+ };
+ container.addEventListener('touchstart', cancelOnUserTouch, { passive: true, once: true });
+ const watch = () => {
+ if (cancelled) return;
+ // Only correct upward drift (residual momentum). Downward movement or
+ // content growth above the viewport must not be fought here.
+ if (container.scrollTop < target - MOMENTUM_WATCHDOG_TOLERANCE_PX) {
+ container.scrollTop = target;
+ }
+ frames += 1;
+ if (frames < MOMENTUM_WATCHDOG_FRAMES) {
+ window.requestAnimationFrame(watch);
+ } else {
+ container.removeEventListener('touchstart', cancelOnUserTouch);
+ }
+ };
+ window.requestAnimationFrame(watch);
+};
+
+const hasInsertedBeforeKnownOldest = (
+ previousOldestId: string | null,
+ currentOldestId: string | null,
+ messages: ChatMessageEntry[],
+): boolean => {
+ if (!previousOldestId || !currentOldestId || currentOldestId === previousOldestId) {
+ return false;
+ }
+
+ return messages.some((message) => message.info.id === previousOldestId);
+};
+
export const useChatTimelineController = ({
sessionId,
messages,
@@ -339,9 +400,12 @@ export const useChatTimelineController = ({
// before triggering the state change. useLayoutEffect consumes it
// after React commits new DOM — before the browser paints.
const prePrependScrollRef = React.useRef<{
+ sessionId: string | null;
height: number;
top: number;
anchor: ViewportAnchor | null;
+ oldestId: string | null;
+ newestId: string | null;
} | null>(null);
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
@@ -364,26 +428,47 @@ export const useChatTimelineController = ({
scrollHeight: number;
} | null>(null);
+ React.useLayoutEffect(() => {
+ prePrependScrollRef.current = null;
+ prependTrackingRef.current = null;
+ }, [sessionId]);
+
React.useLayoutEffect(() => {
const container = scrollRef.current;
if (!container) return;
- const snap = prePrependScrollRef.current;
+ let snap = prePrependScrollRef.current;
const prev = prependTrackingRef.current;
const currentOldestId = renderedMessages[0]?.info?.id ?? null;
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
- // A prepend = content inserted ABOVE the viewport: the oldest message id
- // changed while the newest stayed the same. This distinguishes a history
- // load from a bottom append, a streaming part growing, or a session switch.
- const isPrepend = Boolean(
- prev
- && prev.oldestId
- && currentOldestId
- && currentOldestId !== prev.oldestId
- && prev.newestId
- && currentNewestId
- && currentNewestId === prev.newestId,
- );
+ // A prepend = content inserted ABOVE the viewport: either the newest
+ // stayed fixed, or the old first message still exists below a new first
+ // message. The latter keeps preservation alive if a tail append lands in
+ // the same commit as the history page.
+ const isPrepend = prev
+ ? isOlderHistoryPrependCommit({
+ previousOldestId: prev.oldestId,
+ previousNewestId: prev.newestId,
+ currentOldestId,
+ currentNewestId,
+ }) || hasInsertedBeforeKnownOldest(prev.oldestId, currentOldestId, renderedMessages)
+ : false;
+
+ if (snap && snap.sessionId !== sessionIdRef.current) {
+ prePrependScrollRef.current = null;
+ snap = null;
+ }
+
+ const isSnapshotPrepend = snap
+ ? isOlderHistoryPrependCommit({
+ previousOldestId: snap.oldestId,
+ previousNewestId: snap.newestId,
+ currentOldestId,
+ currentNewestId,
+ }) || hasInsertedBeforeKnownOldest(snap.oldestId, currentOldestId, renderedMessages)
+ : false;
+ const didPrepend = isPrepend || isSnapshotPrepend;
+ const shouldConsumeSnapshot = Boolean(snap && (isPrepend || isSnapshotPrepend));
const updateTracking = () => {
prependTrackingRef.current = {
@@ -393,6 +478,22 @@ export const useChatTimelineController = ({
};
};
+ const refreshPendingSnapshot = () => {
+ const pending = prePrependScrollRef.current;
+ if (!pending) {
+ return;
+ }
+
+ prePrependScrollRef.current = {
+ ...pending,
+ height: container.scrollHeight,
+ top: container.scrollTop,
+ anchor: captureViewportAnchor(),
+ oldestId: currentOldestId,
+ newestId: currentNewestId,
+ };
+ };
+
if (isPinnedRef.current) {
// Bottom-pinned. Only content inserted ABOVE (a prepend / history load)
// needs an explicit re-pin: with overflow-anchor:none the browser leaves
@@ -407,38 +508,85 @@ export const useChatTimelineController = ({
// best, and the source of the old up/down jiggle on send / from the
// queue / while streaming. So for an append we do nothing and let
// auto-follow own it.
- if (snap || isPrepend) {
+ if (didPrepend) {
prePrependScrollRef.current = null;
goToBottom('instant');
+ } else if (snap) {
+ refreshPendingSnapshot();
}
updateTracking();
return;
}
- if (snap) {
+ // When the history list is virtualized, virtua runs with `shift` during
+ // history loads and compensates the prepend internally. Manual
+ // height-delta compensation on top of that applies the same delta twice
+ // and throws the viewport far downward. Anchor restore stays allowed —
+ // it corrects to an absolute element position, so it cannot double up.
+ const historyVirtualized = messageListRef.current?.isHistoryVirtualized() ?? false;
+
+ if (snap && shouldConsumeSnapshot) {
prePrependScrollRef.current = null;
+ const heightDelta = container.scrollHeight - snap.height;
+ const applyHeightDelta = (): boolean => {
+ if (historyVirtualized || heightDelta <= 0) {
+ return false;
+ }
+ container.scrollTop = snap.top + heightDelta;
+ return true;
+ };
+
+ // Non-virtualized mobile list only: fight iOS momentum manually.
+ // The virtualized mobile list (tanstack) defers prepend adjustments
+ // through touch/momentum in core, so manual writes would double up.
+ if (isMobileSurfaceRuntime() && !historyVirtualized && heightDelta > 0) {
+ setScrollTopDefeatingMomentum(container, snap.top + heightDelta);
+ updateTracking();
+ return;
+ }
+
// When a viewport anchor is available, delegate to MessageList
// restoreViewportAnchor which falls back to virtualizer-aware
// scrollHistoryIndexIntoView when the element is not in the DOM.
+ // Note: an unchanged scrollTop after restore is NOT a failure here —
+ // the virtualized list compensates the prepend internally, so
+ // staying near snap.top is the correct outcome.
if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
// Fallback: height-delta compensation
- const delta = container.scrollHeight - snap.height;
- if (delta > 0) {
- container.scrollTop = snap.top + delta;
- }
+ applyHeightDelta();
+ }
+ if (historyVirtualized && snap.anchor && isMobileSurfaceRuntime()) {
+ // Mobile only: freshly prepended rows keep re-measuring for a
+ // few frames and each pass can shift content, so hold the
+ // anchor until it settles. Desktop must NOT run this — wheel
+ // scrolling during the hold would fight the re-assertions and
+ // read as a frozen scroll; the virtualizer's own anchoring is
+ // enough there.
+ messageListRef.current?.holdViewportAnchor(snap.anchor);
}
- } else if (isPrepend && prev) {
+ } else if (isPrepend && prev && !historyVirtualized) {
// Released viewport: preserve the read position by compensating for the
// exact height the prepend added above, with no intermediate frame for
- // auto-follow to fight.
+ // auto-follow to fight. Virtualized lists skip this — virtua `shift`
+ // already compensated the prepend.
const delta = container.scrollHeight - prev.scrollHeight;
if (delta > 0) {
- container.scrollTop = container.scrollTop + delta;
+ const target = container.scrollTop + delta;
+ if (isMobileSurfaceRuntime()) {
+ setScrollTopDefeatingMomentum(container, target);
+ } else {
+ container.scrollTop = target;
+ }
}
+ } else if (snap) {
+ // setIsLoadingOlder/historyMeta can commit before the server page
+ // arrives. Keep the snapshot armed, but refresh it so later fallback
+ // compensation only accounts for rows actually prepended above.
+ refreshPendingSnapshot();
}
updateTracking();
- }, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]);
+ }, [captureViewportAnchor, messageListRef, renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]);
const revealBufferedTurns = React.useCallback(async (): Promise
=> false, []);
@@ -462,9 +610,12 @@ export const useChatTimelineController = ({
// compensate synchronously when React commits the new messages.
if (input.preserveViewport && container) {
prePrependScrollRef.current = {
+ sessionId: sessionIdRef.current,
height: container.scrollHeight,
top: container.scrollTop,
anchor: captureViewportAnchor(),
+ oldestId: beforeOldestMessageId,
+ newestId: beforeMessages[beforeMessages.length - 1]?.info?.id ?? null,
};
}
@@ -474,6 +625,7 @@ export const useChatTimelineController = ({
try {
const targetSessionId = sessionIdRef.current;
if (!targetSessionId) {
+ prePrependScrollRef.current = null;
return false;
}
@@ -485,6 +637,7 @@ export const useChatTimelineController = ({
while (true) {
await loadMoreMessages(targetSessionId, 'up');
if (sessionIdRef.current !== targetSessionId) {
+ prePrependScrollRef.current = null;
return false;
}
@@ -506,6 +659,7 @@ export const useChatTimelineController = ({
return true;
}
if (!messageGrowth) {
+ prePrependScrollRef.current = null;
return false;
}
if (!historySignalsRef.current.hasMoreAboveTurns) {
@@ -516,6 +670,9 @@ export const useChatTimelineController = ({
loadedOldestMessageId = afterOldestMessageId;
loadedLimit = afterLimit;
}
+ } catch (error) {
+ prePrependScrollRef.current = null;
+ throw error;
} finally {
setIsLoadingOlder(false);
settleHistoryInteraction();
@@ -536,6 +693,12 @@ export const useChatTimelineController = ({
}, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, settleHistoryInteraction]);
const handleHistoryScroll = React.useCallback(() => {
+ // Mobile never loads history from scroll position: any prepend racing
+ // an active touch gesture can be hijacked by the native scroll
+ // animation. The user scrolls to the natural top and taps an explicit
+ // "load older" button instead — the insert then happens from a resting
+ // state, which is fully deterministic.
+ if (isMobileSurfaceRuntime()) return;
const container = scrollRef.current;
if (!container) return;
if (isPinnedRef.current) return;
@@ -547,6 +710,10 @@ export const useChatTimelineController = ({
}, [loadEarlier, scrollRef]);
const loadEarlierIfPinnedViewportUnderfilled = React.useCallback(() => {
+ // On mobile the initial page is intentionally smaller. Auto-prepending
+ // older rows after first paint shifts the narrow timeline; let explicit
+ // upward scroll request history instead.
+ if (isMobileSurfaceRuntime()) return;
if (historyInteractionRef.current) return;
const container = scrollRef.current;
if (!container) return;
diff --git a/packages/ui/src/components/chat/markdownRendererLoader.ts b/packages/ui/src/components/chat/markdownRendererLoader.ts
new file mode 100644
index 0000000000..986fbedcc4
--- /dev/null
+++ b/packages/ui/src/components/chat/markdownRendererLoader.ts
@@ -0,0 +1,13 @@
+let markdownRendererModulePromise: Promise | null = null;
+
+export const loadMarkdownRendererModule = () => {
+ markdownRendererModulePromise ??= import('./MarkdownRendererImpl').catch((error) => {
+ markdownRendererModulePromise = null;
+ throw error;
+ });
+ return markdownRendererModulePromise;
+};
+
+export const preloadMarkdownRenderer = () => {
+ void loadMarkdownRendererModule().catch(() => undefined);
+};
diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
index 5ecf206ef8..3e5b11993d 100644
--- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
+++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
@@ -10,6 +10,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
+import { summarizeSelectionForNotes } from '@/lib/smallModel';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -518,7 +519,9 @@ export const TextSelectionMenu: React.FC = ({ containerR
try {
setIsAddingToNotes(true);
- const noteText = selectedTextMarkdown || selectedText;
+ // Long selections are distilled into a compact note by the small model;
+ // short ones (and any generation failure) go in verbatim.
+ const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId);
const projectData = await getProjectNotesAndTodos(currentProjectRef);
const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText);
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
@@ -541,7 +544,7 @@ export const TextSelectionMenu: React.FC = ({ containerR
} finally {
setIsAddingToNotes(false);
}
- }, [currentProjectRef, hideMenu, selectedText, selectedTextMarkdown, t]);
+ }, [currentProjectRef, currentSessionId, hideMenu, selectedText, selectedTextMarkdown, t]);
if (!position.show) return null;
diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx
index ee0443f38a..59e1fc5b82 100644
--- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx
+++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx
@@ -118,10 +118,14 @@ export const ReasoningTimelineBlock: React.FC = ({
: expansion.expanded;
const [shouldRenderExpandedContent, setShouldRenderExpandedContent] = React.useState(defaultExpanded === true || canAutoExpand);
const contentId = React.useId();
- const scrollRef = React.useRef(null);
const contentRef = React.useRef(null);
const contentAnimationRef = React.useRef(null);
const contentMountedRef = React.useRef(false);
+ // Stable handle to onContentChange so the height-animation layout effect can
+ // signal auto-follow without taking onContentChange as a dependency (which
+ // would risk re-running — and thus restarting — the animation on re-render).
+ const onContentChangeRef = React.useRef(onContentChange);
+ onContentChangeRef.current = onContentChange;
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
const toggleAriaLabel = isExpanded
@@ -160,12 +164,6 @@ export const ReasoningTimelineBlock: React.FC = ({
onContentChange?.('structural');
}, [onContentChange, text]);
- React.useEffect(() => {
- if (isStreaming && isExpanded && scrollRef.current) {
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
- }
- }, [text, isStreaming, isExpanded]);
-
React.useEffect(() => {
if (isExpanded || isStreaming) {
setShouldRenderExpandedContent(true);
@@ -239,6 +237,11 @@ export const ReasoningTimelineBlock: React.FC = ({
element.style.height = '0px';
} else {
element.style.height = `${element.scrollHeight}px`;
+ // Only the COLLAPSE animation needs the guard: it shrinks the
+ // timeline and the trailing async scroll events can be misread as a
+ // user scroll-away. Expansion grows the timeline and re-pins cleanly,
+ // and guarding it caused a faint scroll fight while thinking streams.
+ onContentChangeRef.current?.('animation');
}
const animation = animate(
@@ -280,6 +283,27 @@ export const ReasoningTimelineBlock: React.FC = ({
return null;
}
+ const reasoningBody = (
+ <>
+
+
+
+ {actions ? (
+
+ ) : null}
+ >
+ );
+
return (
= ({
className="pointer-events-none absolute left-0 top-0 bottom-0 w-px"
style={{ backgroundColor: 'var(--tools-border)' }}
/>
-
-
-
+ {isStreaming ? (
+ // While streaming, let the thinking grow inline — no
+ // capped, independently-scrollable box. The chat's own
+ // auto-follow then handles following / releasing, so the
+ // box never captures the wheel or fights the user's
+ // scroll. The max-height scroll box is applied only once
+ // the thinking has finished (the branch below).
+
+ {reasoningBody}
- {actions ? (
-
- ) : null}
-
+ ) : (
+
+ {reasoningBody}
+
+ )}
) : null}
diff --git a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx
index 028f889a94..11e7763f66 100644
--- a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx
+++ b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx
@@ -3,7 +3,7 @@
*
* Renders large code/read outputs without mounting one highlighter per line:
* 1. ONE worker tokenization of the whole block (off the main thread)
- * 2. virtua to only render visible rows
+ * 2. @tanstack/react-virtual to only render visible rows
*
* Tokenizing the whole block at once also preserves cross-line syntax context
* (multi-line strings/comments) that per-line highlighting loses. Colors resolve
@@ -11,7 +11,7 @@
*/
import React from 'react';
-import { Virtualizer } from 'virtua';
+import { useVirtualizer } from '@tanstack/react-virtual';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
@@ -114,28 +114,41 @@ const VirtualizedRows: React.FC
= React.memo(({
const parentRef = React.useRef(null);
const viewportHeight = `min(${lines.length * ROW_HEIGHT}px, ${maxHeight})`;
+ const virtualizer = useVirtualizer({
+ count: lines.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => ROW_HEIGHT,
+ overscan: 20,
+ });
+ const virtualItems = virtualizer.getVirtualItems();
+
return (
-
- {(line, index) => (
-
- )}
-
+
+ {virtualItems.map((item) => {
+ const line = lines[item.index];
+ if (!line) return null;
+ return (
+
+
+
+ );
+ })}
+
);
});
diff --git a/packages/ui/src/components/chat/message/parts/generatedJsonResult.test.ts b/packages/ui/src/components/chat/message/parts/generatedJsonResult.test.ts
new file mode 100644
index 0000000000..f1f6cea566
--- /dev/null
+++ b/packages/ui/src/components/chat/message/parts/generatedJsonResult.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from 'bun:test';
+
+import { parseGeneratedJsonResult } from './generatedJsonResult';
+
+describe('parseGeneratedJsonResult', () => {
+ test('parses a full pull request JSON result', () => {
+ expect(parseGeneratedJsonResult('{"title":"Side task","body":"Details"}')).toEqual({
+ kind: 'pr',
+ title: 'Side task',
+ body: 'Details',
+ raw: JSON.stringify({ title: 'Side task', body: 'Details' }, null, 2),
+ });
+ });
+
+ test('parses a full fenced JSON result', () => {
+ expect(parseGeneratedJsonResult('```json\n{"subject":"Fix parser","highlights":["Narrow detection"]}\n```')).toEqual({
+ kind: 'commit',
+ subject: 'Fix parser',
+ highlights: ['Narrow detection'],
+ raw: JSON.stringify({ subject: 'Fix parser', highlights: ['Narrow detection'] }, null, 2),
+ });
+ });
+
+ test('ignores JSON examples embedded in markdown prose', () => {
+ const markdown = [
+ 'Recommended endpoint:',
+ '',
+ '```json',
+ '{',
+ ' "title": "Side task",',
+ ' "prompt": "Investigate X"',
+ '}',
+ '```',
+ '',
+ 'This should stay markdown.',
+ ].join('\n');
+
+ expect(parseGeneratedJsonResult(markdown)).toBeNull();
+ });
+});
diff --git a/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts b/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts
index 783a20ef24..2d95cb73eb 100644
--- a/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts
+++ b/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts
@@ -16,21 +16,15 @@ export type GeneratedResult = GeneratedCommitResult | GeneratedPrResult;
const parseJsonObjects = (value: string): Record[] => {
const text = value.trim();
- const candidates = new Set();
+ const candidates: string[] = [];
- const fencedMatches = text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi);
- for (const match of fencedMatches) {
- if (match[1]) candidates.add(match[1].trim());
+ const fencedMatch = text.match(/^```(?:json)?\s*([\s\S]*?)```$/i);
+ if (fencedMatch?.[1]) {
+ candidates.push(fencedMatch[1].trim());
}
- const firstObjectStart = text.indexOf('{');
- if (firstObjectStart >= 0) {
- for (let end = text.length; end > firstObjectStart; end -= 1) {
- if (text[end - 1] === '}') {
- candidates.add(text.slice(firstObjectStart, end));
- break;
- }
- }
+ if (text.startsWith('{') && text.endsWith('}')) {
+ candidates.push(text);
}
const parsed: Record[] = [];
diff --git a/packages/ui/src/components/chat/useMobileAutocompleteMaxHeight.ts b/packages/ui/src/components/chat/useMobileAutocompleteMaxHeight.ts
new file mode 100644
index 0000000000..59f7caa68c
--- /dev/null
+++ b/packages/ui/src/components/chat/useMobileAutocompleteMaxHeight.ts
@@ -0,0 +1,57 @@
+import React from 'react';
+
+/**
+ * Mobile: clamp an autocomplete popup (anchored above the composer via
+ * `bottom-full`) so it never rises past the top of the chat area. The chat
+ * `` starts below the app header in both the Capacitor shell and the
+ * mobile browser, so its top edge is the correct boundary for both.
+ *
+ * Re-measures on window resizes and when the native keyboard choreography
+ * settles (the composer — and therefore the popup's anchor — moves with it).
+ *
+ * Returns an inline max-height in px, or undefined when disabled. NOTE: the
+ * inline value REPLACES any `max-h-*` class (it does not combine) — on mobile
+ * the popup is allowed to grow all the way to the boundary, unlike the
+ * desktop design cap.
+ */
+export const useMobileAutocompleteMaxHeight = (
+ containerRef: React.RefObject,
+ enabled: boolean,
+): number | undefined => {
+ const [maxHeight, setMaxHeight] = React.useState(undefined);
+
+ React.useLayoutEffect(() => {
+ if (!enabled) return;
+ const measure = () => {
+ const el = containerRef.current;
+ if (!el) return;
+ const main = el.closest('main');
+ if (!main) return;
+ // Mobile browsers pan the page up to reveal the focused field, so
+ // 's top can sit ABOVE the visible screen (negative client
+ // coordinates). The binding boundary is whichever is lower: the
+ // chat area's top or the visual viewport's top (its offsetTop is
+ // expressed in the same layout-viewport client coordinates).
+ const visualTop = window.visualViewport?.offsetTop ?? 0;
+ const boundaryTop = Math.max(main.getBoundingClientRect().top, visualTop);
+ // The popup's bottom edge is its anchor (composer top) and does not
+ // depend on its current height.
+ const available = el.getBoundingClientRect().bottom - boundaryTop - 8;
+ const next = Math.max(120, Math.floor(available));
+ setMaxHeight((prev) => (prev === next ? prev : next));
+ };
+ measure();
+ window.addEventListener('resize', measure);
+ window.addEventListener('oc:keyboard-settled', measure);
+ window.visualViewport?.addEventListener('resize', measure);
+ window.visualViewport?.addEventListener('scroll', measure);
+ return () => {
+ window.removeEventListener('resize', measure);
+ window.removeEventListener('oc:keyboard-settled', measure);
+ window.visualViewport?.removeEventListener('resize', measure);
+ window.visualViewport?.removeEventListener('scroll', measure);
+ };
+ });
+
+ return enabled ? maxHeight : undefined;
+};
diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx
index 4ba3ccad85..269d4b90f6 100644
--- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx
+++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx
@@ -424,7 +424,7 @@ export function DesktopHostSwitcherDialog({
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
}
const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || '');
- const res = await desktopHostProbe(url, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
+ const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
})
);
@@ -492,7 +492,7 @@ export function DesktopHostSwitcherDialog({
if (!apiOrigin) return;
setSwitchingHostId(host.id);
const clientToken = host.id === LOCAL_HOST_ID ? await getLocalClientToken() : (host.clientToken || '');
- const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
+ const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
@@ -504,7 +504,7 @@ export function DesktopHostSwitcherDialog({
return;
}
- switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, runtimeKey: runtimeKeyForHost(host) });
+ switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) });
onHostSwitched?.();
setSwitchingHostId(null);
return;
@@ -598,7 +598,7 @@ export function DesktopHostSwitcherDialog({
if (host.id !== LOCAL_HOST_ID && isDesktopShell()) {
setSwitchingHostId(host.id);
- const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
+ const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
@@ -646,7 +646,7 @@ export function DesktopHostSwitcherDialog({
const url = resolved.persistedUrl;
const label = (editLabel || redactSensitiveUrl(url)).trim();
- const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
+ const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url, apiUrl: url } : h));
await persist(nextHosts, defaultHostId);
cancelEdit();
if (resolved.redeemUrl) {
@@ -663,7 +663,7 @@ export function DesktopHostSwitcherDialog({
const origin = host.id === LOCAL_HOST_ID ? localOrigin : getDesktopHostApiUrl(host);
if (!origin) return;
const target = toNavigationUrl(origin);
- desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null }).catch((err: unknown) => {
+ desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((err: unknown) => {
toast.error(t('desktopHostSwitcher.error.failedToOpenNewWindow'), {
description: err instanceof Error ? err.message : String(err),
});
diff --git a/packages/ui/src/components/dictation/ComposerDictation.tsx b/packages/ui/src/components/dictation/ComposerDictation.tsx
new file mode 100644
index 0000000000..6b79116813
--- /dev/null
+++ b/packages/ui/src/components/dictation/ComposerDictation.tsx
@@ -0,0 +1,488 @@
+/**
+ * Composer dictation controls: a mic button for the composer footer plus a
+ * full-composer overlay while dictation is active (recording, transcribing,
+ * or failed). The overlay mirrors the composer's own layout — the transcript
+ * area uses the same paddings/typography as the textarea and the action row
+ * reuses the footer icon-button styling — so toggling dictation causes no
+ * vertical shift.
+ */
+
+import React from 'react';
+
+import { Icon } from '@/components/icon/Icon';
+import { useI18n } from '@/lib/i18n';
+import { useThemeSystem } from '@/contexts/useThemeSystem';
+import { cn } from '@/lib/utils';
+import { runtimeFetch } from '@/lib/runtime-fetch';
+import { useDictation } from '@/hooks/useDictation';
+import { isDictationCaptureSupported } from '@/lib/dictation/use-dictation-audio-source';
+import { isVSCodeRuntime } from '@/lib/desktop';
+import { useConfigStore } from '@/stores/useConfigStore';
+import { useUIStore } from '@/stores/useUIStore';
+import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
+
+interface ComposerDictationProps {
+ radius?: number | string;
+ isMobile: boolean;
+ footerIconButtonClass: string;
+ footerPaddingClass: string;
+ iconSizeClass: string;
+ sendIconSizeClass: string;
+ disabled?: boolean;
+ onInsert: (text: string) => void;
+ onInsertAndSend: (text: string) => void;
+ /** Reports whether dictation is active (recording/transcribing/failed overlay shown). */
+ onActiveChange?: (active: boolean) => void;
+ /** Render the mic trigger button (default). Pass false when the host renders
+ its own trigger and only needs the overlay + recording engine. */
+ renderTrigger?: boolean;
+ /** Rendered at the very top of the active overlay (e.g. the mobile composer
+ drag handle, so swipe-expand keeps working in Listening mode). */
+ topAccessory?: React.ReactNode;
+}
+
+const formatDuration = (seconds: number): string => {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}:${String(secs).padStart(2, '0')}`;
+};
+
+const VolumeMeter: React.FC<{ volume: number }> = ({ volume }) => {
+ const { currentTheme } = useThemeSystem();
+ return (
+
+ );
+};
+
+/**
+ * Polls the dictation status route while the local model is downloading and
+ * returns the download percent (null while unknown / not downloading).
+ */
+const useModelDownloadProgress = (active: boolean): number | null => {
+ const sttLocalModel = useConfigStore((state) => state.sttLocalModel);
+ const [percent, setPercent] = React.useState(null);
+
+ React.useEffect(() => {
+ if (!active) {
+ setPercent(null);
+ return;
+ }
+ let cancelled = false;
+ const poll = async () => {
+ try {
+ const response = await runtimeFetch('/api/dictation/status', {
+ query: { provider: 'local', localModel: sttLocalModel },
+ });
+ if (!response.ok || cancelled) {
+ return;
+ }
+ const data = await response.json();
+ const model = Array.isArray(data?.models)
+ ? data.models.find((m: { id: string }) => m.id === sttLocalModel)
+ : null;
+ if (!cancelled) {
+ setPercent(typeof model?.downloadProgress === 'number' ? model.downloadProgress : null);
+ }
+ } catch {
+ // Display-only; keep the previous value.
+ }
+ };
+ void poll();
+ const interval = setInterval(() => {
+ void poll();
+ }, 2000);
+ return () => {
+ cancelled = true;
+ clearInterval(interval);
+ };
+ }, [active, sttLocalModel]);
+
+ return active ? percent : null;
+};
+
+export const ComposerDictation: React.FC = ({
+ radius,
+ isMobile,
+ footerIconButtonClass,
+ footerPaddingClass,
+ iconSizeClass,
+ sendIconSizeClass,
+ disabled,
+ onInsert,
+ onInsertAndSend,
+ onActiveChange,
+ renderTrigger = true,
+ topAccessory,
+}) => {
+ const { t } = useI18n();
+ const { currentTheme } = useThemeSystem();
+ const dictationEnabled = useConfigStore((state) => state.dictationEnabled);
+ const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
+ const dictationShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('toggle_dictation', shortcutOverrides));
+ // The dictation server (WebSocket + STT worker) lives in the OpenChamber
+ // web server; the VS Code bridge has no server process for it.
+ const [supported] = React.useState(() => !isVSCodeRuntime() && isDictationCaptureSupported());
+
+ const pendingActionRef = React.useRef<'insert' | 'send' | null>(null);
+ const onInsertRef = React.useRef(onInsert);
+ const onInsertAndSendRef = React.useRef(onInsertAndSend);
+ React.useEffect(() => {
+ onInsertRef.current = onInsert;
+ onInsertAndSendRef.current = onInsertAndSend;
+ }, [onInsert, onInsertAndSend]);
+
+ const dictation = useDictation({
+ onTranscript: (text) => {
+ const action = pendingActionRef.current;
+ pendingActionRef.current = null;
+ if (action === 'send') {
+ onInsertAndSendRef.current(text);
+ } else {
+ onInsertRef.current(text);
+ }
+ },
+ });
+
+ const {
+ status,
+ partialTranscript,
+ volume,
+ duration,
+ error,
+ errorReason,
+ startDictation,
+ confirmDictation,
+ cancelDictation,
+ retryFailedDictation,
+ acceptPartialTranscript,
+ discardFailedDictation,
+ } = dictation;
+
+ const isModelDownloading = status === 'recording' && errorReason === 'model_download_in_progress';
+ const downloadPercent = useModelDownloadProgress(isModelDownloading);
+
+ const statusRef = React.useRef(status);
+ React.useEffect(() => {
+ statusRef.current = status;
+ }, [status]);
+
+ // Layout effect on purpose: the host may expand/collapse the composer in
+ // response, and that state change must land in the same paint as the
+ // overlay (a plain effect painted one clipped frame of overlay content
+ // inside the still-collapsed pill before the morph started).
+ React.useLayoutEffect(() => {
+ onActiveChange?.(status !== 'idle');
+ }, [status, onActiveChange]);
+
+ // Keyboard shortcut (toggle_dictation): idle -> start recording,
+ // recording -> confirm and insert. Dispatched by useKeyboardShortcuts.
+ React.useEffect(() => {
+ const onToggle = () => {
+ if (statusRef.current === 'idle') {
+ void startDictation();
+ } else if (statusRef.current === 'recording') {
+ pendingActionRef.current = 'insert';
+ void confirmDictation();
+ }
+ };
+ window.addEventListener('openchamber:dictation-toggle', onToggle);
+ return () => window.removeEventListener('openchamber:dictation-toggle', onToggle);
+ }, [startDictation, confirmDictation]);
+
+ // While recording: Enter confirms (insert), Escape cancels. Capture-phase
+ // so the composer's own Enter-to-send never fires underneath the overlay.
+ React.useEffect(() => {
+ if (status !== 'recording') {
+ return;
+ }
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.isComposing) {
+ return;
+ }
+ if (event.key === 'Enter' && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
+ event.preventDefault();
+ event.stopPropagation();
+ pendingActionRef.current = 'insert';
+ void confirmDictation();
+ return;
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ event.stopPropagation();
+ void cancelDictation();
+ }
+ };
+ window.addEventListener('keydown', onKeyDown, { capture: true });
+ return () => window.removeEventListener('keydown', onKeyDown, { capture: true });
+ }, [status, confirmDictation, cancelDictation]);
+
+ // Pixel-parity with the composer: the real footer row is taller than our
+ // buttons (its height comes from the tallest control, e.g. the model
+ // picker), so measure it — it stays mounted underneath the overlay — and
+ // give our action row the same height so the icons line up exactly.
+ const overlayRef = React.useRef(null);
+ const [footerHeight, setFooterHeight] = React.useState(null);
+ const isActiveStatus = status !== 'idle';
+ React.useLayoutEffect(() => {
+ if (!isActiveStatus) {
+ return;
+ }
+ // The overlay is rendered inside the composer footer itself, so the
+ // real footer is an ancestor, not a sibling.
+ const realFooter = overlayRef.current?.closest('[data-chat-input-footer="true"]');
+ if (realFooter && realFooter.offsetHeight > 0) {
+ setFooterHeight(realFooter.offsetHeight);
+ }
+ }, [isActiveStatus]);
+
+ if (!supported || !dictationEnabled) {
+ return null;
+ }
+
+ const isActive = status !== 'idle';
+
+ const confirmWith = (action: 'insert' | 'send') => {
+ pendingActionRef.current = action;
+ void confirmDictation();
+ };
+
+ const retry = () => {
+ pendingActionRef.current = 'insert';
+ void retryFailedDictation();
+ };
+
+ const placeholderText = (() => {
+ if (status === 'failed') {
+ return '';
+ }
+ if (status === 'uploading') {
+ return t('chat.dictation.processing');
+ }
+ if (isModelDownloading) {
+ return downloadPercent !== null
+ ? t('chat.dictation.downloadingModelProgress', { percent: String(downloadPercent) })
+ : t('chat.dictation.downloadingModel');
+ }
+ return t('chat.dictation.listening');
+ })();
+
+ // Dictation must not dismiss the soft keyboard: block the focus transfer
+ // iOS performs on tap for the mic and every overlay control (same pattern
+ // as PermissionAutoAcceptButton).
+ const keepKeyboardFocusProps = {
+ onMouseDown: (event: React.MouseEvent) => event.preventDefault(),
+ onPointerDownCapture: (event: React.PointerEvent) => {
+ if (event.pointerType === 'touch') {
+ event.preventDefault();
+ }
+ },
+ } as const;
+
+ return (
+ <>
+ {renderTrigger ? (
+ {
+ void startDictation();
+ }}
+ disabled={disabled || isActive}
+ title={dictationShortcut ? `${t('chat.dictation.start')} (${dictationShortcut})` : t('chat.dictation.start')}
+ aria-label={t('chat.dictation.start')}
+ >
+
+
+ ) : null}
+ {isActive ? (
+
+ {topAccessory}
+
+ {partialTranscript ? (
+
+ {partialTranscript}
+
+ ) : (
+
+ {placeholderText}
+
+ )}
+ {status === 'failed' ? (
+
+ {error || t('chat.dictation.failed')}
+
+ ) : null}
+ {status === 'recording' && error && !isModelDownloading ? (
+
+ {error}
+
+ ) : null}
+
+
+ {status === 'recording' ? (
+ <>
+
+
+
+
+
+
+ {formatDuration(duration)}
+
+ >
+ ) : status === 'uploading' ? (
+
+ ) : null}
+ {/* Same inter-control gap as the composer's right cluster:
+ gap-x-1 on mobile, md:gap-x-3 on desktop. */}
+
+ {status === 'recording' ? (
+ <>
+ {
+ void cancelDictation();
+ }}
+ title={t('chat.dictation.cancel')}
+ aria-label={t('chat.dictation.cancel')}
+ >
+
+
+ confirmWith('insert')}
+ title={t('chat.dictation.insert')}
+ aria-label={t('chat.dictation.insert')}
+ >
+
+
+ confirmWith('send')}
+ title={t('chat.dictation.insertAndSend')}
+ aria-label={t('chat.dictation.insertAndSend')}
+ >
+
+
+ >
+ ) : status === 'uploading' ? (
+ {
+ void cancelDictation();
+ }}
+ title={t('chat.dictation.cancel')}
+ aria-label={t('chat.dictation.cancel')}
+ >
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+ {partialTranscript.trim() ? (
+ {
+ pendingActionRef.current = 'insert';
+ acceptPartialTranscript();
+ }}
+ title={t('chat.dictation.insert')}
+ aria-label={t('chat.dictation.insert')}
+ >
+
+
+ ) : null}
+ >
+ )}
+
+
+
+ ) : null}
+ >
+ );
+};
diff --git a/packages/ui/src/components/feedback/ShareOpinionDialog.tsx b/packages/ui/src/components/feedback/ShareOpinionDialog.tsx
new file mode 100644
index 0000000000..b3609c63da
--- /dev/null
+++ b/packages/ui/src/components/feedback/ShareOpinionDialog.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { Icon } from '@/components/icon/Icon';
+import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
+import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
+import { useI18n } from '@/lib/i18n';
+import { openExternalUrl } from '@/lib/url';
+
+type ShareOpinionDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+const SHARE_OPINION_MARKDOWN = `**Help shape what OpenChamber becomes next!**
+
+Hey 👋,
+
+OpenChamber has grown mostly through word of mouth, GitHub issues, Discord feedback, and people telling me what is broken, confusing, or surprisingly useful.
+
+I'm planning the next chapter now - mobile, better remote access, a tighter VS Code ↔ desktop/web/mobile flow, and more, but before building too much, I want to hear from the people actually using it.
+
+I'm doing a round of short 1-on-1 calls. No sales pitch, no formal script - just a real conversation about how you use OpenChamber, what you love, what frustrates you, and what would make it much more valuable.
+
+You can book a call or use the short survey below.
+
+What you get:
+
+- A direct chance to influence the roadmap
+- Your pain points and feature requests prioritized with more context
+- A Power User role in Discord for people helping shape the product
+- My genuine thanks for helping make OpenChamber better
+
+**This project is what it is because of your feedback. Thank you, genuinely.**
+
+I'll remove this button in 10 days 🙂`;
+
+export function ShareOpinionDialog({ open, onOpenChange }: ShareOpinionDialogProps): React.ReactNode {
+ const { t } = useI18n();
+
+ return (
+
+
+ {t('shareOpinion.dialog.title')}
+
+
+ void openExternalUrl('https://calendly.com/artmore/30min')}
+ >
+
+ {t('shareOpinion.actions.bookCall')}
+
+ void openExternalUrl('https://forms.gle/cdMVKUGs5QuLWkA86')}
+ >
+
+ {t('shareOpinion.actions.shortSurvey')}
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts
index eb415ebc41..0cc872c6a3 100644
--- a/packages/ui/src/components/icon/sprite.ts
+++ b/packages/ui/src/components/icon/sprite.ts
@@ -10,6 +10,7 @@ export const iconSpriteData = {
"alert": ` `,
"align-justify": ` `,
"apple": ` `,
+ "apps-2-ai": ` `,
"archive": ` `,
"archive-stack": ` `,
"arrow-down": ` `,
@@ -157,10 +158,8 @@ export const iconSpriteData = {
"macbook": ` `,
"menu-2": ` `,
"menu-fold-2": ` `,
- "menu": ` `,
"menu-search": ` `,
"mic": ` `,
- "mic-off": ` `,
"more-2-fill": ` `,
"more-2": ` `,
"more": ` `,
@@ -168,6 +167,7 @@ export const iconSpriteData = {
"node-tree": ` `,
"notification-3": ` `,
"palette": ` `,
+ "pencil-ai-2": ` `,
"pencil-ai": ` `,
"pencil": ` `,
"picture-in-picture-2": ` `,
@@ -211,7 +211,6 @@ export const iconSpriteData = {
"star-fill": ` `,
"star": ` `,
"sticky-note": ` `,
- "stop-circle": ` `,
"stop": ` `,
"subtract": ` `,
"survey": ` `,
@@ -228,7 +227,7 @@ export const iconSpriteData = {
"unpin": ` `,
"user-3": ` `,
"user": ` `,
- "voice-recognition": ` `,
+ "video-chat": ` `,
"volume-up": ` `,
"window": ` `,
} as const satisfies Record;
diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx
index a2dd707d06..10d44b28b8 100644
--- a/packages/ui/src/components/layout/VSCodeLayout.tsx
+++ b/packages/ui/src/components/layout/VSCodeLayout.tsx
@@ -108,6 +108,10 @@ export const VSCodeLayout: React.FC = () => {
}, []);
const [currentView, setCurrentView] = React.useState(() => (bootDraftOpen ? 'chat' : 'sessions'));
+ // Mirror currentView so the navigate event handler (registered once) can read the live value.
+ const currentViewRef = React.useRef(currentView);
+ // Snapshot of the view the user was on before opening Settings, so close restores it.
+ const viewBeforeSettingsRef = React.useRef(null);
const [containerWidth, setContainerWidth] = React.useState(0);
const [expandedSidebarWidth, setExpandedSidebarWidth] = React.useState(SESSIONS_SIDEBAR_WIDTH);
const [isResizingExpandedSidebar, setIsResizingExpandedSidebar] = React.useState(false);
@@ -185,6 +189,11 @@ export const VSCodeLayout: React.FC = () => {
}
}, [currentSessionId]);
+ // Keep currentViewRef in sync so the stable navigate handler reads the live view.
+ React.useEffect(() => {
+ currentViewRef.current = currentView;
+ }, [currentView]);
+
React.useEffect(() => {
const vscodeApi = runtimeApis.vscode;
if (!vscodeApi) {
@@ -347,6 +356,9 @@ export const VSCodeLayout: React.FC = () => {
const detail = (event as CustomEvent<{ view?: string }>).detail;
const view = detail?.view;
if (view === 'settings') {
+ if (currentViewRef.current !== 'settings') {
+ viewBeforeSettingsRef.current = currentViewRef.current;
+ }
setCurrentView('settings');
} else if (view === 'chat') {
setCurrentView('chat');
@@ -532,7 +544,11 @@ export const VSCodeLayout: React.FC = () => {
// Settings view
setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')}
+ onClose={() => {
+ const previousView = viewBeforeSettingsRef.current;
+ viewBeforeSettingsRef.current = null;
+ setCurrentView(previousView ?? (usesExpandedLayout ? 'chat' : 'sessions'));
+ }}
forceMobile={usesMobileLayout}
/>
diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx
index 6f86fee3e2..a5013bea3a 100644
--- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx
@@ -39,6 +39,9 @@ export const DefaultsSettings: React.FC = () => {
const [defaultModel, setDefaultModel] = React.useState();
const [defaultVariant, setDefaultVariant] = React.useState();
const [defaultAgent, setDefaultAgent] = React.useState();
+ const [smallModelUseDefault, setSmallModelUseDefault] = React.useState(true);
+ const [smallModelOverride, setSmallModelOverride] = React.useState();
+ const [smallModelProviders, setSmallModelProviders] = React.useState();
const [isLoading, setIsLoading] = React.useState(true);
const parsedModel = React.useMemo(() => getDisplayModel(defaultModel), [defaultModel]);
@@ -50,6 +53,8 @@ export const DefaultsSettings: React.FC = () => {
defaultModel?: string;
defaultVariant?: string;
defaultAgent?: string;
+ smallModelUseDefault?: boolean;
+ smallModelOverride?: string;
} | null = null;
if (!data) {
@@ -59,13 +64,16 @@ export const DefaultsSettings: React.FC = () => {
const result = await runtimeSettings.load();
const settings = result?.settings;
if (settings) {
+ const raw = settings as Record;
data = {
defaultModel: typeof settings.defaultModel === 'string' ? settings.defaultModel : undefined,
defaultVariant:
- typeof (settings as Record).defaultVariant === 'string'
- ? ((settings as Record).defaultVariant as string)
+ typeof raw.defaultVariant === 'string'
+ ? (raw.defaultVariant as string)
: undefined,
defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined,
+ smallModelUseDefault: typeof raw.smallModelUseDefault === 'boolean' ? raw.smallModelUseDefault : undefined,
+ smallModelOverride: typeof raw.smallModelOverride === 'string' ? raw.smallModelOverride : undefined,
};
}
} catch {
@@ -101,6 +109,10 @@ export const DefaultsSettings: React.FC = () => {
if (model !== undefined) setDefaultModel(model);
if (variant !== undefined) setDefaultVariant(variant);
if (agent !== undefined) setDefaultAgent(agent);
+ if (typeof data.smallModelUseDefault === 'boolean') setSmallModelUseDefault(data.smallModelUseDefault);
+ if (typeof data.smallModelOverride === 'string' && data.smallModelOverride.trim()) {
+ setSmallModelOverride(data.smallModelOverride.trim());
+ }
}
} catch (error) {
console.warn('Failed to load defaults settings:', error);
@@ -189,6 +201,53 @@ export const DefaultsSettings: React.FC = () => {
[setAgent, setSettingsDefaultAgent]
);
+ const handleSmallModelUseDefaultChange = React.useCallback(
+ async (useDefault: boolean) => {
+ setSmallModelUseDefault(useDefault);
+ try {
+ await updateDesktopSettings({ smallModelUseDefault: useDefault });
+ } catch (error) {
+ console.warn('Failed to save small model preference:', error);
+ }
+ },
+ []
+ );
+
+ const handleSmallModelOverrideChange = React.useCallback(
+ async (providerId: string, modelId: string) => {
+ const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
+ setSmallModelOverride(newValue);
+ try {
+ await updateDesktopSettings({ smallModelOverride: newValue ?? '' });
+ } catch (error) {
+ console.warn('Failed to save small model override:', error);
+ }
+ },
+ []
+ );
+
+ const parsedSmallModel = React.useMemo(() => getDisplayModel(smallModelOverride), [smallModelOverride]);
+
+ React.useEffect(() => {
+ if (smallModelUseDefault || smallModelProviders !== undefined) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ const response = await runtimeFetch('/api/small-model', { method: 'GET', headers: { Accept: 'application/json' } });
+ if (!response.ok) return;
+ const payload = await response.json().catch(() => null) as { authenticatedProviders?: unknown } | null;
+ if (!cancelled && Array.isArray(payload?.authenticatedProviders)) {
+ setSmallModelProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string'));
+ }
+ } catch {
+ // leave undefined — picker falls back to showing all providers
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [smallModelUseDefault, smallModelProviders]);
+
const availableVariants = React.useMemo(() => {
if (!parsedModel.providerId || !parsedModel.modelId) return [];
const provider = providers.find((p) => p.id === parsedModel.providerId);
@@ -305,6 +364,56 @@ export const DefaultsSettings: React.FC = () => {
+
+
+
+
{t('settings.openchamber.defaults.smallModel.title')}
+
+
+
+
+
+ {t('settings.openchamber.defaults.smallModel.description')}
+
+
+ void handleSmallModelUseDefaultChange(!smallModelUseDefault)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ void handleSmallModelUseDefaultChange(!smallModelUseDefault);
+ }
+ }}
+ >
+ void handleSmallModelUseDefaultChange(checked)}
+ ariaLabel={t('settings.openchamber.defaults.smallModel.useDefaultAria')}
+ />
+ {t('settings.openchamber.defaults.smallModel.useDefault')}
+
+
+ {!smallModelUseDefault ? (
+
+
+ {t('settings.openchamber.defaults.smallModel.overrideModel')}
+
+
+
+
+
+ ) : null}
+
);
};
diff --git a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx
index a2861c3c9c..661d492f65 100644
--- a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx
@@ -5,10 +5,12 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
getDesktopLanAddress,
+ getDesktopKeepAwake,
getDesktopLaunchAtLogin,
isDesktopLocalOriginActive,
isDesktopShell,
restartDesktopApp,
+ setDesktopKeepAwake,
setDesktopLaunchAtLogin,
} from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
@@ -29,6 +31,9 @@ export const DesktopNetworkSettings: React.FC = () => {
const [launchAtLoginSupported, setLaunchAtLoginSupported] = React.useState(false);
const [launchAtLoginEnabled, setLaunchAtLoginEnabled] = React.useState(false);
const [isSavingLaunchAtLogin, setIsSavingLaunchAtLogin] = React.useState(false);
+ const [keepAwakeSupported, setKeepAwakeSupported] = React.useState(false);
+ const [keepAwakeEnabled, setKeepAwakeEnabled] = React.useState(false);
+ const [isSavingKeepAwake, setIsSavingKeepAwake] = React.useState(false);
const [error, setError] = React.useState(null);
const [lanAddress, setLanAddress] = React.useState(null);
@@ -107,6 +112,27 @@ export const DesktopNetworkSettings: React.FC = () => {
};
}, [isLocalDesktop]);
+ React.useEffect(() => {
+ if (!isLocalDesktop) {
+ setKeepAwakeSupported(false);
+ return;
+ }
+
+ let cancelled = false;
+ void (async () => {
+ const status = await getDesktopKeepAwake();
+ if (cancelled) {
+ return;
+ }
+ setKeepAwakeSupported(status?.supported === true);
+ setKeepAwakeEnabled(status?.enabled === true);
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [isLocalDesktop]);
+
React.useEffect(() => {
if (!isLocalDesktop || !draftValue) {
setLanAddress(null);
@@ -183,6 +209,30 @@ export const DesktopNetworkSettings: React.FC = () => {
}
}, [isSavingLaunchAtLogin, launchAtLoginEnabled, launchAtLoginSupported, t]);
+ const handleKeepAwakeToggle = React.useCallback(async () => {
+ if (!keepAwakeSupported || isSavingKeepAwake) {
+ return;
+ }
+
+ const nextValue = !keepAwakeEnabled;
+ setKeepAwakeEnabled(nextValue);
+ setIsSavingKeepAwake(true);
+ setError(null);
+
+ try {
+ const status = await setDesktopKeepAwake(nextValue);
+ if (!status?.supported) {
+ throw new Error(t('settings.openchamber.desktopNetwork.error.keepAwakeUnsupported'));
+ }
+ setKeepAwakeEnabled(status.enabled);
+ } catch (cause) {
+ setKeepAwakeEnabled(!nextValue);
+ setError(cause instanceof Error ? cause.message : t('settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed'));
+ } finally {
+ setIsSavingKeepAwake(false);
+ }
+ }, [isSavingKeepAwake, keepAwakeEnabled, keepAwakeSupported, t]);
+
const handleSaveAndRestart = React.useCallback(async () => {
if (!isDirty) {
return;
@@ -261,6 +311,35 @@ export const DesktopNetworkSettings: React.FC = () => {
) : null}
+ {keepAwakeSupported ? (
+ {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ handleKeepAwakeToggle();
+ }
+ }}
+ >
+
+
+
{t('settings.openchamber.desktopNetwork.field.keepAwake')}
+
+ {t('settings.openchamber.desktopNetwork.field.keepAwakeDescription')}
+
+
+
+ ) : null}
+
{t('settings.openchamber.desktopPassword.field.password')}
diff --git a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx
index 388bdcb9f2..9dcbbd8a7f 100644
--- a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx
@@ -7,6 +7,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
+import { getClientPlatform } from '@/lib/platform';
import { useI18n } from '@/lib/i18n';
const DEFAULT_NOTIFICATION_TEMPLATES = {
@@ -39,7 +40,15 @@ export const NotificationSettings: React.FC = () => {
const { t } = useI18n();
const isDesktop = React.useMemo(() => isDesktopShell(), []);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
- const isBrowser = !isDesktop && !isVSCode;
+ // The native Capacitor app runs in a WKWebView with no Web Notification API; it has its
+ // own native (Local Notifications) permission. Treat it as a native runtime, not a
+ // browser, so the toggle isn't gated on Notification.permission (which is stuck there).
+ const isNativeApp = React.useMemo(() => {
+ if (typeof window === 'undefined') return false;
+ const capacitor = (window as typeof window & { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
+ return capacitor?.isNativePlatform?.() === true || window.location.protocol === 'capacitor:';
+ }, []);
+ const isBrowser = !isDesktop && !isVSCode && !isNativeApp;
const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled);
const setNativeNotificationsEnabled = useUIStore(state => state.setNativeNotificationsEnabled);
const notificationMode = useUIStore(state => state.notificationMode);
@@ -131,7 +140,7 @@ export const NotificationSettings: React.FC = () => {
}
};
- const canShowNotifications = isDesktop || isVSCode || (isBrowser && typeof Notification !== 'undefined' && Notification.permission === 'granted');
+ const canShowNotifications = isDesktop || isVSCode || isNativeApp || (isBrowser && typeof Notification !== 'undefined' && Notification.permission === 'granted');
const updateTemplate = (
event: 'completion' | 'error' | 'question' | 'subtask',
@@ -383,6 +392,7 @@ export const NotificationSettings: React.FC = () => {
auth: keys.auth,
},
origin: typeof window !== 'undefined' ? window.location.origin : undefined,
+ platform: getClientPlatform(),
}),
15000,
'Push subscribe request timed out'
@@ -474,7 +484,10 @@ export const NotificationSettings: React.FC = () => {
{t('settings.notifications.page.delivery.enableLabel')}
- {nativeNotificationsEnabled && canShowNotifications && (
+ {/* The native Capacitor app never notifies while focused (hard rule) and uses
+ generic, non-customizable text, so the "notify while focused" toggle and the
+ test button are hidden there. */}
+ {nativeNotificationsEnabled && canShowNotifications && !isNativeApp && (
<>
{
- {/* --- Template Customization --- */}
+ {/* --- Template Customization (not on the native app — it uses generic text) --- */}
+ {!isNativeApp && (
@@ -666,6 +680,7 @@ export const NotificationSettings: React.FC = () => {
))}
+ )}
>
)}
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
index 3cd6dfe085..8bfee71ff9 100644
--- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx
@@ -142,9 +142,9 @@ const VisualSectionContent: React.FC = () => {
]} />;
};
-// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft
+// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Follow-up behavior, Persist draft
const ChatSectionContent: React.FC = () => {
- return ;
+ return ;
};
// Sessions section: Default model & agent, Session retention
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
index c1efef3d0f..a9cf147b2e 100644
--- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
@@ -5,8 +5,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ThemeMode } from '@/types/theme';
import { useUIStore } from '@/stores/useUIStore';
-import { useMessageQueueStore } from '@/stores/messageQueueStore';
-import { cn, getModifierLabel } from '@/lib/utils';
+import { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore';
+import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { NumberInput } from '@/components/ui/number-input';
@@ -230,11 +230,22 @@ const WEEK_START_OPTIONS: Option<'auto' | 'monday' | 'sunday'>[] = [
},
];
+const FOLLOW_UP_BEHAVIOR_OPTIONS: Option[] = [
+ {
+ id: 'steer',
+ labelKey: 'settings.openchamber.visual.option.followUpBehavior.steer.label',
+ },
+ {
+ id: 'queue',
+ labelKey: 'settings.openchamber.visual.option.followUpBehavior.queue.label',
+ },
+];
+
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
return mode === 'markdown' ? 'markdown' : 'plain';
};
-type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
+type VisibleSetting = 'sessionAssist' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -248,6 +259,8 @@ export const OpenChamberVisualSettings: React.FC
const { browserTab } = usePwaDetection();
const directoryShowHidden = useDirectoryShowHidden();
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
+ const sessionAssistEnabled = useUIStore(state => state.sessionAssistEnabled);
+ const setSessionAssistEnabled = useUIStore(state => state.setSessionAssistEnabled);
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks);
const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks);
@@ -288,8 +301,8 @@ export const OpenChamberVisualSettings: React.FC
const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop);
const fileEditorKeymap = useUIStore(state => state.fileEditorKeymap);
const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap);
- const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled);
- const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
+ const followUpBehavior = useMessageQueueStore(state => state.followUpBehavior);
+ const setFollowUpBehavior = useMessageQueueStore(state => state.setFollowUpBehavior);
const persistChatDraft = useUIStore(state => state.persistChatDraft);
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft);
const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled);
@@ -310,6 +323,7 @@ export const OpenChamberVisualSettings: React.FC
const setShowSplitAssistantMessageActions = useUIStore(state => state.setShowSplitAssistantMessageActions);
const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport);
const setMessageStreamTransport = useConfigStore((state) => state.setSettingsMessageStreamTransport);
+ const effectiveMessageStreamTransport = messageStreamTransport;
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
const setSettingsDefaultFileViewerPreview = useConfigStore((state) => state.setSettingsDefaultFileViewerPreview);
const isSettingsDialogOpen = useUIStore(state => state.isSettingsDialogOpen);
@@ -550,7 +564,7 @@ export const OpenChamberVisualSettings: React.FC
|| shouldShow('dotfiles')
|| shouldShow('fileViewerPreview')
|| shouldShow('reasoning')
- || shouldShow('queueMode')
+ || shouldShow('followUpBehavior')
|| shouldShow('persistDraft')
|| shouldShow('showToolFileIcons')
|| shouldShow('expandedTools')
@@ -1418,7 +1432,7 @@ export const OpenChamberVisualSettings: React.FC
{hasBehaviorSettings && (
- {(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || shouldShow('messageTransport') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode)) && (
+ {(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || shouldShow('messageTransport') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode) || shouldShow('followUpBehavior')) && (
{shouldShow('chatRenderMode') && (
@@ -1514,7 +1528,7 @@ export const OpenChamberVisualSettings: React.FC
key={option.id}
variant="chip"
size="xs"
- aria-pressed={messageStreamTransport === option.id}
+ aria-pressed={effectiveMessageStreamTransport === option.id}
className="!font-normal"
onClick={() => handleMessageStreamTransportChange(option.id)}
>
@@ -1524,7 +1538,7 @@ export const OpenChamberVisualSettings: React.FC
{(() => {
- const option = MESSAGE_STREAM_TRANSPORT_OPTIONS.find((item) => item.id === messageStreamTransport);
+ const option = MESSAGE_STREAM_TRANSPORT_OPTIONS.find((item) => item.id === effectiveMessageStreamTransport);
return option?.descriptionKey ? tUnsafe(option.descriptionKey) : '';
})()}
@@ -1724,11 +1738,70 @@ export const OpenChamberVisualSettings: React.FC
)}
+ {shouldShow('followUpBehavior') && (
+
+ {t('settings.openchamber.visual.section.followUpBehavior')}
+
+ {FOLLOW_UP_BEHAVIOR_OPTIONS.map((option) => {
+ const selected = followUpBehavior === option.id;
+ return (
+
setFollowUpBehavior(option.id)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setFollowUpBehavior(option.id);
+ }
+ }}
+ className="flex w-full items-center gap-2 py-0 text-left"
+ >
+ setFollowUpBehavior(option.id)}
+ ariaLabel={t('settings.openchamber.visual.field.followUpBehaviorAria', { option: tUnsafe(option.labelKey) })}
+ />
+
+ {tUnsafe(option.labelKey)}
+
+
+ );
+ })}
+
+
+ )}
+
)}
- {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
+ {(shouldShow('sessionAssist') || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
+ {shouldShow('sessionAssist') && (
+ setSessionAssistEnabled(!sessionAssistEnabled)}
+ onKeyDown={(event) => {
+ if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ setSessionAssistEnabled(!sessionAssistEnabled);
+ }
+ }}
+ >
+
+ {t('settings.openchamber.visual.field.sessionAssist')}
+
+ )}
{shouldShow('reasoning') && (
)}
- {shouldShow('queueMode') && (
- setQueueMode(!queueModeEnabled)}
- onKeyDown={(event) => {
- if (event.key === ' ' || event.key === 'Enter') {
- event.preventDefault();
- setQueueMode(!queueModeEnabled);
- }
- }}
- >
-
-
- {t('settings.openchamber.visual.field.queueMessagesByDefault')}
-
-
-
-
-
- {t('settings.openchamber.visual.field.queueMessagesByDefaultTooltip', { modifier: getModifierLabel() })}
-
-
-
-
- )}
-
{shouldShow('persistDraft') && (
{
const handleSaveAndReload = React.useCallback(async () => {
setIsSaving(true);
try {
- await updateDesktopSettings({ opencodeBinary: value.trim() });
+ // Strip a wrapping quote pair (Windows "Copy as path" pastes) — literal
+ // quotes are never part of a real path.
+ const trimmed = value.trim();
+ const unquoted = trimmed.length >= 2
+ && ((trimmed.startsWith('"') && trimmed.endsWith('"'))
+ || (trimmed.startsWith("'") && trimmed.endsWith("'")))
+ ? trimmed.slice(1, -1).trim()
+ : trimmed;
+ await updateDesktopSettings({ opencodeBinary: unquoted });
await reloadOpenCodeConfiguration({
message: t('settings.openchamber.opencodeCli.actions.restartingOpenCode'),
mode: 'projects',
diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
index 93da103a31..5bf4847bc2 100644
--- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx
@@ -1,5 +1,4 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
-import { useBrowserVoice } from '@/hooks/useBrowserVoice';
import { useConfigStore } from '@/stores/useConfigStore';
import { useDeviceInfo } from '@/lib/device';
@@ -11,103 +10,362 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
+import { Radio } from '@/components/ui/radio';
import { Button } from '@/components/ui/button';
import { NumberInput } from '@/components/ui/number-input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
-import { audioStreamService } from '@/lib/voice/audioStreamService';
-import { wasmSttService, WASM_MODELS } from '@/lib/voice/wasmSttService';
-import type { WasmModelStatus } from '@/lib/voice/wasmSttService';
import { cn } from '@/lib/utils';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useI18n } from '@/lib/i18n';
+import { useLocalTTS } from '@/hooks/useLocalTTS';
import { disposePreviewAudio } from './voicePreviewAudio';
-const LANGUAGE_OPTIONS = [
- { value: 'en-US', label: 'English' },
- { value: 'es-ES', label: 'Español' },
- { value: 'fr-FR', label: 'Français' },
- { value: 'de-DE', label: 'Deutsch' },
- { value: 'ja-JP', label: '日本語' },
- { value: 'zh-CN', label: '中文' },
- { value: 'pt-BR', label: 'Português' },
- { value: 'it-IT', label: 'Italiano' },
- { value: 'ko-KR', label: '한국어' },
- { value: 'uk-UA', label: 'Українська' },
-];
-const WasmModelStatusIndicator = ({ modelId }: { modelId: string }) => {
+const LOCAL_STT_MODELS = [
+ {
+ id: 'parakeet-tdt-0.6b-v2-int8',
+ labelKey: 'settings.voice.page.stt.model.parakeetV2',
+ badgeKey: 'settings.voice.page.stt.badge.bestForEnglish',
+ accuracy: 5,
+ speed: 5,
+ size: '460 MB',
+ },
+ {
+ id: 'parakeet-tdt-0.6b-v3-int8',
+ labelKey: 'settings.voice.page.stt.model.parakeetV3',
+ badgeKey: 'settings.voice.page.stt.badge.bestForMultilingual',
+ accuracy: 5,
+ speed: 4,
+ size: '465 MB',
+ },
+ {
+ id: 'whisper-base-int8',
+ labelKey: 'settings.voice.page.stt.model.whisperBase',
+ badgeKey: null,
+ accuracy: 3,
+ speed: 3,
+ size: '200 MB',
+ },
+ {
+ id: 'whisper-tiny-int8',
+ labelKey: 'settings.voice.page.stt.model.whisperTiny',
+ badgeKey: null,
+ accuracy: 2,
+ speed: 4,
+ size: '115 MB',
+ },
+] as const;
+
+interface DictationModelState {
+ id: string;
+ installed: boolean;
+ downloading: boolean;
+ downloadProgress: number | null;
+ downloadError: string | null;
+}
+
+const RatingBar = ({ value, label }: { value: number; label: string }) => (
+
+
+
+
+ {label}
+
+);
+
+const LocalModelPicker = ({
+ selectedModelId,
+ onSelect,
+}: {
+ selectedModelId: string;
+ onSelect: (modelId: string) => void;
+}) => {
const { t } = useI18n();
- const [status, setStatus] = useState
(wasmSttService.getModelStatus());
- const [loading, setLoading] = useState(false);
+ const tUnsafe = useCallback((key: string) => t(key as Parameters[0]), [t]);
+ const [models, setModels] = useState>(new Map());
+ const [requestingId, setRequestingId] = useState(null);
- useEffect(() => {
- const handler = (s: WasmModelStatus) => setStatus(s);
- wasmSttService.onModelStatusChange = handler;
- return () => { wasmSttService.onModelStatusChange = null; };
+ const refresh = useCallback(async () => {
+ try {
+ const response = await runtimeFetch('/api/dictation/status', {
+ query: { provider: 'local' },
+ });
+ if (!response.ok) {
+ return;
+ }
+ const data = await response.json();
+ if (Array.isArray(data?.models)) {
+ setModels(new Map(data.models.map((m: DictationModelState) => [m.id, m])));
+ }
+ } catch {
+ // Display-only status; keep the previous state on fetch failure.
+ }
}, []);
- const currentModelId = wasmSttService.getCurrentModelId();
- const isLoadingOrDownloading = status.state === 'downloading' || status.state === 'loading';
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
- // Reset local loading state when model finishes loading or errors
+ const anyDownloading = Array.from(models.values()).some((m) => m.downloading);
useEffect(() => {
- if (status.state !== 'downloading' && status.state !== 'loading') {
- setLoading(false);
+ if (!anyDownloading) {
+ return;
}
- }, [status.state]);
+ const interval = setInterval(() => {
+ void refresh();
+ }, 2000);
+ return () => clearInterval(interval);
+ }, [anyDownloading, refresh]);
+
+ const handleDownload = async (modelId: string) => {
+ setRequestingId(modelId);
+ try {
+ await runtimeFetch(`/api/dictation/models/${encodeURIComponent(modelId)}/download`, {
+ method: 'POST',
+ });
+ await refresh();
+ } catch {
+ // Status refresh reports errors.
+ } finally {
+ setRequestingId(null);
+ }
+ };
- const handleDownload = async () => {
- setLoading(true);
+ const handleDelete = async (modelId: string) => {
+ setRequestingId(modelId);
try {
- await wasmSttService.loadModel(modelId);
+ await runtimeFetch(`/api/dictation/models/${encodeURIComponent(modelId)}`, {
+ method: 'DELETE',
+ });
+ await refresh();
} catch {
- // Error is shown via status indicator
+ // Status refresh reports errors.
+ } finally {
+ setRequestingId(null);
}
};
- if (status.state === 'ready' && currentModelId === modelId) {
- return (
-
-
- {t('settings.voice.page.stt.wasmLoaded')}
-
-
- );
- }
+ return (
+
+ {LOCAL_STT_MODELS.map((entry) => {
+ const selected = selectedModelId === entry.id;
+ const state = models.get(entry.id) ?? null;
+ return (
+
+
+ onSelect(entry.id)}
+ ariaLabel={tUnsafe(entry.labelKey)}
+ />
+
+
onSelect(entry.id)}
+ onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(entry.id); } }}
+ >
+
+
+ {tUnsafe(entry.labelKey)}
+
+ {entry.badgeKey ? (
+
+ {tUnsafe(entry.badgeKey)}
+
+ ) : null}
+
+
+
+
+ {entry.size}
+
+ {state?.downloadError ? (
+
{state.downloadError}
+ ) : null}
+
+
+ {state?.installed ? (
+ <>
+ { void handleDelete(entry.id); }}
+ title={t('settings.voice.page.stt.modelDelete')}
+ aria-label={t('settings.voice.page.stt.modelDelete')}
+ >
+
+
+
+ >
+ ) : state?.downloading ? (
+
+
+
+ {typeof state.downloadProgress === 'number' ? `${state.downloadProgress}%` : ''}
+
+
+ ) : (
+ { void handleDownload(entry.id); }}
+ title={t('settings.voice.page.stt.modelDownload')}
+ aria-label={t('settings.voice.page.stt.modelDownload')}
+ >
+
+
+ )}
+
+
+ );
+ })}
+
+ );
+};
- if (isLoadingOrDownloading) {
- const progress = status.state === 'downloading' ? Math.round(status.progress) : undefined;
- return (
-
-
- {status.state === 'downloading' ? t('settings.voice.page.stt.wasmDownloading') : t('settings.voice.page.stt.wasmLoading')}
- {progress !== undefined ? ` (${progress}%)` : ''}
-
-
- );
- }
+/** Kokoro en_v0_19 speaker ids in sherpa-onnx order. */
+const KOKORO_VOICE_OPTIONS = [
+ { id: 0, label: 'Alloy (af)' },
+ { id: 1, label: 'Bella (af)' },
+ { id: 2, label: 'Nicole (af)' },
+ { id: 3, label: 'Sarah (af)' },
+ { id: 4, label: 'Sky (af)' },
+ { id: 5, label: 'Adam (am)' },
+ { id: 6, label: 'Michael (am)' },
+ { id: 7, label: 'Emma (bf)' },
+ { id: 8, label: 'Isabella (bf)' },
+ { id: 9, label: 'George (bm)' },
+ { id: 10, label: 'Lewis (bm)' },
+];
- if (status.state === 'error') {
- // Partial download (cached progress): show retry button.
- return (
-
- {status.error}
-
- {t('settings.voice.page.stt.wasmRetry')}
-
-
- );
+const LOCAL_TTS_MODEL_ID = 'kokoro-en-v0_19';
+
+const LocalTtsModelStatus = () => {
+ const { t } = useI18n();
+ const [model, setModel] = useState(null);
+ const [requesting, setRequesting] = useState(false);
+
+ const refresh = useCallback(async () => {
+ try {
+ const response = await runtimeFetch('/api/dictation/status', { query: { provider: 'local' } });
+ if (!response.ok) {
+ return;
+ }
+ const data = await response.json();
+ const entry = Array.isArray(data?.ttsModels)
+ ? data.ttsModels.find((m: DictationModelState) => m.id === LOCAL_TTS_MODEL_ID)
+ : null;
+ if (entry) {
+ setModel(entry);
+ }
+ } catch {
+ // Display-only status; keep the previous state on fetch failure.
+ }
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ useEffect(() => {
+ if (!model?.downloading) {
+ return;
+ }
+ const interval = setInterval(() => {
+ void refresh();
+ }, 2000);
+ return () => clearInterval(interval);
+ }, [model?.downloading, refresh]);
+
+ const request = async (method: 'POST' | 'DELETE') => {
+ setRequesting(true);
+ try {
+ const path = method === 'POST'
+ ? `/api/dictation/models/${LOCAL_TTS_MODEL_ID}/download`
+ : `/api/dictation/models/${LOCAL_TTS_MODEL_ID}`;
+ await runtimeFetch(path, { method });
+ await refresh();
+ } catch {
+ // Status refresh reports errors.
+ } finally {
+ setRequesting(false);
+ }
+ };
+
+ if (!model) {
+ return null;
}
return (
-
-
- {t('settings.voice.page.stt.wasmNotLoaded')}
-
-
- {t('settings.voice.page.stt.wasmDownload')}
-
+
+ Kokoro
+ 305 MB
+ {model.installed ? (
+ <>
+
+ { void request('DELETE'); }}
+ title={t('settings.voice.page.stt.modelDelete')}
+ aria-label={t('settings.voice.page.stt.modelDelete')}
+ >
+
+
+ >
+ ) : model.downloading ? (
+
+
+
+ {typeof model.downloadProgress === 'number' ? `${model.downloadProgress}%` : ''}
+
+
+ ) : (
+ { void request('POST'); }}
+ title={t('settings.voice.page.stt.modelDownload')}
+ aria-label={t('settings.voice.page.stt.modelDownload')}
+ >
+
+
+ )}
+ {model.downloadError ? (
+ {model.downloadError}
+ ) : null}
);
};
@@ -131,11 +389,6 @@ const OPENAI_VOICE_OPTIONS = [
export const VoiceSettings: React.FC = () => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
- const {
- isSupported,
- language,
- setLanguage,
- } = useBrowserVoice();
const voiceProvider = useConfigStore((state) => state.voiceProvider);
const setVoiceProvider = useConfigStore((state) => state.setVoiceProvider);
const speechRate = useConfigStore((state) => state.speechRate);
@@ -146,6 +399,22 @@ export const VoiceSettings: React.FC = () => {
const setSpeechVolume = useConfigStore((state) => state.setSpeechVolume);
const sayVoice = useConfigStore((state) => state.sayVoice);
const setSayVoice = useConfigStore((state) => state.setSayVoice);
+ const localTtsVoiceId = useConfigStore((state) => state.localTtsVoiceId);
+ const setLocalTtsVoiceId = useConfigStore((state) => state.setLocalTtsVoiceId);
+ const { speak: speakLocalTts, stop: stopLocalTts, isPlaying: isLocalTtsPlaying, error: localTtsError } = useLocalTTS();
+
+ const previewLocalVoice = useCallback(() => {
+ if (isLocalTtsPlaying) {
+ stopLocalTts();
+ return;
+ }
+ const voiceLabel = KOKORO_VOICE_OPTIONS.find((v) => v.id === localTtsVoiceId)?.label
+ ?? String(localTtsVoiceId);
+ void speakLocalTts(t('settings.voice.page.preview.voiceLine', { voiceName: voiceLabel }), {
+ speakerId: localTtsVoiceId,
+ speed: useConfigStore.getState().speechRate,
+ });
+ }, [isLocalTtsPlaying, localTtsVoiceId, speakLocalTts, stopLocalTts, t]);
const browserVoice = useConfigStore((state) => state.browserVoice);
const setBrowserVoice = useConfigStore((state) => state.setBrowserVoice);
const openaiVoice = useConfigStore((state) => state.openaiVoice);
@@ -172,19 +441,13 @@ export const VoiceSettings: React.FC = () => {
const setSttApiKey = useConfigStore((state) => state.setSttApiKey);
const sttModel = useConfigStore((state) => state.sttModel);
const setSttModel = useConfigStore((state) => state.setSttModel);
- const wasmSttModel = useConfigStore((state) => state.wasmSttModel);
- const setWasmSttModel = useConfigStore((state) => state.setWasmSttModel);
+ const sttLocalModel = useConfigStore((state) => state.sttLocalModel);
+ const setSttLocalModel = useConfigStore((state) => state.setSttLocalModel);
const sttLanguage = useConfigStore((state) => state.sttLanguage);
const setSttLanguage = useConfigStore((state) => state.setSttLanguage);
- const sttSilenceThresholdDb = useConfigStore((state) => state.sttSilenceThresholdDb);
- const setSttSilenceThresholdDb = useConfigStore((state) => state.setSttSilenceThresholdDb);
- const sttSilenceHoldMs = useConfigStore((state) => state.sttSilenceHoldMs);
- const setSttSilenceHoldMs = useConfigStore((state) => state.setSttSilenceHoldMs);
- const sttTranscribeOnStop = useConfigStore((state) => state.sttTranscribeOnStop);
- const setSttTranscribeOnStop = useConfigStore((state) => state.setSttTranscribeOnStop);
const setShowMessageTTSButtons = useConfigStore((state) => state.setShowMessageTTSButtons);
- const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled);
- const setVoiceModeEnabled = useConfigStore((state) => state.setVoiceModeEnabled);
+ const dictationEnabled = useConfigStore((state) => state.dictationEnabled);
+ const setDictationEnabled = useConfigStore((state) => state.setDictationEnabled);
const [isSayAvailable, setIsSayAvailable] = useState(false);
const [sayVoices, setSayVoices] = useState
>([]);
@@ -274,7 +537,7 @@ export const VoiceSettings: React.FC = () => {
}, [isBrowserPreviewPlaying]);
useEffect(() => {
- if (!voiceModeEnabled || (voiceProvider !== 'openai' && voiceProvider !== 'openai-compatible')) {
+ if (!showMessageTTSButtons || (voiceProvider !== 'openai' && voiceProvider !== 'openai-compatible')) {
setIsOpenAIAvailable(openaiApiKey.trim().length > 0);
return;
}
@@ -292,10 +555,10 @@ export const VoiceSettings: React.FC = () => {
};
checkOpenAIAvailability();
- }, [openaiApiKey, voiceModeEnabled, voiceProvider]);
+ }, [openaiApiKey, showMessageTTSButtons, voiceProvider]);
useEffect(() => {
- if (!voiceModeEnabled) {
+ if (!showMessageTTSButtons) {
setIsSayAvailable(false);
setSayVoices([]);
return;
@@ -317,7 +580,7 @@ export const VoiceSettings: React.FC = () => {
.catch(() => {
setIsSayAvailable(false);
});
- }, [voiceModeEnabled]);
+ }, [showMessageTTSButtons]);
const previewVoice = useCallback(async () => {
if (previewAudio) {
@@ -498,11 +761,11 @@ export const VoiceSettings: React.FC = () => {
return (
- {/* Voice Setup */}
-
+ {/* Playback (read messages aloud) */}
+
- {t('settings.voice.page.section.voiceSetup')}
+ {t('settings.voice.page.section.playbackAndSummary')}
@@ -512,15 +775,15 @@ export const VoiceSettings: React.FC = () => {
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
- aria-pressed={voiceModeEnabled}
- onClick={() => setVoiceModeEnabled(!voiceModeEnabled)}
- onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setVoiceModeEnabled(!voiceModeEnabled); } }}
+ aria-pressed={showMessageTTSButtons}
+ onClick={() => setShowMessageTTSButtons(!showMessageTTSButtons)}
+ onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setShowMessageTTSButtons(!showMessageTTSButtons); } }}
>
-
-
{t('settings.voice.page.field.enableVoiceMode')}
+
+
{t('settings.voice.page.field.messageReadAloudButton')}
- {voiceModeEnabled && (
+ {showMessageTTSButtons && (
<>
@@ -533,6 +796,7 @@ export const VoiceSettings: React.FC = () => {
{t('settings.voice.page.provider.browser')} {t('settings.voice.page.tooltip.browser')}
+ {t('settings.voice.page.provider.local')} {t('settings.voice.page.tooltip.localTts')}
OpenAI: {t('settings.voice.page.tooltip.openai')}
{t('settings.voice.page.provider.custom')} {t('settings.voice.page.tooltip.custom')}
{t('settings.voice.page.provider.say')} {t('settings.voice.page.tooltip.say')}
@@ -550,6 +814,15 @@ export const VoiceSettings: React.FC = () => {
>
{t('settings.voice.page.provider.browser')}
+ setVoiceProvider('local')}
+ className="!font-normal"
+ >
+ {t('settings.voice.page.provider.local')}
+
{
)}
+ {/* Local (Kokoro) TTS model status */}
+ {voiceProvider === 'local' &&
}
+
{/* Voice Selection */}
{t('settings.voice.page.field.voice')}
+ {voiceProvider === 'local' && (
+ <>
+
setLocalTtsVoiceId(Number.parseInt(value, 10) || 0)}
+ >
+
+
+ {(value) => KOKORO_VOICE_OPTIONS.find((v) => String(v.id) === value)?.label ?? value}
+
+
+
+ {KOKORO_VOICE_OPTIONS.map((v) => (
+ {v.label}
+ ))}
+
+
+
+ {isLocalTtsPlaying ? : }
+
+ {localTtsError ? (
+
{localTtsError}
+ ) : null}
+ >
+ )}
+
{voiceProvider === 'openai' && isOpenAIAvailable && (
<>
@@ -775,7 +1077,7 @@ export const VoiceSettings: React.FC = () => {
@@ -784,7 +1086,7 @@ export const VoiceSettings: React.FC = () => {
@@ -793,7 +1095,7 @@ export const VoiceSettings: React.FC = () => {
- {/* Language */}
-
-
{t('settings.voice.page.field.language')}
-
-
-
-
-
-
- {LANGUAGE_OPTIONS.map((lang) => (
- {lang.label}
- ))}
-
-
+ {/* TTS input mode */}
+
+
+
+
+ {t('settings.voice.page.field.ttsInputMode')}
+
+
+
+ setTtsInputMode('sanitized')}
+ className="!font-normal"
+ >
+ {t('settings.voice.page.field.ttsInputModeSanitized')}
+
+ setTtsInputMode('raw')}
+ className="!font-normal"
+ >
+ {t('settings.voice.page.field.ttsInputModeRaw')}
+
+ setTtsInputMode('summarized')}
+ className="!font-normal"
+ >
+ {t('settings.voice.page.field.ttsInputModeSummarized')}
+
+
>
@@ -826,8 +1151,7 @@ export const VoiceSettings: React.FC = () => {
{/* Speech Recognition */}
- {voiceModeEnabled && (
-
+
{t('settings.voice.page.section.speechRecognition')}
@@ -835,6 +1159,19 @@ export const VoiceSettings: React.FC = () => {
+ setDictationEnabled(!dictationEnabled)}
+ onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setDictationEnabled(!dictationEnabled); } }}
+ >
+
+ {t('settings.voice.page.field.enableVoiceInput')}
+
+
+ {dictationEnabled && (<>
@@ -845,7 +1182,7 @@ export const VoiceSettings: React.FC = () => {
- {t('settings.voice.page.provider.browser')} {t('settings.voice.page.tooltip.sttBrowser')}
+ {t('settings.voice.page.provider.local')} {t('settings.voice.page.tooltip.sttLocal')}
{t('settings.voice.page.provider.server')} {t('settings.voice.page.tooltip.sttServer')}
@@ -855,53 +1192,34 @@ export const VoiceSettings: React.FC = () => {
setSttProvider('browser')}
+ aria-pressed={sttProvider === 'local'}
+ onClick={() => setSttProvider('local')}
className="!font-normal"
>
- {t('settings.voice.page.provider.browser')}
+ {t('settings.voice.page.provider.local')}
setSttProvider('server')}
+ aria-pressed={sttProvider === 'openai-compatible'}
+ onClick={() => setSttProvider('openai-compatible')}
className="!font-normal"
>
{t('settings.voice.page.provider.server')}
-
setSttProvider('wasm')}
- className="!font-normal"
- >
- {t('settings.voice.page.provider.wasm')}
-
- {sttProvider === 'server' && (
-
-
setSttTranscribeOnStop(!sttTranscribeOnStop)}
- onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSttTranscribeOnStop(!sttTranscribeOnStop); } }}
- >
-
- {t('settings.voice.page.field.transcribeOnStop')}
-
+ {sttProvider === 'local' && (
+
+ {t('settings.voice.page.field.model')}
+
+
+ )}
- {!audioStreamService.isSupported() && (
-
- {t('settings.voice.page.field.sttBrowserSupportError')}
-
- )}
+ {sttProvider === 'openai-compatible' && (
+
{t('settings.voice.page.field.serverUrl')}
@@ -979,129 +1297,14 @@ export const VoiceSettings: React.FC = () => {
/>
-
-
{t('settings.voice.page.field.silenceThreshold')}
-
- {!isMobile && setSttSilenceThresholdDb(Number(e.target.value))} className={sliderClass} />}
-
- {sttSilenceThresholdDb} dB
-
-
-
-
-
{t('settings.voice.page.field.silenceHold')}
-
- {!isMobile && setSttSilenceHoldMs(Number(e.target.value))} className={sliderClass} />}
-
- {t('settings.voice.page.field.millisecondsUnit')}
-
-
)}
- {sttProvider === 'wasm' && (
-
-
-
- {t('settings.voice.page.stt.wasmModel')}
-
-
-
-
-
-
- {WASM_MODELS.map((m) => (
-
-
- {m.name}
-
- {m.size} · {m.languages}
-
-
-
- ))}
-
-
-
- {WASM_MODELS.find((m) => m.id === wasmSttModel)?.description}
-
-
- {/* Model status indicator */}
-
-
- )}
+ >)}
-
- )}
-
- {/* Playback & Summarization */}
-
-
-
- {t('settings.voice.page.section.playbackAndSummary')}
-
-
-
-
- setShowMessageTTSButtons(!showMessageTTSButtons)}
- onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setShowMessageTTSButtons(!showMessageTTSButtons); } }}
- >
-
- {t('settings.voice.page.field.messageReadAloudButton')}
-
-
-
-
-
-
- {t('settings.voice.page.field.ttsInputMode')}
-
-
-
- setTtsInputMode('sanitized')}
- className="!font-normal"
- >
- {t('settings.voice.page.field.ttsInputModeSanitized')}
-
- setTtsInputMode('raw')}
- className="!font-normal"
- >
- {t('settings.voice.page.field.ttsInputModeRaw')}
-
-
-
-
-
-
-
- {voiceModeEnabled && isSupported && (
-
-
- {t('settings.voice.page.hint.shiftClickPrefix')}
- {' '}
- Shift
- {' + '}
- Click
- {' '}
- {t('settings.voice.page.hint.shiftClickSuffix')}
-
-
- )}
+
);
};
diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx
index 2edcf0f8b3..1fee25b1fa 100644
--- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx
+++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx
@@ -4,10 +4,12 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
+import type { Session } from '@opencode-ai/sdk/v2';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
+import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useDeviceInfo } from '@/lib/device';
import { checkIsGitRepository } from '@/lib/gitApi';
import {
@@ -228,10 +230,17 @@ export const WorktreeSectionContent: React.FC
= ({
// Build a set of session IDs that are directly linked
const directSessionIds = new Set(directSessions.map((s) => s.id));
- // Find all subsessions recursively
- const findSubsessions = (parentIds: Set): typeof sessions => {
- const subsessions = sessions.filter((session) => {
- const parentID = (session as { parentID?: string | null }).parentID;
+ // Search subsessions across all directories, not just the current sync
+ // context, so subagent sessions created in other worktrees/project roots
+ // are still included in the delete list.
+ const allKnownSessions = [
+ ...useGlobalSessionsStore.getState().activeSessions,
+ ...useGlobalSessionsStore.getState().archivedSessions,
+ ];
+
+ const findSubsessions = (parentIds: Set): Session[] => {
+ const subsessions = allKnownSessions.filter((session) => {
+ const parentID = (session as Session & { parentID?: string | null }).parentID;
return parentID && parentIds.has(parentID);
});
if (subsessions.length === 0) {
diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx
index 638bfac30a..5e38a383e5 100644
--- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx
+++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx
@@ -46,7 +46,8 @@ import {
resolveDesktopHostUrl,
type DesktopHost,
} from '@/lib/desktopHosts';
-import { isDesktopShell } from '@/lib/desktop';
+import { getDesktopLanAddress, isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop';
+import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from '@/lib/runtime-switch';
const randomPort = (): number => {
@@ -199,6 +200,85 @@ const formatLogLine = (line: string): string => {
return `[${iso}] [${level}] ${message}`;
};
+type HeaderDraft = {
+ id: string;
+ name: string;
+ value: string;
+};
+
+const createHeaderDraft = (name = '', value = ''): HeaderDraft => ({
+ id: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
+ ? crypto.randomUUID()
+ : `header-${Date.now()}-${Math.random().toString(16).slice(2)}`,
+ name,
+ value,
+});
+
+const isReservedRequestHeaderName = (name: string): boolean => name.trim().toLowerCase() === 'authorization';
+
+const buildRequestHeaders = (headers: HeaderDraft[]): Record | undefined => {
+ const next: Record = {};
+ for (const header of headers) {
+ const name = header.name.trim();
+ const value = header.value.trim();
+ if (name && value && !isReservedRequestHeaderName(name)) next[name] = value;
+ }
+ return Object.keys(next).length > 0 ? next : undefined;
+};
+
+const readRequestHeaderDrafts = (headers: Record | undefined): HeaderDraft[] => {
+ return Object.entries(headers || {}).map(([name, value]) => createHeaderDraft(name, value));
+};
+
+const getRuntimePort = (): number | null => {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
+ const portSource = runtimeApiBaseUrl || window.location.href;
+ try {
+ const port = Number(new URL(portSource).port || window.location.port);
+ return Number.isFinite(port) && port > 0 ? port : null;
+ } catch {
+ const port = Number(window.location.port);
+ return Number.isFinite(port) && port > 0 ? port : null;
+ }
+};
+
+const resolvePairingServerUrl = async (): Promise => {
+ const fallback = normalizeHostUrl(getRuntimeApiBaseUrl()) || window.location.origin;
+ if (!isDesktopShell() || !isDesktopLocalOriginActive()) {
+ return fallback;
+ }
+
+ let response: Response;
+ try {
+ response = await runtimeFetch('/api/config/settings', {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ });
+ } catch {
+ return fallback;
+ }
+ if (!response.ok) return fallback;
+
+ const settings = (await response.json().catch(() => null)) as null | {
+ desktopLanAccessActive?: unknown;
+ };
+ if (settings?.desktopLanAccessActive !== true) {
+ return fallback;
+ }
+
+ const address = await getDesktopLanAddress();
+ const port = getRuntimePort();
+ if (!address || !port) {
+ return fallback;
+ }
+
+ return `http://${address}:${port}`;
+};
+
const navigateToUrl = (rawUrl: string): void => {
const target = rawUrl.trim();
if (!target) {
@@ -301,6 +381,7 @@ export const RemoteInstancesPage: React.FC = () => {
const [directLabel, setDirectLabel] = React.useState('');
const [directUrl, setDirectUrl] = React.useState('');
const [directToken, setDirectToken] = React.useState('');
+ const [directHeaders, setDirectHeaders] = React.useState([]);
const [directConnectLink, setDirectConnectLink] = React.useState('');
const [directError, setDirectError] = React.useState(null);
const [directAddDialogOpen, setDirectAddDialogOpen] = React.useState(false);
@@ -309,6 +390,7 @@ export const RemoteInstancesPage: React.FC = () => {
const [directEditLabel, setDirectEditLabel] = React.useState('');
const [directEditUrl, setDirectEditUrl] = React.useState('');
const [directEditToken, setDirectEditToken] = React.useState('');
+ const [directEditHeaders, setDirectEditHeaders] = React.useState([]);
const [remoteClients, setRemoteClients] = React.useState([]);
const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false);
const [remoteClientLabel, setRemoteClientLabel] = React.useState('');
@@ -374,16 +456,18 @@ export const RemoteInstancesPage: React.FC = () => {
url,
apiUrl: url,
...(directToken.trim() ? { clientToken: directToken.trim() } : {}),
+ ...(buildRequestHeaders(directHeaders) ? { requestHeaders: buildRequestHeaders(directHeaders) } : {}),
};
await persistDirectHosts([host, ...directHosts], directDefaultHostId);
setDirectLabel('');
setDirectUrl('');
setDirectToken('');
+ setDirectHeaders([]);
setDirectAddDialogOpen(false);
if (resolved.redeemUrl) {
navigateToUrl(resolved.redeemUrl);
}
- }, [directDefaultHostId, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]);
+ }, [directDefaultHostId, directHeaders, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]);
const importDirectConnectLink = React.useCallback(async () => {
const payload = parseClientConnectionPayload(directConnectLink);
@@ -427,6 +511,7 @@ export const RemoteInstancesPage: React.FC = () => {
setDirectEditLabel(host.label);
setDirectEditUrl(host.apiUrl || host.url);
setDirectEditToken(host.clientToken || '');
+ setDirectEditHeaders(readRequestHeaderDrafts(host.requestHeaders));
setDirectError(null);
}, []);
@@ -445,6 +530,7 @@ export const RemoteInstancesPage: React.FC = () => {
url,
apiUrl: url,
clientToken: directEditToken.trim() || undefined,
+ requestHeaders: buildRequestHeaders(directEditHeaders),
}
: host);
await persistDirectHosts(nextHosts, directDefaultHostId);
@@ -452,7 +538,7 @@ export const RemoteInstancesPage: React.FC = () => {
if (resolved.redeemUrl) {
navigateToUrl(resolved.redeemUrl);
}
- }, [directDefaultHostId, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]);
+ }, [directDefaultHostId, directEditHeaders, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]);
const createSshInstanceFromDialog = React.useCallback(async () => {
const command = sshCommandDraft.trim();
@@ -513,7 +599,7 @@ export const RemoteInstancesPage: React.FC = () => {
if (!clientAuth) return;
setRemoteClientError(null);
try {
- const serverUrl = normalizeHostUrl(getRuntimeApiBaseUrl()) || window.location.origin;
+ const serverUrl = await resolvePairingServerUrl();
const result = await clientAuth.createClient({ label: remoteClientLabel.trim() || 'Paired client' });
const payload = buildClientConnectionPayload({ serverUrl, token: result.token, label: remoteClientLabel || 'OpenChamber' });
const encoded = encodeClientConnectionPayload(payload);
@@ -1095,6 +1181,25 @@ export const RemoteInstancesPage: React.FC = () => {
setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
+
+
+
{t('settings.remoteInstances.direct.headers.title')}
+
{t('settings.remoteInstances.direct.headers.description')}
+
+ {directHeaders.map((header) => (
+
+ setDirectHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, name: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.namePlaceholder')} disabled={directSaving} />
+ setDirectHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, value: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.valuePlaceholder')} type="password" disabled={directSaving} />
+ setDirectHeaders((headers) => headers.filter((item) => item.id !== header.id))} className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-[var(--status-error-background)] hover:text-[var(--status-error)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]" aria-label={t('settings.remoteInstances.direct.headers.removeAria')} disabled={directSaving}>
+
+
+
+ ))}
+
setDirectHeaders((headers) => [...headers, createHeaderDraft()])} disabled={directSaving}>
+
+ {t('settings.remoteInstances.direct.headers.actions.add')}
+
+
setDirectAddDialogOpen(false)} disabled={directSaving}>{t('settings.common.actions.cancel')}
{t('settings.remoteInstances.direct.actions.add')}
@@ -1113,6 +1218,25 @@ export const RemoteInstancesPage: React.FC = () => {
setDirectEditLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} />
setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus />
setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} />
+
+
+
{t('settings.remoteInstances.direct.headers.title')}
+
{t('settings.remoteInstances.direct.headers.description')}
+
+ {directEditHeaders.map((header) => (
+
+ setDirectEditHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, name: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.namePlaceholder')} disabled={directSaving} />
+ setDirectEditHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, value: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.valuePlaceholder')} type="password" disabled={directSaving} />
+ setDirectEditHeaders((headers) => headers.filter((item) => item.id !== header.id))} className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-[var(--status-error-background)] hover:text-[var(--status-error)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]" aria-label={t('settings.remoteInstances.direct.headers.removeAria')} disabled={directSaving}>
+
+
+
+ ))}
+
setDirectEditHeaders((headers) => [...headers, createHeaderDraft()])} disabled={directSaving}>
+
+ {t('settings.remoteInstances.direct.headers.actions.add')}
+
+
setDirectEditingId(null)} disabled={directSaving}>{t('settings.common.actions.cancel')}
{t('settings.common.actions.saveChanges')}
diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx
index eefb1666bf..abe99af5dc 100644
--- a/packages/ui/src/components/session/SessionSidebar.tsx
+++ b/packages/ui/src/components/session/SessionSidebar.tsx
@@ -13,7 +13,7 @@ import { useSync } from '@/sync/use-sync';
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
-import { getSafeStorage } from '@/stores/utils/safeStorage';
+import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { NewWorktreeDialog } from './NewWorktreeDialog';
@@ -35,6 +35,7 @@ import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders
import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
+import { ShareOpinionDialog } from '@/components/feedback/ShareOpinionDialog';
import { SessionGroupSection } from './sidebar/SessionGroupSection';
import { SidebarHeader } from './sidebar/SidebarHeader';
import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
@@ -80,6 +81,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
+const SHARE_OPINION_TOAST_STORAGE_KEY = 'openchamber.shareOpinionToast.dismissed.v2';
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
@@ -188,7 +190,7 @@ export const SessionSidebar: React.FC = ({
const [editTitle, setEditTitle] = React.useState('');
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState(null);
const [expandedParents, setExpandedParents] = React.useState>(new Set());
- const safeStorage = React.useMemo(() => getSafeStorage(), []);
+ const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []);
const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set());
const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map());
@@ -196,6 +198,7 @@ export const SessionSidebar: React.FC = ({
const newWorktreeDialogOpen = useUIStore((state) => state.isNewWorktreeDialogOpen);
const setNewWorktreeDialogOpen = useUIStore((state) => state.setNewWorktreeDialogOpen);
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
+ const [shareOpinionDialogOpen, setShareOpinionDialogOpen] = React.useState(false);
const [openSidebarMenuKey, setOpenSidebarMenuKey] = React.useState(null);
const [renamingFolderId, setRenamingFolderId] = React.useState(null);
const [renameFolderDraft, setRenameFolderDraft] = React.useState('');
@@ -207,7 +210,7 @@ export const SessionSidebar: React.FC = ({
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
const [collapsedGroups, setCollapsedGroups] = React.useState>(() => {
try {
- const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY);
+ const raw = getDeferredSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY);
if (!raw) {
return new Set();
}
@@ -219,7 +222,7 @@ export const SessionSidebar: React.FC = ({
});
const [groupOrderByProject, setGroupOrderByProject] = React.useState>(() => {
try {
- const raw = getSafeStorage().getItem(GROUP_ORDER_STORAGE_KEY);
+ const raw = getDeferredSafeStorage().getItem(GROUP_ORDER_STORAGE_KEY);
if (!raw) {
return new Map();
}
@@ -237,7 +240,7 @@ export const SessionSidebar: React.FC = ({
});
const [activeSessionByProject, setActiveSessionByProject] = React.useState>(() => {
try {
- const raw = getSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY);
+ const raw = getDeferredSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY);
if (!raw) {
return new Map();
}
@@ -635,6 +638,36 @@ export const SessionSidebar: React.FC = ({
});
}, [t, updateStore]);
+ const handleOpenShareOpinionDialog = React.useCallback(() => {
+ setShareOpinionDialogOpen(true);
+ }, []);
+
+ React.useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ if (window.localStorage.getItem(SHARE_OPINION_TOAST_STORAGE_KEY) === 'true') {
+ return;
+ }
+ window.localStorage.setItem(SHARE_OPINION_TOAST_STORAGE_KEY, 'true');
+ } catch {
+ // If storage is unavailable, still show once for this sidebar mount.
+ }
+ const timeoutId = window.setTimeout(() => {
+ toast.info(t('shareOpinion.toast.title'), {
+ description: t('shareOpinion.toast.description'),
+ action: {
+ label: t('shareOpinion.actions.shareOpinion'),
+ onClick: () => setShareOpinionDialogOpen(true),
+ },
+ duration: 12_000,
+ });
+ }, 1_000);
+
+ return () => window.clearTimeout(timeoutId);
+ }, [t]);
+
const handleOpenSettings = React.useCallback(() => {
if (mobileVariant) {
setSessionSwitcherOpen(false);
@@ -877,6 +910,9 @@ export const SessionSidebar: React.FC = ({
color?: string;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
iconBackground?: string;
+ addedAt?: number;
+ lastOpenedAt?: number;
+ sidebarCollapsed?: boolean;
}>;
}, [projects]);
@@ -981,7 +1017,7 @@ export const SessionSidebar: React.FC = ({
sectionsForRender,
searchMatchCount,
} = useSessionSidebarSections({
- normalizedProjects,
+ normalizedProjects: sortedProjects,
getSessionsForProject,
getArchivedSessionsForProject,
availableWorktreesByProject,
@@ -1089,6 +1125,74 @@ export const SessionSidebar: React.FC = ({
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions);
+ const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
+ const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder);
+
+ const recentProjectIds = React.useMemo(() => {
+ const recentSessions = deriveRecentSessions(sessions);
+ if (recentSessions.length === 0) return new Set();
+
+ // Build path→id map once for O(1) lookups
+ const pathToId = new Map();
+ for (const project of normalizedProjects) {
+ if (project.normalizedPath) {
+ pathToId.set(project.normalizedPath, project.id);
+ }
+ }
+
+ const ids = new Set();
+ for (const session of recentSessions) {
+ const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
+ if (directory) {
+ const projectId = pathToId.get(directory);
+ if (projectId) ids.add(projectId);
+ }
+ }
+ return ids;
+ }, [sessions, normalizedProjects]);
+
+ const sortedProjects = React.useMemo(() => {
+ const list = [...normalizedProjects];
+
+ // 1. Sort by the selected order
+ switch (projectSortOrder) {
+ case 'a-z':
+ list.sort((a, b) => {
+ const aLabel = (a.label || a.path).toLowerCase();
+ const bLabel = (b.label || b.path).toLowerCase();
+ return aLabel.localeCompare(bLabel);
+ });
+ break;
+ case 'z-a':
+ list.sort((a, b) => {
+ const aLabel = (a.label || a.path).toLowerCase();
+ const bLabel = (b.label || b.path).toLowerCase();
+ return bLabel.localeCompare(aLabel);
+ });
+ break;
+ case 'date-added':
+ list.sort((a, b) => (b.addedAt ?? 0) - (a.addedAt ?? 0));
+ break;
+ case 'recent':
+ list.sort((a, b) => (b.lastOpenedAt ?? 0) - (a.lastOpenedAt ?? 0));
+ break;
+ case 'manual': {
+ // Restore from snapshot, keep unknown projects at the end
+ const orderMap = new Map(manualProjectOrder.map((id, i) => [id, i]));
+ list.sort((a, b) => {
+ const ai = orderMap.get(a.id) ?? Infinity;
+ const bi = orderMap.get(b.id) ?? Infinity;
+ return ai - bi;
+ });
+ break;
+ }
+ }
+
+ // 2. Pin projects with recent sessions to the top
+ const recent = list.filter((p) => recentProjectIds.has(p.id));
+ const rest = list.filter((p) => !recentProjectIds.has(p.id));
+ return [...recent, ...rest];
+ }, [normalizedProjects, projectSortOrder, manualProjectOrder, recentProjectIds]);
const activeNowSessions = React.useMemo(() => {
if (!showRecentSection || isVSCode) {
@@ -1592,6 +1696,7 @@ export const SessionSidebar: React.FC = ({
removeProject={removeProject}
projectHeaderSentinelRefs={projectHeaderSentinelRefs}
reorderProjects={reorderProjects}
+ projectSortOrder={projectSortOrder}
getOrderedGroups={getOrderedGroups}
setGroupOrderByProject={setGroupOrderByProject}
openSidebarMenuKey={openSidebarMenuKey}
@@ -1619,10 +1724,16 @@ export const SessionSidebar: React.FC = ({
onOpenShortcuts={toggleHelpDialog}
onOpenAbout={() => setAboutDialogOpen(true)}
onOpenUpdate={handleOpenUpdateDialog}
+ onOpenShareOpinion={handleOpenShareOpinionDialog}
showRuntimeButtons={!isVSCode}
showUpdateButton={showSidebarUpdateButton}
/>
+
+
(null);
- const archivedScrollRef = React.useRef(null);
const [archivedScrollEl, setArchivedScrollEl] = React.useState(null);
// Offset of the virtual container from the scroll element's content origin.
// virtua reads startMargin from Virtualizer options and uses it
@@ -617,7 +616,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
React.useLayoutEffect(() => {
if (!shouldVirtualize) {
if (archivedScrollEl !== null) setArchivedScrollEl(null);
- archivedScrollRef.current = null;
if (archivedScrollMargin !== 0) setArchivedScrollMargin(0);
return;
}
@@ -632,7 +630,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
if (providedScrollEl && providedScrollEl.contains(container)) {
scrollEl = providedScrollEl;
if (scrollEl !== archivedScrollEl) {
- archivedScrollRef.current = scrollEl;
setArchivedScrollEl(scrollEl);
return;
}
@@ -649,7 +646,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
el = el.parentElement;
}
if (scrollEl !== archivedScrollEl) {
- archivedScrollRef.current = scrollEl;
setArchivedScrollEl(scrollEl);
return;
}
@@ -661,6 +657,31 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset));
});
+ // The scroll element is an ANCESTOR of this section (the sidebar's
+ // ScrollableOverlay), so scrollMargin translates its scrollTop into
+ // container-relative coordinates — the tanstack equivalent of virtua's
+ // startMargin this replaces.
+ // Enable ONLY once the ancestor scroll element is resolved. While the
+ // virtualizer is disabled the core resets its cached scroll offset, so the
+ // first enabled read takes initialOffset() from the LIVE scrollTop below —
+ // making the core's attach-time scrollTo target the current position (a
+ // visual no-op) instead of a stale 0 that reset the sidebar to the top.
+ // The core only learns the offset from scroll events after that, so this
+ // initial seeding is what makes the first render window correct too.
+ const virtualizerReady = shouldVirtualize && archivedScrollEl !== null;
+ const sessionVirtualizer = useVirtualizer({
+ count: visibleSessions.length,
+ enabled: virtualizerReady,
+ getScrollElement: () => archivedScrollEl,
+ initialOffset: () => archivedScrollEl?.scrollTop ?? 0,
+ estimateSize: () => ARCHIVED_ROW_ESTIMATE_PX,
+ // Expanded parents render children inline and dwarf the row estimate;
+ // widen the window so their extra height stays covered.
+ overscan: hasExpandedParent ? 20 : 8,
+ scrollMargin: archivedScrollMargin,
+ getItemKey: (index) => visibleSessions[index]?.session.id ?? index,
+ });
+
// Hooks below MUST stay above the search-empty early-return so they
// fire in the same order every render — rules-of-hooks.
const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => {
@@ -675,12 +696,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
return collected;
}, []);
- // The "delete all in group" handler closes over the full list of
- // sessions in this group. Memoize so the recursive flatten only runs
- // when the underlying source group nodes change, not on every render.
+ // Flat list of all sessions in this group (including nested children).
+ // Used by both the "delete all archived" button and the "delete worktree"
+ // button. Memoize so the recursive flatten only runs when the underlying
+ // source group nodes change, not on every render.
const allGroupSessions = React.useMemo(
- () => (group.isArchivedBucket ? collectGroupSessions(sourceGroupNodes) : []),
- [collectGroupSessions, sourceGroupNodes, group.isArchivedBucket],
+ () => collectGroupSessions(sourceGroupNodes),
+ [collectGroupSessions, sourceGroupNodes],
);
// Precompute the per-folder "delete all sessions in folder" list once
@@ -912,21 +934,63 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
{renderFolderItems()}
{shouldVirtualize ? (