feat(ui): add in-document search to the Markdown file preview

Ctrl/Cmd+F (and a toolbar button) opens a compact find bar over the
rendered Markdown preview, with match highlighting, a live count, and
next/previous navigation that scrolls the current match into view.
Escape closes the bar and returns focus where it was.

Merge follow-ups on top of the contribution: mount the bar in the
fullscreen viewer as well as the inline preview, combine it with the
FilePreviewCommentMenu wrapper that landed on main, debounce the
highlight pass so typing does not re-walk the whole document on every
keystroke, use the status-warning theme utilities instead of raw CSS
variables for the highlights, and add the Turkish strings for the
locale added after the branch was cut.

Closes #2401
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 23:48:35 +03:00
1276 changed files with 111873 additions and 29728 deletions
+59 -36
View File
@@ -1,23 +1,27 @@
import React from 'react';
import { MainLayout } from '@/components/layout/MainLayout';
import { ChatView } from '@/components/views/ChatView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { FireworksProvider } from '@/contexts/FireworksContext';
import { Toaster } from '@/components/ui/sonner';
import { Button } from '@/components/ui/button';
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
import { setStreamPerfEnabled } from '@/stores/utils/streamDebug';
import { setRequestsInFlightTrackingEnabled } from '@/stores/utils/requestsInFlight';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
// useEventStream removed — replaced by SyncProvider + SyncBridge
import { useMenuActions } from '@/hooks/useMenuActions';
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
import { useTraySync } from '@/hooks/useTraySync';
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
import { useRouter } from '@/hooks/useRouter';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
import { useAgentMemorySync } from '@/hooks/useAgentMemorySync';
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
import { useConfigStore } from '@/stores/useConfigStore';
import { hasModifier } from '@/lib/utils';
import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop';
import {
getInjectedBootOutcome,
@@ -32,7 +36,6 @@ import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionR
import { useSessionUIStore } from '@/sync/session-ui-store';
import { markSessionViewed } from '@/sync/notification-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
@@ -54,7 +57,11 @@ import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { useI18n } from '@/lib/i18n';
import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
import {
EMBEDDED_VISIBILITY_UPDATE,
isEmbeddedSessionChat,
requestEmbeddedSessionVisibility,
} from '@/components/layout/contextPanelEmbeddedChat';
import { SyncAppEffects } from '@/apps/AppEffects';
import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset';
import { useAppFontEffects } from '@/apps/useAppFontEffects';
@@ -106,6 +113,7 @@ type EmbeddedSessionChatConfig = {
sessionId: string;
directory: string | null;
readOnly: boolean;
allowPromptingSubagentSessions?: boolean;
};
type EmbeddedVisibilityPayload = {
@@ -138,6 +146,9 @@ const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
sessionId,
directory,
readOnly: params.get('readOnly') === '1' || params.get('readOnly') === 'true',
allowPromptingSubagentSessions: params.has('allowPromptingSubagentSessions')
? params.get('allowPromptingSubagentSessions') === '1'
: undefined,
};
};
@@ -199,7 +210,16 @@ const EmbeddedSessionChatContent: React.FC<{
<>
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<OpenCodeUpdateToast />
<ChatView readOnly={embeddedSessionChat.readOnly} />
<ChatView
active={embeddedBackgroundWorkEnabled}
// Always subscribe to message history in the mounted session-chat
// iframe. Visibility still gates composer focus and background work so
// a boot-inactive / lost-handshake race cannot leave a busy subagent
// showing only its status row (#2903 / #2892).
messagesEnabled={true}
readOnly={embeddedSessionChat.readOnly}
initialAllowPromptingSubagentSessions={embeddedSessionChat.allowPromptingSubagentSessions}
/>
<Toaster />
</>
);
@@ -228,7 +248,10 @@ function App({ apis }: AppProps) {
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
// Embedded chats start inactive until the parent panel identifies the active
// tab. Otherwise a newly loaded background tab can focus its composer first
// and steal keyboard input from the main chat.
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(false);
const [initRetryExhausted, setInitRetryExhausted] = React.useState(false);
const [initRetryEpoch, setInitRetryEpoch] = React.useState(0);
const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0);
@@ -258,6 +281,13 @@ function App({ apis }: AppProps) {
};
}, [showMemoryDebug]);
React.useEffect(() => {
setRequestsInFlightTrackingEnabled(showMemoryDebug);
return () => {
setRequestsInFlightTrackingEnabled(false);
};
}, [showMemoryDebug]);
React.useEffect(() => {
applyMobileKeyboardMode(mobileKeyboardMode);
}, [mobileKeyboardMode]);
@@ -527,17 +557,16 @@ function App({ apis }: AppProps) {
}
const applyVisibility = (payload?: EmbeddedVisibilityPayload) => {
const nextVisible = payload?.visible === true;
setIsEmbeddedVisible(nextVisible);
setIsEmbeddedVisible(payload?.visible === true);
};
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) {
if (event.origin !== window.location.origin || event.source !== window.parent) {
return;
}
const data = event.data as { type?: unknown; payload?: EmbeddedVisibilityPayload };
if (data?.type !== 'openchamber:embedded-visibility') {
if (data?.type !== EMBEDDED_VISIBILITY_UPDATE) {
return;
}
@@ -550,6 +579,7 @@ function App({ apis }: AppProps) {
scopedWindow.__openchamberSetEmbeddedVisibility = applyVisibility;
window.addEventListener('message', handleMessage);
requestEmbeddedSessionVisibility();
return () => {
window.removeEventListener('message', handleMessage);
@@ -604,7 +634,6 @@ function App({ apis }: AppProps) {
const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0
? detail.directory.trim()
: null;
useUIStore.getState().setActiveMainTab('chat');
void useSessionUIStore.getState().setCurrentSession(sessionId, directory);
};
@@ -618,12 +647,9 @@ function App({ apis }: AppProps) {
React.useEffect(() => {
if (typeof window === 'undefined') return;
const onOpenMiniChat = () => {
const currentDir = useDirectoryStore.getState().currentDirectory;
const { activeProjectId, projects } = useProjectsStore.getState();
const activeProject = projects.find((p) => p.id === activeProjectId) ?? null;
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: currentDir || activeProject?.path || '',
projectId: activeProject?.id ?? null,
directory: '',
projectId: null,
});
};
window.addEventListener('openchamber:open-mini-chat', onOpenMiniChat);
@@ -655,11 +681,12 @@ function App({ apis }: AppProps) {
const projectId = typeof detail?.projectId === 'string' && detail.projectId.trim().length > 0
? detail.projectId.trim()
: null;
useUIStore.getState().setActiveMainTab('chat');
const hasProjectTarget = Boolean(directory || projectId);
useUIStore.getState().setSessionSwitcherOpen(false);
useSessionUIStore.getState().openNewSessionDraft({
selectedProjectId: projectId,
directoryOverride: directory,
target: hasProjectTarget ? 'project' : 'chat',
selectedProjectId: hasProjectTarget ? projectId : null,
directoryOverride: hasProjectTarget ? directory : null,
preserveDirectoryOverride: Boolean(directory),
});
};
@@ -683,10 +710,16 @@ function App({ apis }: AppProps) {
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled });
// Loaded here rather than by the Memory tab: the session index is built from
// this snapshot, so leaving it to the panel meant a user who never opened
// Project notes sent every message with no memory index at all.
useAgentMemorySync(currentDirectory || null);
usePwaInstallPrompt();
useWindowTitle();
useRootScrollLock();
useRouter();
const handleToggleMemoryDebug = React.useCallback(() => {
@@ -696,28 +729,16 @@ function App({ apis }: AppProps) {
useMenuActions(handleToggleMemoryDebug);
useTraySync();
useGlobalSessionsPolling(!embeddedSessionChat);
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
// Palette-only action: the memory debug panel has no keyboard shortcut.
React.useEffect(() => {
if (embeddedSessionChat) {
return;
}
const handleKeyDown = (e: KeyboardEvent) => {
const isDebugShortcut = hasModifier(e)
&& e.shiftKey
&& !e.altKey
&& (e.code === 'KeyD' || e.key.toLowerCase() === 'd');
if (isDebugShortcut) {
e.preventDefault();
setShowMemoryDebug(prev => !prev);
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
if (embeddedSessionChat) return;
const handleToggle = () => setShowMemoryDebug((previous) => !previous);
window.addEventListener('openchamber:memory-debug-toggle', handleToggle);
return () => window.removeEventListener('openchamber:memory-debug-toggle', handleToggle);
}, [embeddedSessionChat]);
React.useEffect(() => {
@@ -883,6 +904,7 @@ function App({ apis }: AppProps) {
isVSCodeRuntime={isVSCodeRuntime}
embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
/>
<AppLinkConfirmDialog />
</div>
</TooltipProvider>
</RuntimeAPIProvider>
@@ -926,6 +948,7 @@ function App({ apis }: AppProps) {
<OpenCodeUpdateToast />
<MainLayout />
<Toaster />
<AppLinkConfirmDialog />
{!isBootShell && (
<>
<ConfigUpdateOverlay />
+15 -7
View File
@@ -5,8 +5,10 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
import { opencodeClient } from '@/lib/opencode/client';
import type { RuntimeAPIs } from '@/lib/api/types';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -25,6 +27,7 @@ import {
worktreeMapsEqual,
} from '@/lib/worktrees/worktreeManager';
import type { WorktreeMetadata } from '@/types/worktree';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
@@ -153,9 +156,9 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : '';
if (!sessionId) return;
if (useSessionUIStore.getState().currentSessionId === sessionId) return;
const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0
? detail.directory.trim()
: (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory ?? null;
const sessionDirectory = (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory?.trim();
const directory = sessionDirectory
|| (typeof detail?.directory === 'string' && detail.directory.trim().length > 0 ? detail.directory.trim() : null);
void sync.ensureSessionRenderable(sessionId);
setCurrentSession(sessionId, directory);
sessionBootstrappedRef.current = true;
@@ -166,9 +169,11 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
React.useEffect(() => {
if (config.mode !== 'draft' || draftOpen || currentSessionId) return;
const hasProjectTarget = Boolean(config.projectId || config.directory);
openNewSessionDraft({
selectedProjectId: config.projectId,
directoryOverride: config.directory,
target: hasProjectTarget ? 'project' : 'chat',
selectedProjectId: hasProjectTarget ? config.projectId : CHAT_DRAFT_PROJECT_ID,
directoryOverride: hasProjectTarget ? config.directory : null,
preserveDirectoryOverride: Boolean(config.directory),
});
}, [config, currentSessionId, draftOpen, openNewSessionDraft]);
@@ -278,10 +283,11 @@ const MiniChatPresencePublisher: React.FC = () => {
const useSessionUnavailable = (config: MiniChatConfig): boolean => {
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const draftOpen = useSessionUIStore((state) => state.newSessionDraft.open);
const [timedOut, setTimedOut] = React.useState(false);
React.useEffect(() => {
if (config.mode !== 'session' || !config.sessionId || currentSessionId === config.sessionId) {
if (draftOpen || config.mode !== 'session' || !config.sessionId || currentSessionId) {
setTimedOut(false);
return;
}
@@ -291,7 +297,7 @@ const useSessionUnavailable = (config: MiniChatConfig): boolean => {
}
const timeout = window.setTimeout(() => setTimedOut(true), 5000);
return () => window.clearTimeout(timeout);
}, [config.mode, config.sessionId, currentSessionId, sessions]);
}, [config.mode, config.sessionId, currentSessionId, draftOpen, sessions]);
return timedOut;
};
@@ -313,6 +319,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
useMiniChatKeyboardShortcuts();
usePushVisibilityBeacon({ enabled: true });
useWindowTitle();
useRootScrollLock();
return (
<ErrorBoundary>
@@ -321,6 +328,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
<ElectronMiniChatContent config={config} />
<AppLinkConfirmDialog />
<Toaster />
</div>
</TooltipProvider>
+103 -36
View File
@@ -9,8 +9,10 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { ChatView } from '@/components/views/ChatView';
import { PlanView } from '@/components/views/PlanView';
import { SettingsView } from '@/components/views/SettingsView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
@@ -20,6 +22,7 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client';
import type { RuntimeAPIs } from '@/lib/api/types';
import type { ProjectRef } from '@/lib/projectContextApi';
import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device';
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
import { useI18n } from '@/lib/i18n';
@@ -54,7 +57,7 @@ import { MobileSessionsSheet } from './MobileSessionsSheet';
import { MobileFullscreenSurface } from './MobileFullscreenSurface';
import { MobileWorkspaceDrawer, type MobileWorkspaceTab } from './MobileWorkspaceDrawer';
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
import { autoConnectLastInstance, getAutoConnectTargetLabel, reprobeActiveConnection, type AutoConnectOutcome } from './mobileConnections';
import { autoConnectLastInstance, getAutoConnectTargetLabel, logMobileConnectEvent, reprobeActiveConnection, type AutoConnectOutcome } from './mobileConnections';
import { isCapacitorMobileApp, useNativeAndroidBackButton, useNativeMobileChrome, useNativeMobileLifecycle } from './mobileNativeChrome';
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
import { useAppFontEffects } from './useAppFontEffects';
@@ -83,6 +86,7 @@ const MOBILE_SETTINGS_PAGES = [
'providers',
'usage',
'voice',
'integrations',
'about',
] as const;
@@ -108,7 +112,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
const [workspaceTab, setWorkspaceTab] = React.useState<MobileWorkspaceTab>('changes');
// A plan opened from the workspace drawer's Notes tab, shown as a fullscreen
// layer on top of it (back returns to the notes).
const [openPlan, setOpenPlan] = React.useState<{ path: string; title: string } | null>(null);
const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string; projectRef: ProjectRef } | null>(null);
const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav');
// When set, the Changes surface opens directly into the per-file diff for this path.
const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null);
@@ -539,7 +543,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
>
<ErrorBoundary>
<PlanView
targetPath={openPlan.path}
savedProjectPlan={{ projectRef: openPlan.projectRef, planId: openPlan.id }}
onNavigatedToChat={() => {
closeSurface();
closeWorkspace();
@@ -660,9 +664,11 @@ export function MobileApp({ apis }: MobileAppProps) {
// saved instance instead of dead-ending on the connect screen until the
// user restarts the app. Success fires runtime-endpoint-changed, which
// re-bootstraps everything.
logMobileConnectEvent('resume:auto-connect', {});
void autoConnectLastInstance();
return;
}
logMobileConnectEvent('resume:reprobe', {});
// Re-probe the active device's transports on resume: the network may have
// changed while the app slept, so hot-switch LAN⇄relay if a better transport
@@ -675,7 +681,8 @@ export function MobileApp({ apis }: MobileAppProps) {
if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' });
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' });
};
const disconnect = () => {
const disconnect = (reason: string) => {
logMobileConnectEvent('resume:disconnect', { reason });
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
setConnectionEpoch((value) => value + 1);
};
@@ -683,36 +690,50 @@ export function MobileApp({ apis }: MobileAppProps) {
void reprobeActiveConnection().then((outcome) => {
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
if (outcome === 'no-connection') {
disconnect();
disconnect('no-connection');
return;
}
if (outcome === 'needs-login') {
// Token explicitly rejected (revoked/expired) — tell the user why they
// land back on the connect screen instead of silently bouncing them.
setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' });
disconnect();
disconnect('needs-login');
return;
}
if (outcome === 'unreachable') {
// Right after a resume or Wi-Fi switch the network is often still
// settling (on Android without a SIM there is NO connectivity at all for
// a few seconds), so a single fast probe races the network coming up.
// Retry once after a grace period before tearing the connection down.
window.setTimeout(() => {
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
void reprobeActiveConnection().then((retry) => {
// settling (Android without a SIM has NO connectivity for a few
// seconds; a WireGuard tunnel re-handshakes; a relay cold start pays
// TLS + WS + E2EE before it can answer), so a single fast probe races
// the network coming up. Retry on a widening grace ladder before
// tearing the connection down — the last attempt runs with the full
// connect budget so slow-but-alive transports get a real chance.
const retryDelaysMs = [4000, 10000];
const retryAt = (attempt: number) => {
window.setTimeout(() => {
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
if (retry === 'switched') return;
if (retry === 'unchanged') {
refreshInPlace();
return;
}
if (retry === 'needs-login') {
setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' });
}
disconnect();
});
}, 4000);
const lastAttempt = attempt === retryDelaysMs.length - 1;
void reprobeActiveConnection({ fast: !lastAttempt }).then((retry) => {
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
if (retry === 'switched') return;
if (retry === 'unchanged') {
refreshInPlace();
return;
}
if (retry === 'needs-login') {
setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' });
disconnect('retry-needs-login');
return;
}
if (!lastAttempt) {
retryAt(attempt + 1);
return;
}
disconnect(`retry-${retry}`);
});
}, retryDelaysMs[attempt]);
};
retryAt(0);
return;
}
if (outcome === 'switched') return;
@@ -753,6 +774,23 @@ export function MobileApp({ apis }: MobileAppProps) {
};
}, [isNativeMobileApp, handleNativeResume]);
// A confirmed mid-session auth expiry (classified centrally from live 401
// traffic) runs the same seq-guarded re-probe the resume path uses: it ends
// in needs-login → the native welcome screen with the auth-expired notice.
// The shared web banner never renders on native (the session gate is not
// mounted here), so this is the only surface reacting to the signal.
React.useEffect(() => {
if (!isNativeMobileApp) return;
return useAuthSessionStore.subscribe((store, previous) => {
if (store.state === 'expired' && previous.state !== 'expired') {
handleNativeResume();
// The probe ladder owns the outcome from here; the shared store goes
// back to 'ok' so a later expiry can signal again.
useAuthSessionStore.getState().markAuthenticated();
}
});
}, [isNativeMobileApp, handleNativeResume]);
React.useEffect(() => {
registerRuntimeAPIs(apis);
return () => registerRuntimeAPIs(null);
@@ -764,6 +802,15 @@ export function MobileApp({ apis }: MobileAppProps) {
// stale. The SyncProvider is keyed by runtimeEndpointEpoch so it remounts too.
React.useEffect(() => {
return subscribeRuntimeEndpointChanged((detail) => {
// Catch-all trail entry: EVERY endpoint change lands here regardless of
// which code path triggered it, so a "kicked to the connect screen"
// report always shows what dropped the runtime even when the trigger
// itself is not instrumented.
logMobileConnectEvent('endpoint:changed', {
runtimeKey: detail.runtimeKey || 'none',
previousRuntimeKey: detail.previousRuntimeKey || 'none',
connected: Boolean(detail.apiBaseUrl),
});
// A LAN⇄relay swap for the SAME device keeps the runtime key stable. Treat
// that as a transport-only change: rebind the sync layer to the new
// transport but keep the user's session/connection state — no reconnecting
@@ -800,19 +847,30 @@ export function MobileApp({ apis }: MobileAppProps) {
}
let cancelled = false;
setAutoConnectPhase('attempting');
void autoConnectLastInstance()
.catch((): AutoConnectOutcome => ({ status: 'no-candidate' }))
.then((outcome) => {
if (cancelled) return;
// Landing on the connect screen silently reads as data loss — say WHY
// the saved instance didn't come back (unreachable vs revoked auth).
if (outcome.status === 'unreachable') {
setAutoConnectNotice({ kind: 'unreachable', label: outcome.label });
} else if (outcome.status === 'needs-login') {
setAutoConnectNotice({ kind: 'auth-expired', label: outcome.label });
}
setAutoConnectPhase('done');
});
void (async () => {
const outcome = await autoConnectLastInstance()
.catch((): AutoConnectOutcome => ({ status: 'no-candidate' }));
if (cancelled) return;
// Landing on the connect screen silently reads as data loss — say WHY
// the saved instance didn't come back (unreachable vs revoked auth).
if (outcome.status === 'unreachable') {
setAutoConnectNotice({ kind: 'unreachable', label: outcome.label });
} else if (outcome.status === 'needs-login') {
setAutoConnectNotice({ kind: 'auth-expired', label: outcome.label });
}
// Release the splash on the fast verdict — a dead server must not pin
// the logo for the full connect budget. The fast probe races a
// just-woken network/relay (WireGuard re-handshake, relay TLS + WS +
// E2EE cold start), so a false "unreachable" is common right after
// launch: retry once IN THE BACKGROUND with the full budget. A success
// switches the runtime and the app moves in from the connect screen on
// its own; a manual connect the user started meanwhile wins via
// skipIfConnected.
setAutoConnectPhase('done');
if (outcome.status === 'unreachable') {
void autoConnectLastInstance({ fast: false, skipIfConnected: true }).catch(() => null);
}
})();
return () => {
cancelled = true;
};
@@ -833,6 +891,7 @@ export function MobileApp({ apis }: MobileAppProps) {
if (!isNativeMobileApp || !getRuntimeApiBaseUrl()) return;
let cancelled = false;
const dropToConnectScreen = (notice: MobileConnectionNotice | null) => {
logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' });
if (notice) setAutoConnectNotice(notice);
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
setConnectionEpoch((value) => value + 1);
@@ -847,7 +906,14 @@ export function MobileApp({ apis }: MobileAppProps) {
return;
}
if (outcome === 'unreachable') {
// A fast probe racing the just-woken network/relay produces false
// "unreachable" verdicts (seen in the field: the same LAN candidate
// refuses on launch and answers 200 two minutes later). Show the
// connect screen on the fast verdict — no splash hostage — and retry
// once in the background with the full budget; a success reconnects
// the app from the connect screen on its own.
dropToConnectScreen(label ? { kind: 'unreachable', label } : null);
void autoConnectLastInstance({ fast: false, skipIfConnected: true }).catch(() => null);
return;
}
// 'no-connection': at cold start the runtime key may not map to a saved
@@ -1212,6 +1278,7 @@ export function MobileApp({ apis }: MobileAppProps) {
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
setConnectionEpoch((value) => value + 1);
}} />
<AppLinkConfirmDialog />
<Toaster position="top-center" offset="calc(var(--oc-safe-area-top, 0px) + 16px)" />
{isInitialized ? <ConfigUpdateOverlay /> : null}
</div>
@@ -0,0 +1,55 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
import { formatMobileConnectDebugEntry, getMobileConnectDebugEntries, getMobileConnectDebugText } from './mobileConnectionDebug';
// Hidden diagnostics surface for device-only connection bugs: renders the
// in-memory connection event trail with one-tap copy, so a user on a release
// build (no tethered debugger, no Web Inspector) can paste the exact probe
// sequence into a bug report. Opened via long-press easter eggs on the connect
// screen logo and the instances list — invisible unless you know it's there.
export const MobileConnectionDebugPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const { t } = useI18n();
const [copied, setCopied] = React.useState(false);
// Snapshot on open; a live-updating log under the user's finger would fight
// the copy button. Reopen to refresh.
const entries = React.useMemo(() => getMobileConnectDebugEntries(), []);
const handleCopy = React.useCallback(() => {
void copyTextToClipboard(getMobileConnectDebugText()).then((result) => {
if (!result.ok) return;
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
});
}, []);
return (
<div className="fixed inset-0 z-[70] flex flex-col bg-background pb-[var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))] pt-[var(--safe-area-inset-top,env(safe-area-inset-top,0px))] text-foreground">
<div className="flex items-center justify-between gap-2 border-b border-border/70 px-4 py-2.5">
<h2 className="min-w-0 truncate typography-ui-label text-foreground">{t('mobile.connectionDebug.title')}</h2>
<div className="flex shrink-0 items-center gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={handleCopy} disabled={entries.length === 0}>
<Icon name={copied ? 'check' : 'file-copy'} className="size-4" />
{copied ? t('mobile.connectionDebug.copied') : t('mobile.connectionDebug.copy')}
</Button>
<Button type="button" variant="ghost" size="icon" aria-label={t('mobile.connectionDebug.close')} onClick={onClose}>
<Icon name="close" className="size-[18px]" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 py-3">
{entries.length === 0 ? (
<p className="typography-small text-muted-foreground">{t('mobile.connectionDebug.empty')}</p>
) : (
<pre className="whitespace-pre-wrap break-words typography-code text-muted-foreground">
{entries.map(formatMobileConnectDebugEntry).join('\n')}
</pre>
)}
</div>
</div>
);
};
@@ -7,6 +7,8 @@ import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { connectionDisplayUrl, useMobileConnection } from './mobileConnections';
import { useDebugPanelLongPress } from './mobileConnectionDebug';
import { MobileConnectionDebugPanel } from './MobileConnectionDebugPanel';
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay';
@@ -37,6 +39,10 @@ export const MobileConnectionWelcome: React.FC<{
// Which saved connection is being connected to, for the per-row spinner.
const [connectingId, setConnectingId] = React.useState<string | null>(null);
const [password, setPassword] = React.useState('');
// Hidden diagnostics: long-press the logo to open the connection event log —
// reachable even when a user has been bounced back to this screen.
const [debugOpen, setDebugOpen] = React.useState(false);
const debugLongPress = useDebugPanelLongPress(React.useCallback(() => setDebugOpen(true), []));
const handleSubmit = React.useCallback((event: React.FormEvent) => {
event.preventDefault();
@@ -127,10 +133,13 @@ export const MobileConnectionWelcome: React.FC<{
<>
{isScanning ? <MobileQrScannerOverlay onCancel={() => scanAbortRef.current?.abort()} /> : null}
{isCompletingScan ? <MobileQrConnectionLoading /> : null}
{debugOpen ? <MobileConnectionDebugPanel onClose={() => setDebugOpen(false)} /> : null}
<main className="oc-keyboard-fill-screen flex min-h-dvh flex-col overflow-y-auto bg-background px-6 pb-[calc(var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))+28px)] pt-[calc(var(--safe-area-inset-top,env(safe-area-inset-top,0px))+28px)] text-foreground">
<div className="m-auto flex w-full max-w-[360px] shrink-0 flex-col items-center gap-9 py-8">
<div className="flex flex-col items-center gap-5 text-center">
<OpenChamberLogo width={72} height={72} className="size-[72px]" />
<span {...debugLongPress} className="select-none" style={{ touchAction: 'manipulation' }}>
<OpenChamberLogo width={72} height={72} className="size-[72px]" />
</span>
<h1 className="typography-h2 text-foreground">{t('mobile.connect.welcome.title')}</h1>
</div>
@@ -7,6 +7,8 @@ import { isRelayModeActive } from '@/lib/relay/runtime-tunnel';
import { cn } from '@/lib/utils';
import { connectionDisplayUrl, isActiveRuntimeConnection, useMobileConnection } from './mobileConnections';
import { useDebugPanelLongPress } from './mobileConnectionDebug';
import { MobileConnectionDebugPanel } from './MobileConnectionDebugPanel';
import { isQrScanSupported, scanConnectionQr } from './mobileQrScan';
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay';
@@ -37,6 +39,10 @@ export const MobileInstancesSurface: React.FC<{
const [formOpen, setFormOpen] = React.useState(false);
// Which row is being connected to, for the per-row spinner.
const [connectingId, setConnectingId] = React.useState<string | null>(null);
// Hidden diagnostics: long-press a connection row to open the connection
// event log (the long-press swallows the row's normal connect tap).
const [debugOpen, setDebugOpen] = React.useState(false);
const debugLongPress = useDebugPanelLongPress(React.useCallback(() => setDebugOpen(true), []));
// Populate/clear the form imperatively (on edit tap / cancel / save) rather than via
// an effect keyed on the derived connection object. With an effect, any churn of the
@@ -189,11 +195,12 @@ export const MobileInstancesSurface: React.FC<{
<>
{isScanning ? <MobileQrScannerOverlay onCancel={() => scanAbortRef.current?.abort()} /> : null}
{isCompletingScan ? <MobileQrConnectionLoading /> : null}
{debugOpen ? <MobileConnectionDebugPanel onClose={() => setDebugOpen(false)} /> : null}
<div className="flex h-full flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto px-5 py-4">
<div className="space-y-6">
{connections.length > 0 ? (
<div className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
<div {...debugLongPress} className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
{connections.map((connection) => {
const confirming = confirmingDeleteId === connection.id;
const isActive = isActiveRuntimeConnection(connection);
@@ -287,7 +294,7 @@ export const MobileInstancesSurface: React.FC<{
})}
</div>
) : (
<p className="rounded-[18px] border border-dashed border-border/70 px-4 py-6 text-center typography-small text-muted-foreground">
<p {...debugLongPress} className="rounded-[18px] border border-dashed border-border/70 px-4 py-6 text-center typography-small text-muted-foreground">
{t('mobile.connect.saved.empty')}
</p>
)}
+17 -125
View File
@@ -2,16 +2,15 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import type { IconName } from '@/components/icon/icons';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { preloadProviderLogos } from '@/hooks/useProviderLogo';
import { useTabletLayout } from '@/lib/device';
import { useI18n } from '@/lib/i18n';
import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
import { getDisplayModelName } from '@/lib/quota/model-families';
import { clampPercent, resolveUsageTone } from '@/lib/quota';
import { UsageProviderCards } from '@/components/usage/UsageProviderCards';
import { useUsageProviderGroups, type UsageProviderGroup } from '@/components/usage/usageGroups';
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import type { QuotaProviderId, UsageWindow } from '@/types';
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionMessages } from '@/sync/sync-context';
@@ -34,34 +33,12 @@ const formatTokens = (value: number): string => {
return String(value);
};
type MobileUsageLimitRow = {
key: string;
label: string;
subtitle?: string;
window: UsageWindow;
};
type MobileUsageProviderGroup = {
providerId: QuotaProviderId;
providerName: string;
rows: MobileUsageLimitRow[];
status: string | null;
};
type ContextDisplay = {
percentage: number;
tokens: string;
colorClass: string;
} | null;
const getWindowValueClass = (window: UsageWindow): string => {
const usedPercent = window.usedPercent;
if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) return 'text-foreground';
if (usedPercent >= 80) return 'text-[var(--status-error)]';
if (usedPercent >= 50) return 'text-[var(--status-warning)]';
return 'text-foreground';
};
const ContextProgressIcon: React.FC<{ percentage: number }> = ({ percentage }) => {
const progressPct = clampPercent(percentage) ?? 0;
const tone = resolveUsageTone(percentage);
@@ -130,7 +107,7 @@ const SessionMetadataOverlay: React.FC<{
onClose: () => void;
anchorRef: React.RefObject<HTMLElement | null>;
contextDisplay: ContextDisplay;
usageGroups: MobileUsageProviderGroup[];
usageGroups: UsageProviderGroup[];
usageDisplayMode: 'usage' | 'remaining';
isUsageLoading: boolean;
timeFormatPreference: TimeFormatPreference;
@@ -283,7 +260,7 @@ const SessionMetadataOverlay: React.FC<{
};
const MobileUsageLimits: React.FC<{
groups: MobileUsageProviderGroup[];
groups: UsageProviderGroup[];
displayMode: 'usage' | 'remaining';
isLoading: boolean;
timeFormatPreference: TimeFormatPreference;
@@ -318,54 +295,11 @@ const MobileUsageLimits: React.FC<{
</span>
</div>
<div className="space-y-1.5">
{groups.map((group) => (
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-2.5">
<div className="flex min-w-0 items-center gap-2">
<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium text-foreground">
{group.providerName}
</span>
{group.status && group.rows.length === 0 ? (
<span className="shrink-0 truncate typography-micro text-muted-foreground">
{group.status}
</span>
) : null}
</div>
{group.rows.length > 0 ? (
<div className="mt-1.5 space-y-1">
{group.rows.map((row) => {
const displayPercent = displayMode === 'remaining' ? row.window.remainingPercent : row.window.usedPercent;
const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent);
const resetLabel = formatQuotaResetLabel(
row.window.resetAt,
row.window.resetAfterFormatted ?? row.window.resetAtFormatted,
timeFormatPreference,
);
return (
<div key={row.key} className="flex min-w-0 items-baseline justify-between gap-3">
<span className="inline-flex min-w-0 flex-1 items-baseline gap-1.5">
<span className="truncate typography-ui-label text-muted-foreground">
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label}
</span>
{resetLabel ? (
<span className="shrink-0 truncate typography-micro text-muted-foreground/70">{resetLabel}</span>
) : null}
</span>
<span className={cn('shrink-0 typography-ui-label font-semibold tabular-nums', getWindowValueClass(row.window))}>
{metricLabel === '-' ? '' : metricLabel}
</span>
</div>
);
})}
</div>
) : null}
{group.status && group.rows.length > 0 ? (
<div className="mt-1.5 typography-micro text-muted-foreground">{group.status}</div>
) : null}
</div>
))}
</div>
<UsageProviderCards
groups={groups}
displayMode={displayMode}
timeFormatPreference={timeFormatPreference}
/>
</div>
);
};
@@ -403,7 +337,6 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
const quotaDisplayMode = useQuotaStore((state) => state.displayMode);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const selectedQuotaModels = useQuotaStore((state) => state.selectedModels);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
useQuotaAutoRefresh();
@@ -455,6 +388,7 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & {
tokens?: {
total?: unknown;
input?: unknown;
output?: unknown;
reasoning?: unknown;
@@ -462,6 +396,11 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
};
};
if (message.role !== 'assistant' || !message.tokens) continue;
// Multi-step turns accumulate the fields across API round-trips, so
// summing them overstates the window. The server-reported total is the
// final round-trip's window; sum only when the server did not send it.
const reportedTotal = getTokenCount(message.tokens.total);
if (reportedTotal > 0) return reportedTotal;
const total = getTokenCount(message.tokens.input)
+ getTokenCount(message.tokens.output)
+ getTokenCount(message.tokens.reasoning)
@@ -491,54 +430,7 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
? { percentage: contextPercentage, tokens: contextTokens, colorClass: contextColorClass }
: null;
const usageGroups = React.useMemo<MobileUsageProviderGroup[]>(() => {
const resultsByProvider = new Map(quotaResults.map((result) => [result.providerId, result]));
return QUOTA_PROVIDERS
.filter((providerMeta) => dropdownProviderIds.includes(providerMeta.id))
.filter((providerMeta) => resultsByProvider.get(providerMeta.id)?.configured === true)
.map((providerMeta) => {
const result = resultsByProvider.get(providerMeta.id)!;
const rows: MobileUsageLimitRow[] = [];
for (const [label, window] of Object.entries(result?.usage?.windows ?? {})) {
rows.push({
key: `window-${label}`,
label: formatWindowLabel(label),
window,
});
}
const modelEntries = Object.entries(result?.usage?.models ?? {});
const providerSelectedModels = selectedQuotaModels[providerMeta.id] ?? [];
const visibleModelEntries = providerSelectedModels.length > 0
? modelEntries.filter(([modelName]) => providerSelectedModels.includes(modelName))
: modelEntries;
for (const [modelName, modelUsage] of visibleModelEntries) {
const entries = Object.entries(modelUsage.windows ?? {});
if (entries.length === 0) continue;
const [label, window] = entries[0];
rows.push({
key: `model-${modelName}-${label}`,
label: formatWindowLabel(label),
subtitle: getDisplayModelName(modelName),
window,
});
}
const status = !result.ok && result.error
? result.error
: rows.length === 0
? t('header.services.noRateLimitsReported')
: null;
return {
providerId: providerMeta.id,
providerName: providerMeta.name,
rows,
status,
};
});
}, [dropdownProviderIds, quotaResults, selectedQuotaModels, t]);
const usageGroups = useUsageProviderGroups();
React.useEffect(() => {
if (!open || usageGroups.length === 0) return;
@@ -3,7 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
import { SessionActivityDuration } from '@/components/session/SessionActivityDuration';
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems';
import { useSwitcherItems } from '@/components/session/sidebar/shell/useSwitcherItems';
import { useTabletLayout } from '@/lib/device';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
+87 -18
View File
@@ -41,8 +41,11 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { toast } from '@/components/ui';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getProjectLabel, normalizePath } from './mobilePaths';
import { CHAT_DRAFT_PROJECT_ID, isChatDirectoryPath } from '@/lib/chatDirectories';
import { partitionSidebarSessions } from '@/components/session/sidebar/list/sessionCollection';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useI18n } from '@/lib/i18n';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { cn } from '@/lib/utils';
import {
@@ -188,11 +191,8 @@ const findExactProjectMatch = (projects: ProjectMeta[], directory: string): Proj
return projects.find((project) => projectMatchesExactDirectory(project, normalizedDirectory)) ?? null;
};
const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean => {
if (!query) return true;
const haystack = `${session.title ?? ''} ${session.id} ${getSessionDirectory(session)} ${projectLabel}`.toLowerCase();
return haystack.includes(query);
};
const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean =>
matchesRankQuery([session.title, session.id, getSessionDirectory(session), projectLabel], query);
const MobileProjectIcon: React.FC<{
project: Pick<ProjectMeta, 'id' | 'icon' | 'color' | 'iconImage' | 'iconBackground'>;
@@ -1024,6 +1024,27 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
return merged.filter((session) => !session.time?.archived);
}, [globalActiveSessions, liveSessions]);
// Managed Chats (sessions under ~/.config/openchamber/chats) are not owned
// by any registered project; they get their own section above the project
// tree, the same split the desktop sidebar makes. Temporary /btw forks are
// dropped here as well.
const { projectSessions, chatSessions } = React.useMemo(
() => partitionSidebarSessions(sessions, false),
[sessions],
);
const chatsBucket = React.useMemo<WorktreeBucket>(() => ({
key: CHAT_DRAFT_PROJECT_ID,
label: '',
path: '',
worktree: null,
sessions: orderSessionsByLifecycleScopes(chatSessions, pinnedSessionIds, sessionOrderRanks),
}), [chatSessions, pinnedSessionIds, sessionOrderRanks]);
const chatsBucketKey = `${CHAT_DRAFT_PROJECT_ID}::${CHAT_DRAFT_PROJECT_ID}`;
const chatRootCount = React.useMemo(
() => chatSessions.filter((session) => !getParentId(session)).length,
[chatSessions],
);
const normalizedQuery = query.trim().toLowerCase();
// On open, bring the current session (or at least its project) into view —
@@ -1072,7 +1093,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
for (const worktree of node.project.worktrees) ensureBucket(node, worktree.path, worktree);
}
for (const session of sessions) {
for (const session of projectSessions) {
const directory = getSessionDirectory(session);
if (!directory) continue;
const normalizedDirectory = normalizePath(directory);
@@ -1095,7 +1116,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
}
return nodes;
}, [activeProjectId, pinnedSessionIds, projectsMeta, sessionOrderRanks, sessions]);
}, [activeProjectId, pinnedSessionIds, projectSessions, projectsMeta, sessionOrderRanks]);
const normalizedDirectory = normalizePath(currentDirectory);
@@ -1151,8 +1172,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
// Paginated, tree-aware list of a bucket's sessions: top-level sessions paginate,
// and a parent with subsessions can be expanded to reveal its children (nested,
// recursively). Pagination counts only top-level sessions.
const renderBucketSessions = (node: ProjectNode, bucket: WorktreeBucket, indent: number) => {
const bucketKey = `${node.project.id}::${bucket.key}`;
const renderBucketSessions = (bucketKey: string, bucket: WorktreeBucket, indent: number) => {
// Group children by parent within this bucket, and treat sessions whose parent
// is not in this bucket as top-level so nothing is hidden.
@@ -1338,13 +1358,14 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const buildSessionContextLabel = React.useCallback(
(session: Session): string => {
const directory = getSessionDirectory(session);
if (isChatDirectoryPath(directory)) return t('mobile.sessions.section.chats');
const project = findExactProjectMatch(projectsMeta, directory);
if (!project) return getProjectLabel(directory) || directory;
const matchedWorktree = findExactWorktreeMatch(project, normalizePath(directory));
if (matchedWorktree?.branch) return `${project.label} · ${matchedWorktree.branch}`;
return project.label;
},
[projectsMeta],
[projectsMeta, t],
);
const handleSelectProject = (project: ProjectMeta) => {
@@ -1355,7 +1376,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const filteredNodes = React.useMemo(() => {
if (!normalizedQuery) return projectNodes;
return projectNodes.filter((node) => {
if (`${node.project.label} ${node.project.path}`.toLowerCase().includes(normalizedQuery)) return true;
if (matchesRankQuery([node.project.label, node.project.path], normalizedQuery)) return true;
return node.buckets.some((bucket) =>
bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)),
);
@@ -1385,8 +1406,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const searchProjectMatches = React.useMemo(() => {
if (!normalizedQuery) return [] as Array<ProjectMeta & { sessionCount: number }>;
return projectsMeta
.filter((project) => `${project.label} ${project.path}`.toLowerCase().includes(normalizedQuery))
return rankByQuery(projectsMeta, normalizedQuery, (project) => [project.label, project.path])
.map((project) => ({
...project,
sessionCount: sessions.filter((session) => {
@@ -1484,7 +1504,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
) : null}
</div>
</div>
{projectsMeta.length === 0 ? (
{projectsMeta.length === 0 && chatSessions.length === 0 ? (
<MobileSessionsEmpty
title={t('mobile.sessions.empty.noProjectsTitle')}
description={t('mobile.sessions.empty.noProjectsDescription')}
@@ -1604,7 +1624,56 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
</div>
) : (
<div className="flex flex-col">
{orderedNodes.map((node, nodeIndex) => {
{(() => {
const chatsExpanded = projectExpandedMap[CHAT_DRAFT_PROJECT_ID] ?? true;
const chatsLabel = t('mobile.sessions.section.chats');
return (
<section>
<div className="flex min-h-12 w-full items-center">
<button
type="button"
className="flex min-h-12 min-w-0 flex-1 items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
onClick={() => {
if (revealedRowId) {
handleRowKeyRevealedChange(revealedRowId, false);
return;
}
toggleProject(CHAT_DRAFT_PROJECT_ID, chatsExpanded);
}}
aria-expanded={chatsExpanded}
aria-label={
chatsExpanded
? t('sessions.sidebar.group.collapseAria', { label: chatsLabel })
: t('sessions.sidebar.group.expandAria', { label: chatsLabel })
}
style={{ touchAction: 'manipulation' }}
>
<span className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[var(--surface-muted)] text-muted-foreground">
<Icon name="chat-4" className="size-4" />
</span>
<span className="block min-w-0 flex-1 truncate typography-ui-label font-semibold text-foreground">
{chatsLabel}
</span>
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">
{chatRootCount}
</span>
</button>
</div>
{chatsExpanded ? (
<div className="pb-2">
{chatsBucket.sessions.length > 0 ? (
renderBucketSessions(chatsBucketKey, chatsBucket, PROJECT_SESSION_INDENT)
) : (
<p className="px-3 pb-1 typography-micro text-muted-foreground" style={{ paddingLeft: PROJECT_SESSION_INDENT }}>
{t('sessions.sidebar.activity.chatsEmpty')}
</p>
)}
</div>
) : null}
</section>
);
})()}
{orderedNodes.map((node) => {
const projectExpanded = isProjectExpanded(node);
const buckets = normalizedQuery
? node.buckets.filter((bucket) =>
@@ -1617,7 +1686,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
return (
<section
key={node.project.id}
className={cn(nodeIndex > 0 && 'border-t border-border/70')}
className="border-t border-border/70"
>
<MobileSwipeActionsRow
actionsWidth={96}
@@ -1715,7 +1784,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
return (
<>
{rootBucket && rootBucket.sessions.length > 0
? renderBucketSessions(node, rootBucket, PROJECT_SESSION_INDENT)
? renderBucketSessions(`${node.project.id}::${rootBucket.key}`, rootBucket, PROJECT_SESSION_INDENT)
: null}
{worktreeBuckets.map((bucket) => {
const worktreeExpanded = isWorktreeExpanded(node, bucket);
@@ -1790,7 +1859,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
</button>
</MobileSwipeActionsRow>
{worktreeExpanded
? renderBucketSessions(node, bucket, PROJECT_SESSION_INDENT)
? renderBucketSessions(`${node.project.id}::${bucket.key}`, bucket, PROJECT_SESSION_INDENT)
: null}
</div>
);
@@ -9,6 +9,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
import { TerminalView } from '@/components/views/TerminalView';
import { useI18n } from '@/lib/i18n';
import type { ProjectRef } from '@/lib/projectContextApi';
import { cn } from '@/lib/utils';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
@@ -105,7 +106,7 @@ export const MobileWorkspaceDrawer: React.FC<{
/** When set, the Changes tab opens directly into the per-file diff. */
pendingChangesDiff: { path: string; staged: boolean } | null;
/** Notes tab: opens a plan fullscreen (layered above the drawer). */
onOpenPlan: (plan: { path: string; title: string }) => void;
onOpenPlan: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
/** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */
onOpenMcpSettings: () => void;
variant?: 'drawer' | 'panel';
+7
View File
@@ -8,10 +8,13 @@ import { Toaster } from '@/components/ui/sonner';
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
import { useRouter } from '@/hooks/useRouter';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
import { opencodeClient } from '@/lib/opencode/client';
import type { RuntimeAPIs } from '@/lib/api/types';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -55,7 +58,9 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
useAppFontEffects();
usePushVisibilityBeacon({ enabled: true });
useWindowTitle();
useRootScrollLock();
useRouter();
useGlobalSessionsPolling(panelType !== 'agentManager');
React.useEffect(() => {
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
@@ -108,6 +113,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<div className="h-full text-foreground bg-background">
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<AgentManagerView />
<AppLinkConfirmDialog />
<OpenCodeUpdateToast />
<Toaster position="top-center" />
</div>
@@ -127,6 +133,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<div className="h-full text-foreground bg-background">
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<VSCodeLayout />
<AppLinkConfirmDialog />
<OpenCodeUpdateToast />
<Toaster position="top-center" />
<ConfigUpdateOverlay />
+3 -7
View File
@@ -3,7 +3,7 @@ import React from 'react';
import { isCapacitorApp } from '@/lib/platform';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks';
import { parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks';
/**
* Navigation layer for {@link DeepLinkIntent}s the only place that knows how to *apply* a
@@ -93,13 +93,13 @@ const flush = (): void => {
};
/** Apply an intent now if possible, otherwise stash it until the app is ready / a handler appears. */
export const applyDeepLinkIntent = (intent: DeepLinkIntent): void => {
const applyDeepLinkIntent = (intent: DeepLinkIntent): void => {
pending = intent;
flush();
};
/** Convenience: parse a raw `openchamber://…` URL and apply it. No-op for unrecognised URLs. */
export const applyDeepLinkUrl = (raw: string | null | undefined): void => {
const applyDeepLinkUrl = (raw: string | null | undefined): void => {
const intent = parseDeepLink(raw);
if (intent) {
applyDeepLinkIntent(intent);
@@ -192,7 +192,3 @@ export const useDeepLinkSource = (options: { ready: boolean }): void => {
};
}, []);
};
// Re-export so producers (notifications, future widgets) have one import for the whole vocabulary.
export { buildDeepLink, parseDeepLink };
export type { DeepLinkIntent, SessionsFilter, ViewTarget };
+1 -44
View File
@@ -10,7 +10,7 @@
* context including, eventually, a tiny encoder shared with the native widget/extension.
*/
export const DEEP_LINK_SCHEME = 'openchamber';
const DEEP_LINK_SCHEME = 'openchamber';
export type SessionsFilter = 'all' | 'attention' | 'recent';
export type ViewTarget = 'files' | 'mcp' | 'instances' | 'update';
@@ -124,46 +124,3 @@ export function parseDeepLink(raw: string | null | undefined): DeepLinkIntent |
return null;
}
}
/**
* Build a canonical `openchamber://…` URL for an intent. Used by anything that needs to hand
* a deep link to iOS notification payloads, `widgetURL(...)`, Live Activity tap targets
* so every producer emits the exact shape {@link parseDeepLink} understands.
*/
export function buildDeepLink(intent: DeepLinkIntent): string {
const base = `${DEEP_LINK_SCHEME}://`;
const withQuery = (path: string, params: Record<string, string | undefined>): string => {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string' && value.length > 0) {
search.set(key, value);
}
}
const query = search.toString();
return query ? `${base}${path}?${query}` : `${base}${path}`;
};
switch (intent.type) {
case 'session':
return withQuery(`session/${encodeURIComponent(intent.sessionId)}`, { dir: intent.directory });
case 'new-session':
return withQuery('new', {
dir: intent.directory,
project: intent.projectId,
agent: intent.agent,
model: intent.model,
});
case 'sessions':
return withQuery('sessions', { filter: intent.filter });
case 'status':
return `${base}status`;
case 'settings':
return intent.section ? `${base}settings/${encodeURIComponent(intent.section)}` : `${base}settings`;
case 'changes':
return withQuery(intent.path ? `changes/${intent.path}` : 'changes', {
staged: intent.staged ? 'true' : undefined,
});
case 'view':
return `${base}view/${intent.target}`;
}
}
@@ -0,0 +1,106 @@
// In-memory capture of mobile connection lifecycle events, so device-only
// connection failures (Capacitor iOS/Android) can be diagnosed without a
// tethered debugger: the hidden debug panel renders this buffer and offers a
// one-tap copy for bug reports. Console logging stays the primary sink — this
// mirrors it. Never persisted; details are the already-masked logConnect
// payloads (no tokens or secrets reach this module).
import React from 'react';
type MobileConnectDebugEntry = {
at: number;
step: string;
detail: string;
};
const MAX_ENTRIES = 300;
// The trail documents THE CURRENT app run only — it resets on every launch.
// Days of accumulated history would bury the failure the panel exists to
// expose. (An earlier revision persisted the log across launches; the storage
// key is removed here so installs that ran it don't keep a stale blob around.)
const LEGACY_STORAGE_KEY = 'openchamber.mobile.connectLog.v1';
const entries: MobileConnectDebugEntry[] = [];
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
} catch {
// Storage unavailable — the in-memory trail still works.
}
}
export const recordMobileConnectDebug = (step: string, detail: string): void => {
entries.push({ at: Date.now(), step, detail });
if (entries.length > MAX_ENTRIES) entries.splice(0, entries.length - MAX_ENTRIES);
};
// Launch separator: makes "everything above happened in a previous run of the
// app" readable at a glance in the persisted trail.
if (typeof window !== 'undefined') {
recordMobileConnectDebug('app:launch', '{}');
}
export const getMobileConnectDebugEntries = (): MobileConnectDebugEntry[] => [...entries];
const formatTime = (at: number): string => {
const date = new Date(at);
const pad = (value: number, width = 2) => String(value).padStart(width, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
};
export const formatMobileConnectDebugEntry = (entry: MobileConnectDebugEntry): string =>
`${formatTime(entry.at)} ${entry.step}${entry.detail && entry.detail !== '{}' ? ` ${entry.detail}` : ''}`;
export const getMobileConnectDebugText = (): string =>
entries.map(formatMobileConnectDebugEntry).join('\n');
// Long-press detector for the hidden debug-panel triggers. Pointer-based with a
// movement threshold so scrolling and normal taps never fire it; the synthetic
// click that follows a long-press release is swallowed in the capture phase so
// the host element's normal tap action does not also run.
export const useDebugPanelLongPress = (onLongPress: () => void, delayMs = 700) => {
const timerRef = React.useRef<number | null>(null);
const originRef = React.useRef<{ x: number; y: number } | null>(null);
const firedRef = React.useRef(false);
const clear = React.useCallback(() => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = null;
originRef.current = null;
}, []);
React.useEffect(() => clear, [clear]);
const onPointerDown = React.useCallback((event: React.PointerEvent) => {
firedRef.current = false;
originRef.current = { x: event.clientX, y: event.clientY };
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
firedRef.current = true;
onLongPress();
}, delayMs);
}, [delayMs, onLongPress]);
const onPointerMove = React.useCallback((event: React.PointerEvent) => {
const origin = originRef.current;
if (!origin) return;
if (Math.abs(event.clientX - origin.x) > 10 || Math.abs(event.clientY - origin.y) > 10) clear();
}, [clear]);
const onClickCapture = React.useCallback((event: React.MouseEvent) => {
if (!firedRef.current) return;
firedRef.current = false;
event.preventDefault();
event.stopPropagation();
}, []);
return {
onPointerDown,
onPointerMove,
onPointerUp: clear,
onPointerCancel: clear,
onClickCapture,
};
};
+86 -39
View File
@@ -23,9 +23,11 @@ import type { PairingConnectionPayload, PairingEndpointCandidate } from '@/lib/c
import { isCapacitorApp } from '@/lib/platform';
import { adoptRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel';
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { addRuntimeProxyHeaders, runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { recordMobileConnectDebug } from './mobileConnectionDebug';
const MOBILE_CONNECTIONS_STORAGE_KEY = 'openchamber.mobile.connections.v1';
const MOBILE_SECURE_STORAGE_PREFIX = 'openchamber.mobile.';
const MOBILE_DEVICE_ID_STORAGE_KEY = 'openchamber.mobile.deviceId';
@@ -162,7 +164,7 @@ type PairingRedeemResponse = {
// URL helpers
// ---------------------------------------------------------------------------
export const normalizeConnectionUrl = (value: string): string => {
const normalizeConnectionUrl = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return '';
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
@@ -173,7 +175,7 @@ export const normalizeConnectionUrl = (value: string): string => {
return url.toString().replace(/\/+$/, '');
};
export const getConnectionLabel = (url: string): string => {
const getConnectionLabel = (url: string): string => {
try {
return new URL(url).host;
} catch {
@@ -189,7 +191,7 @@ const getConnectionStorageKey = (url: string): string => {
}
};
export const isSameConnectionUrl = (left: string, right: string): boolean =>
const isSameConnectionUrl = (left: string, right: string): boolean =>
getConnectionStorageKey(left) === getConnectionStorageKey(right);
// ---------------------------------------------------------------------------
@@ -199,7 +201,7 @@ export const isSameConnectionUrl = (left: string, right: string): boolean =>
// Stable identity for a relay connection. Also used as the runtime key passed
// to switchRuntimeEndpoint so "is this saved entry the active runtime?" checks
// can compare against getRuntimeKey().
export const relayConnectionRuntimeKey = (relay: MobileRelayConfig): string =>
const relayConnectionRuntimeKey = (relay: MobileRelayConfig): string =>
`relay:${relay.serverId}@${relay.relayUrl.trim()}`;
// Stable, non-fetchable pseudo-URL for a relay-only device (display only).
@@ -304,11 +306,22 @@ const logDetail = (detail: Record<string, unknown>): string => {
};
const logConnect = (step: string, detail: Record<string, unknown> = {}): void => {
console.info('[mobile-connect]', step, logDetail(detail));
const serialized = logDetail(detail);
console.info('[mobile-connect]', step, serialized);
recordMobileConnectDebug(step, serialized);
};
// Exported for surfaces that participate in the connection lifecycle outside
// this module (resume/online re-probes in MobileApp) so their decisions land in
// the same console + debug-panel trail as the probes themselves.
export const logMobileConnectEvent = (step: string, detail: Record<string, unknown> = {}): void => {
logConnect(step, detail);
};
const logStorage = (step: string, detail: Record<string, unknown> = {}): void => {
console.info('[mobile-storage]', step, logDetail(detail));
const serialized = logDetail(detail);
console.info('[mobile-storage]', step, serialized);
recordMobileConnectDebug(step, serialized);
};
const parseMaybeJson = (value: unknown): unknown => {
@@ -333,11 +346,11 @@ const nativeHttpRequest = async (url: string, init?: RequestInit): Promise<Mobil
if (!isCapacitorApp()) return null;
try {
const { CapacitorHttp } = await import('@capacitor/core');
const headers = Object.fromEntries(new Headers(init?.headers).entries());
const requestHeaders = addRuntimeProxyHeaders(url, new Headers(init?.headers));
const response = await CapacitorHttp.request({
url,
method: init?.method || 'GET',
headers,
headers: Object.fromEntries(requestHeaders.entries()),
data: getJsonRequestData(init?.body),
});
return {
@@ -347,14 +360,14 @@ const nativeHttpRequest = async (url: string, init?: RequestInit): Promise<Mobil
json: async () => parseMaybeJson(response.data),
};
} catch (error) {
console.warn('[mobile-connect]', 'native-http failed', logDetail({ url, error: error instanceof Error ? error.message : String(error) }));
logConnect('native-http:failed', { url, error: error instanceof Error ? error.message : String(error) });
return null;
}
};
const browserFetchRequest = async (url: string, init?: RequestInit): Promise<MobileFetchResponse | null> => {
const response = await fetch(url, init).catch((error) => {
console.warn('[mobile-connect]', 'browser-fetch failed', logDetail({ url, error: error instanceof Error ? error.message : String(error) }));
logConnect('browser-fetch:failed', { url, error: error instanceof Error ? error.message : String(error) });
return null;
});
if (!response) return null;
@@ -777,7 +790,7 @@ export const upsertMobileConnection = async (
return next;
};
export const deleteMobileConnection = async (id: string): Promise<MobileSavedConnection[]> => {
const deleteMobileConnection = async (id: string): Promise<MobileSavedConnection[]> => {
const connections = readConnections();
const removed = connections.find((connection) => connection.id === id) ?? null;
const next = connections.filter((connection) => connection.id !== id);
@@ -835,6 +848,7 @@ const probeConnectionCandidates = async (
// /health is unauthenticated by design — never send the bearer token to an
// address whose identity has not been checked yet.
const health = await requestWithTimeout(`${url}/health`, { method: 'GET' }, requestOptions);
logConnect('probe:direct:health', { url, ok: health?.ok === true, status: health?.status ?? null, source: health?.source ?? null });
if (!health?.ok) continue;
if (expectedServerId) {
const payload = await health.json().catch(() => null);
@@ -850,6 +864,7 @@ const probeConnectionCandidates = async (
// the probe passes, and the app dies later on bootstrap's bearer-only
// requests. Cookie auth stays for the token-less (browser) flow.
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions);
logConnect('probe:direct:session', { url, ok: session?.ok === true, status: session?.status ?? null, source: session?.source ?? null, hasToken: Boolean(token) });
if (session?.status === 401) return { status: 'needs-login' };
if (!session || (!session.ok && session.status !== 404)) continue;
const status = await readSessionStatus(session);
@@ -868,11 +883,15 @@ const probeConnectionCandidates = async (
if (!relayCandidate) return { status: 'unreachable' };
// keepTunnel: an 'ok' probe hands its live tunnel to switchToTransport,
// which adopts it as the runtime tunnel — no second connect + handshake.
// Full-budget probes align relay with the direct-transport connect budget
// (8s) instead of inheriting probeRelaySession's 15s default: 8s is ample
// for TLS + WS + E2EE handshake, and a dead host must not pin the connect
// splash (or a resume retry) for 15 extra seconds.
const { outcome, tunnel } = await probeRelaySession(
relayCandidate.relay,
token,
undefined,
options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : undefined,
options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : MOBILE_CONNECT_TIMEOUT_MS,
{ keepTunnel: true },
);
if (outcome === 'ok') return { status: 'ok', transport: { kind: 'relay', relay: relayCandidate.relay, tunnel } };
@@ -989,36 +1008,49 @@ export type AutoConnectOutcome =
/** The saved token was rejected (expired/revoked) — the user must sign in again. */
| { status: 'needs-login'; label: string };
export const autoConnectLastInstance = async (): Promise<AutoConnectOutcome> => {
export const autoConnectLastInstance = async (options?: { fast?: boolean; skipIfConnected?: boolean }): Promise<AutoConnectOutcome> => {
const fast = options?.fast !== false;
await migrateLegacyInlineTokens();
const candidate = readConnections()[0]; // sorted most-recent-first
logConnect('auto-connect:start', { hasCandidate: Boolean(candidate), fast });
if (!candidate) return { status: 'no-candidate' };
// The runtime transport needs a bearer token; only auto-connect when one is
// already saved. A missing/expired token must go through the login UI.
// The runtime transport authenticates with a bearer token when the server
// issued one. A connection saved WITHOUT a token means its last successful
// connect was tokenless (server auth disabled) — probe it the same way; the
// probe itself reports needs-login if the server has since enabled auth. Only
// an EXPECTED token that cannot be read must go through the login UI.
let token: string | undefined;
if (isCapacitorApp()) {
if (!candidate.hasToken) {
return { status: 'no-candidate' };
}
token = await readSecureToken(secureTokenKeyOf(candidate));
if (!token) {
return { status: 'no-candidate' };
if (candidate.hasToken) {
token = await readSecureToken(secureTokenKeyOf(candidate));
if (!token) return { status: 'no-candidate' };
}
} else {
token = candidate.clientToken;
if (!token) return { status: 'no-candidate' };
if (!token && candidate.hasToken) return { status: 'no-candidate' };
}
// Fast probe: the cold-launch splash should decide in a couple of seconds,
// not sit through the full connect timeouts on a dead LAN candidate. A slow
// network that fails the fast probe still lands on the connect screen where
// a manual tap retries with the full budget.
const result = await probeConnectionCandidates(candidate.candidates, token, { fast: true });
// Fast probe by default: the cold-launch splash should decide in a couple of
// seconds, not sit through the full connect timeouts on a dead LAN candidate.
// Callers retrying after an 'unreachable' verdict pass fast:false so the slow
// retry gets the full connect budget (relay cold starts — TLS + WS + E2EE
// handshake — regularly overrun the fast window).
const result = await probeConnectionCandidates(candidate.candidates, token, { fast });
logConnect('auto-connect:probe', { status: result.status, candidates: candidate.candidates.map((c) => c.kind) });
if (result.status === 'needs-login') return { status: 'needs-login', label: candidate.label };
if (result.status !== 'ok') return { status: 'unreachable', label: candidate.label };
// Background-retry guard: while this slow probe ran, the user may have
// connected manually from the connect screen. Their choice wins — discard
// this result instead of hijacking the runtime (close the probe's unused
// relay tunnel; a direct transport holds nothing).
if (options?.skipIfConnected && getRuntimeApiBaseUrl()) {
if (result.transport.kind === 'relay') result.transport.tunnel?.close();
logConnect('auto-connect:superseded', {});
return { status: 'no-candidate' };
}
await upsertMobileConnection({ id: candidate.id, label: candidate.label, candidates: candidate.candidates }); // bump lastUsedAt (keeps token)
switchToTransport(result.transport, token, { runtimeKey: secureTokenKeyOf(candidate) });
switchToTransport(result.transport, token ?? null, { runtimeKey: secureTokenKeyOf(candidate) });
return { status: 'connected' };
};
@@ -1115,7 +1147,7 @@ const establishLiveTransport = async (
// tunnel via runtimeFetch. A transport failure/timeout is transient (the tunnel
// reconnects on its own) and must not masquerade as a revoked session, so only
// an explicit auth rejection reports invalid.
export const validateActiveRuntimeSession = async (input: {
const validateActiveRuntimeSession = async (input: {
url: string;
clientToken?: string | null;
}, options?: { fast?: boolean }): Promise<boolean> => {
@@ -1163,9 +1195,13 @@ export type ReprobeOutcome = 'switched' | 'unchanged' | 'unreachable' | 'needs-l
// validates the current transport over its live channel; only if that is dead does
// it fall through to the lower-priority candidates. 'unchanged' → keep the runtime
// and just refresh; 'unreachable'/'no-connection' → show the connect screen.
export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
export const reprobeActiveConnection = async (options?: { fast?: boolean }): Promise<ReprobeOutcome> => {
const fast = options?.fast !== false;
const active = findActiveConnection();
if (!active) return 'no-connection';
if (!active) {
logConnect('reprobe:no-connection', { runtimeKey: Boolean(getRuntimeKey()) });
return 'no-connection';
}
let token: string | undefined;
if (isCapacitorApp()) {
@@ -1173,7 +1209,15 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
} else {
token = active.clientToken;
}
if (!token) return 'unreachable';
// Tokenless is valid (server auth disabled — the probe reports needs-login if
// that changed); bail only when an EXPECTED token cannot be read. 'unreachable'
// (not needs-login) so the resume retry ladder re-reads the token — a transient
// secure-storage failure must not force a re-login.
if (!token && active.hasToken) {
logConnect('reprobe:no-token', { hasToken: true });
return 'unreachable';
}
logConnect('reprobe:start', { candidates: active.candidates.map((c) => c.kind), fast, hasToken: Boolean(token) });
const currentIndex = active.candidates.findIndex(
(candidate) => transportMatchesCurrentRuntime(candidate.kind === 'relay' ? { kind: 'relay', relay: candidate.relay } : { kind: 'direct', url: candidate.url }),
@@ -1181,10 +1225,11 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
// 1. A higher-priority transport becoming reachable means "came home" (relay → LAN).
const higher = currentIndex >= 0 ? active.candidates.slice(0, currentIndex) : active.candidates;
const better = await probeConnectionCandidates(higher, token, { fast: true });
const better = await probeConnectionCandidates(higher, token, { fast });
logConnect('reprobe:better', { status: better.status, probed: higher.length });
if (better.status === 'ok') {
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
switchToTransport(better.transport, token, { runtimeKey: secureTokenKeyOf(active) });
switchToTransport(better.transport, token ?? null, { runtimeKey: secureTokenKeyOf(active) });
return 'switched';
}
// The shared token was explicitly rejected — no transport will accept it.
@@ -1192,7 +1237,8 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
// 2. No better transport — is the current one still alive on its live channel?
if (currentIndex >= 0) {
const stillValid = await validateActiveRuntimeSession({ url: getRuntimeApiBaseUrl(), clientToken: token }, { fast: true });
const stillValid = await validateActiveRuntimeSession({ url: getRuntimeApiBaseUrl(), clientToken: token }, { fast });
logConnect('reprobe:current', { stillValid });
if (stillValid) {
// Still on the same transport (typically: woke up on the relay, old LAN
// candidate dead). Ask the server for its current LAN addresses in the
@@ -1205,10 +1251,11 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
// 3. Current transport is dead — fall through to lower-priority candidates.
const lower = currentIndex >= 0 ? active.candidates.slice(currentIndex + 1) : [];
const fallback = await probeConnectionCandidates(lower, token, { fast: true });
const fallback = await probeConnectionCandidates(lower, token, { fast });
logConnect('reprobe:fallback', { status: fallback.status, probed: lower.length });
if (fallback.status === 'ok') {
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
switchToTransport(fallback.transport, token, { runtimeKey: secureTokenKeyOf(active) });
switchToTransport(fallback.transport, token ?? null, { runtimeKey: secureTokenKeyOf(active) });
return 'switched';
}
if (fallback.status === 'needs-login') return 'needs-login';
@@ -1240,7 +1287,7 @@ let candidateRefreshInFlight = false;
// Only runs for relay-paired connections: their token/runtime key derives from
// the stable relay identity, so rewriting direct URLs cannot orphan the stored
// token. The response must echo the connection's serverId or it is ignored.
export const refreshActiveConnectionCandidates = async (): Promise<CandidateRefreshResult> => {
const refreshActiveConnectionCandidates = async (): Promise<CandidateRefreshResult> => {
if (candidateRefreshInFlight) return 'skipped';
const active = findActiveConnection();
if (!active) {
-7
View File
@@ -1,5 +1,3 @@
import type { ProjectEntry } from '@/lib/api/types';
export const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
@@ -9,8 +7,3 @@ export const getProjectLabel = (path: string): string => {
const segments = normalized.split('/').filter(Boolean);
return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized;
};
export const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: string): string => {
if (project) return project.label?.trim() || getProjectLabel(project.path);
return getProjectLabel(fallbackDirectory);
};
+36
View File
@@ -83,6 +83,42 @@ describe('scanConnectionQr on Android', () => {
expect(removeCalls).toBe(2);
});
test('falls back to string parsing when the WebView URL parser rejects the link (old Android WebView)', async () => {
// Old Android WebViews resolve openchamber://connect?... with hostname "" and
// pathname "//connect", so the URL-based parse fails on an intact string. The test
// runtime's URL parser handles the canonical form fine, so simulate the rejection
// with a case variant the URL parser refuses while the string parser accepts.
const url = encodePairingConnectionPayload(buildPairingConnectionPayload({
pairingId: 'pair_abc',
secret: 'one-time',
candidates: [{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 }],
}));
const mixedCase = url.replace('openchamber://connect', 'OpenChamber://CONNECT');
const listeners = new Map<string, (event: { barcodes?: Array<{ rawValue?: string }> }) => void>();
const plugin = {
requestPermissions: mock(async () => ({ camera: 'granted' })),
startScan: mock(async () => {
listeners.get('barcodesScanned')?.({ barcodes: [{ rawValue: mixedCase }] });
}),
stopScan: mock(async () => undefined),
addListener: mock((event: string, callback: (info: { barcodes?: Array<{ rawValue?: string }> }) => void) => {
listeners.set(event, callback);
return { remove: () => undefined };
}),
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
});
const result = await scanConnectionQr();
expect(result.status).toBe('pairing');
if (result.status === 'pairing') {
expect(result.pairing.pairingId).toBe('pair_abc');
expect(result.pairing.candidates).toEqual([{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 }]);
}
});
test('stops scanning when the caller aborts', async () => {
let stopCalls = 0;
const stopScan = async () => { stopCalls += 1; };
+10 -3
View File
@@ -4,7 +4,7 @@
// scan() activity, this path bundles the barcode model in the app and does not need
// Google Play Services. iOS keeps the native ready-made scanner.
import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload';
import { parsePairingConnectionPayload, parsePairingConnectionPayloadString, type PairingConnectionPayload } from '@/lib/connectionPayload';
export type MobileConnectionPayload = {
url: string;
@@ -65,8 +65,15 @@ export const parseConnectionPayload = (raw: string): MobileConnectionPayload | M
return null;
};
const resultFromRawValue = (raw: string): QrScanResult => {
const resultFromRawValue = (raw: string, options?: { pairingStringFallback?: boolean }): QrScanResult => {
const payload = parseConnectionPayload(raw);
if (!payload && options?.pairingStringFallback) {
// Old Android WebViews resolve openchamber://… with hostname "" / pathname "//connect",
// so the URL-based parse above fails even though the scanned string is intact. Retry
// with the URL-API-free string parser before declaring the scan invalid.
const pairing = parsePairingConnectionPayloadString(raw);
if (pairing) return { status: 'pairing', pairing };
}
if (!payload) return { status: 'invalid' };
if ('pairing' in payload) return { status: 'pairing', ...payload };
return { status: 'ok', ...payload };
@@ -100,7 +107,7 @@ const scanWithBundledAndroidScanner = async (
Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => {
const barcode = barcodes?.[0];
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
if (raw) finish(resultFromRawValue(raw));
if (raw) finish(resultFromRawValue(raw, { pairingStringFallback: true }));
})).then((handle) => { barcodeListener = handle; }),
Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' })))
.then((handle) => { errorListener = handle; }),
+1 -1
View File
@@ -70,7 +70,7 @@ const projectLabelForDirectory = (directory: string | null, projects: ProjectEnt
return basename(directory);
};
export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
const sessions = useGlobalSessionsStore.getState().activeSessions;
const unseenBySession = useNotificationStore.getState().index.session.unseenCount;
const notifyOnSubtasks = useUIStore.getState().notifyOnSubtasks;
+6 -5
View File
@@ -3,9 +3,9 @@ import type { RuntimeEndpointChangedDetail } from '@/lib/runtime-switch';
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useUIStore } from '@/stores/useUIStore';
import { usePermissionStore } from '@/stores/permissionStore';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useGitStore } from '@/stores/useGitStore';
@@ -15,7 +15,7 @@ import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { resetStreamingState } from '@/sync/streaming';
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
import { replaceGlobalSessionStatusById } from '@/sync/global-session-status';
import { resetSessionOrdering } from '@/sync/session-ordering';
import { resetSessionActivityTiming } from '@/sync/session-activity-timing';
import { syncDesktopSettings } from '@/lib/persistence';
@@ -36,7 +36,6 @@ export const reconnectAppForTransportSwitch = (): void => {
export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedDetail): void => {
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
if (detail.previousRuntimeKey) {
useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey);
}
@@ -52,10 +51,13 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
lastDisconnectReason: null,
});
useProjectsStore.getState().resetForRuntimeSwitch();
// Notes, todos, plans and the pinned-context bookkeeping are keyed by a
// path-derived project id, which two runtimes can collide on.
useProjectContextStore.getState().reset();
// Cross-project session list (mobile sessions sheet & co) belongs to the
// previous instance — drop it so stale sessions can't linger after a switch.
useGlobalSessionsStore.getState().resetForRuntimeSwitch();
useGlobalSessionStatusStore.setState({ statusById: new Map() });
replaceGlobalSessionStatusById(new Map());
resetSessionOrdering();
// Turn timings belong to the previous instance's sessions, and the reset also
// restarts the resume window so the switch is treated as a fresh load.
@@ -67,7 +69,6 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
resetStreamingState();
queueMicrotask(() => void syncDesktopSettings());
};
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- Claude AI symbol (CC0, Wikimedia Commons File:Claude_AI_symbol.svg), monochrome for theme invert -->
<path fill="currentColor" d="m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,15 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Command Code</title>
<path
d="M8 2.4C5.7 2.45 4.35 2.75 3.45 3.7C2.55 4.65 2.3 6.1 2.25 8.35C2.2 10.15 2.2 13.85 2.25 15.65C2.3 17.9 2.55 19.35 3.45 20.3C4.35 21.25 5.7 21.55 8 21.6C9.9 21.65 14.1 21.65 16 21.6C18.3 21.55 19.65 21.25 20.55 20.3C21.45 19.35 21.7 17.9 21.75 15.65C21.8 13.85 21.8 10.15 21.75 8.35C21.7 6.1 21.45 4.65 20.55 3.7C19.65 2.75 18.3 2.45 16 2.4C14.1 2.35 9.9 2.35 8 2.4Z"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linejoin="round"
/>
<path
d="M10 8H14V6.5C14 4.567 15.567 3 17.5 3C19.433 3 21 4.567 21 6.5C21 8.433 19.433 10 17.5 10H16V14H17.5C19.433 14 21 15.567 21 17.5C21 19.433 19.433 21 17.5 21C15.567 21 14 19.433 14 17.5V16H10V17.5C10 19.433 8.433 21 6.5 21C4.567 21 3 19.433 3 17.5C3 15.567 4.567 14 6.5 14H8V10H6.5C4.567 10 3 8.433 3 6.5C3 4.567 4.567 3 6.5 3C8.433 3 10 4.567 10 6.5V8ZM8 8V6.5C8 5.67157 7.32843 5 6.5 5C5.67157 5 5 5.67157 5 6.5C5 7.32843 5.67157 8 6.5 8H8ZM8 16H6.5C5.67157 16 5 16.6716 5 17.5C5 18.3284 5.67157 19 6.5 19C7.32843 19 8 18.3284 8 17.5V16ZM16 8H17.5C18.3284 8 19 7.32843 19 6.5C19 5.67157 18.3284 5 17.5 5C16.6716 5 16 5.67157 16 6.5V8ZM16 16V17.5C16 18.3284 16.6716 19 17.5 19C18.3284 19 19 18.3284 19 17.5C19 16.6716 18.3284 16 17.5 16H16ZM10 10V14H14V10H10Z"
fill="currentColor"
transform="translate(4.56 4.56) scale(.62)"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,41 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
/**
* Non-blocking notice that the OpenChamber session expired mid-work. It never
* takes the screen on its own: work stays visible and interactive, and only
* the explicit "Log in" click hands control to the session gate's full login
* flow (password, passkey, desktop shell all already there).
*/
export const AuthExpiredBanner: React.FC = () => {
const { t } = useI18n();
const authState = useAuthSessionStore((store) => store.state);
const markReauthenticating = useAuthSessionStore((store) => store.markReauthenticating);
if (authState !== 'expired') {
return null;
}
return (
// Below the header on purpose: the header row can be a window-drag region
// on desktop, where nothing under the cursor is clickable.
<div
className="pointer-events-none fixed inset-x-0 z-[200] flex justify-center px-4"
style={{ top: 'calc(var(--oc-header-height, 56px) + 8px)' }}
>
<div
role="alert"
className="oc-glass-popover oc-glass-floating pointer-events-auto flex items-center gap-3 rounded-lg px-3 py-2"
>
<Icon name="lock" className="size-4 flex-shrink-0" style={{ color: 'var(--status-error)' }} />
<span className="typography-ui-label text-foreground">{t('sessionAuth.expired.banner')}</span>
<Button size="xs" variant="outline" onClick={markReauthenticating} className="normal-case">
{t('sessionAuth.expired.loginAction')}
</Button>
</div>
</div>
);
};
@@ -303,6 +303,19 @@ mock.module('@/lib/passkeys', () => ({
registerCurrentDevicePasskey: mock(() => Promise.resolve(null)),
}));
const authSessionStore = {
state: 'ok' as const,
markAuthenticated: mock(() => undefined),
};
mock.module('@/lib/runtime-auth-expiry', () => ({
installAuthSessionFocusWatch: mock(() => undefined),
useAuthSessionStore: Object.assign(
(selector: (store: typeof authSessionStore) => unknown) => selector(authSessionStore),
{ getState: () => authSessionStore },
),
}));
const { SessionAuthGate } = await import('./SessionAuthGate');
const flushEffects = async () => {
@@ -12,6 +12,8 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { installAuthSessionFocusWatch, useAuthSessionStore } from '@/lib/runtime-auth-expiry';
import { AuthExpiredBanner } from './AuthExpiredBanner';
import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
@@ -351,6 +353,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null);
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
const hasResyncedRef = React.useRef(skipAuth);
const hasBootstrapResyncedRef = React.useRef(skipAuth);
React.useEffect(() => {
if (typeof window === 'undefined') {
@@ -557,6 +560,27 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
}
}, [skipAuth, state]);
// Mid-session expiry: the banner asks for a re-login by flipping the shared
// auth store to 'reauthenticating'; the gate answers with its own status
// check, which lands in the full 'locked' flow on a genuine 401. A
// successful login resolves the store back to 'ok'.
const authSessionState = useAuthSessionStore((store) => store.state);
React.useEffect(() => {
if (!skipAuth) installAuthSessionFocusWatch();
}, [skipAuth]);
React.useEffect(() => {
if (skipAuth) return;
if (authSessionState === 'reauthenticating') {
void checkStatusRef.current?.();
}
}, [authSessionState, skipAuth]);
React.useEffect(() => {
if (skipAuth) return;
if (state === 'authenticated' && useAuthSessionStore.getState().state !== 'ok') {
useAuthSessionStore.getState().markAuthenticated();
}
}, [skipAuth, state]);
React.useEffect(() => {
if (state === 'locked' && passwordInputRef.current) {
passwordInputRef.current.focus();
@@ -570,10 +594,18 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
}
if (state === 'authenticated' && !hasResyncedRef.current) {
hasResyncedRef.current = true;
// First authentication of this page load is bootstrap: adopt the
// persisted workspace pointers. A re-login after mid-session expiry is
// not — this window already has its own workspace, and the shared
// settings document may carry another window's pointers.
const isBootstrapResync = !hasBootstrapResyncedRef.current;
hasBootstrapResyncedRef.current = true;
void (async () => {
await initializeAppearancePreferences();
await syncDesktopSettings();
await applyPersistedDirectoryPreferences();
await syncDesktopSettings({ adoptWorkspace: isBootstrapResync });
if (isBootstrapResync) {
await applyPersistedDirectoryPreferences();
}
})();
}
}, [skipAuth, state]);
@@ -983,5 +1015,10 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
);
}
return <>{children}</>;
return (
<>
{skipAuth ? null : <AuthExpiredBanner />}
{children}
</>
);
};
@@ -0,0 +1,77 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { browserUrlLabel } from '@/lib/browser/url';
import type { BrowserHistoryEntry } from '@/lib/browser/history';
/**
* Addresses already visited in this project, offered under the address bar.
*
* Kept deliberately plain: it is a short list of places, so it borrows the
* app's dropdown surface rather than introducing a second look for the same
* idea. Selection is driven from the address bar's own keyboard handling, which
* is why the highlighted row arrives as a prop instead of being tracked here.
*/
export const BrowserAddressSuggestions: React.FC<{
entries: readonly BrowserHistoryEntry[];
activeIndex: number;
onSelect: (url: string) => void;
onForget: (url: string) => void;
onHighlight: (index: number) => void;
}> = ({ entries, activeIndex, onSelect, onForget, onHighlight }) => {
const { t } = useI18n();
if (entries.length === 0) return null;
return (
<div
className="oc-glass-popover oc-glass-floating absolute inset-x-0 top-full z-50 mt-1 overflow-hidden rounded-xl p-1"
role="listbox"
aria-label={t('contextPanel.browser.history.label')}
>
{entries.map((entry, index) => (
<div
key={entry.url}
role="option"
aria-selected={index === activeIndex}
className={cn(
'group flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1',
index === activeIndex ? 'bg-interactive-hover' : 'hover:bg-interactive-hover',
)}
// Pointer down rather than click: the address bar loses focus first,
// and a blur that closes the list would cancel the click.
onPointerDown={(event) => {
event.preventDefault();
onSelect(entry.url);
}}
onPointerEnter={() => onHighlight(index)}
>
<Icon name="global" className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="truncate typography-micro text-foreground">
{entry.title || browserUrlLabel(entry.url)}
</div>
<div className="truncate typography-micro text-muted-foreground">{entry.url}</div>
</div>
<button
type="button"
className={cn(
'shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity',
'hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100',
)}
aria-label={t('contextPanel.browser.history.forget')}
title={t('contextPanel.browser.history.forget')}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
onForget(entry.url);
}}
>
<Icon name="close" className="size-3" aria-hidden="true" />
</button>
</div>
))}
</div>
);
};
@@ -0,0 +1,140 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import {
FILL_VIEWPORT,
VIEWPORT_PRESETS,
clampViewportSize,
presetViewport,
rotateViewport,
viewportSize,
type BrowserViewport,
} from '@/lib/browser/viewport';
export type BrowserColorScheme = 'system' | 'light' | 'dark';
/**
* Size and appearance controls for the previewed page.
*
* Shown only when asked for. The width and height boxes are the source of
* truth; the preset list is a shortcut into them, which is why choosing a
* preset and then typing a size are the same action from here on.
*/
export const BrowserDeviceBar: React.FC<{
viewport: BrowserViewport;
onViewportChange: (viewport: BrowserViewport) => void;
colorScheme: BrowserColorScheme;
onColorSchemeChange: (scheme: BrowserColorScheme) => void;
scale: number;
}> = ({ viewport, onViewportChange, colorScheme, onColorSchemeChange, scale }) => {
const { t } = useI18n();
const size = viewportSize(viewport);
const presetId = viewport.kind === 'preset' ? viewport.id : '';
const commitSize = (side: 'width' | 'height', raw: string) => {
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) return;
const current = size ?? { width: 1280, height: 800 };
onViewportChange({
kind: 'custom',
width: clampViewportSize(side === 'width' ? parsed : current.width),
height: clampViewportSize(side === 'height' ? parsed : current.height),
});
};
const inputClass = cn(
'h-6 w-14 rounded-full border border-border/50 bg-[var(--surface-elevated)] px-2 text-center',
'typography-micro tabular-nums text-foreground outline-none focus:border-[var(--interactive-focus-ring)]',
);
return (
<div className="flex items-center gap-1.5 border-b border-border bg-[var(--surface-background)] px-2 py-1">
<select
value={presetId}
onChange={(event) => {
const next = presetViewport(event.target.value);
onViewportChange(next ?? FILL_VIEWPORT);
}}
aria-label={t('contextPanel.browser.device.preset')}
className={cn(
'h-6 shrink-0 rounded-full border border-border/50 bg-[var(--surface-elevated)] px-2',
'typography-micro text-foreground outline-none focus:border-[var(--interactive-focus-ring)]',
)}
>
<option value="">{t('contextPanel.browser.device.responsive')}</option>
{VIEWPORT_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>{preset.label}</option>
))}
</select>
<input
value={size ? String(size.width) : ''}
onChange={(event) => commitSize('width', event.target.value)}
placeholder="—"
inputMode="numeric"
aria-label={t('contextPanel.browser.device.width')}
className={inputClass}
/>
<span className="typography-micro text-muted-foreground">×</span>
<input
value={size ? String(size.height) : ''}
onChange={(event) => commitSize('height', event.target.value)}
placeholder="—"
inputMode="numeric"
aria-label={t('contextPanel.browser.device.height')}
className={inputClass}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="w-6 shrink-0 rounded-full px-0 text-muted-foreground hover:text-foreground"
onClick={() => onViewportChange(rotateViewport(viewport))}
disabled={!size}
aria-label={t('contextPanel.browser.device.rotate')}
>
<Icon name="refresh" className="size-3.5" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('contextPanel.browser.device.rotate')}</TooltipContent>
</Tooltip>
{/* Only worth saying when the page is not shown at its real size. */}
{size && scale < 1 ? (
<span className="shrink-0 typography-micro tabular-nums text-muted-foreground">
{Math.round(scale * 100)}%
</span>
) : null}
<div className="ml-auto flex shrink-0 items-center gap-1">
{(['system', 'light', 'dark'] as const).map((scheme) => (
<Button
key={scheme}
type="button"
variant={colorScheme === scheme ? 'secondary' : 'ghost'}
size="xs"
className={cn(
'shrink-0 rounded-full px-2.5 typography-micro',
colorScheme === scheme ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
onClick={() => onColorSchemeChange(scheme)}
aria-pressed={colorScheme === scheme}
>
{t(scheme === 'system'
? 'contextPanel.browser.device.schemeSystem'
: scheme === 'light'
? 'contextPanel.browser.device.schemeLight'
: 'contextPanel.browser.device.schemeDark')}
</Button>
))}
</div>
</div>
);
};
@@ -0,0 +1,143 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { useI18n } from '@/lib/i18n';
import { fetchDevServers, mergeDevServerCandidates, type DevServerDiscovery } from '@/lib/browser/devServers';
import { clearAnnouncedDevServers, useAnnouncedDevServers } from '@/lib/browser/announcedServers';
import { browserUrlLabel, isLoopbackUrl } from '@/lib/browser/url';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
/**
* What the panel shows before anything is loaded.
*
* Rather than an inert placeholder, this lists the servers actually running,
* which is almost always what the user came here to open. Discovery failure is
* stated plainly instead of being rendered as "nothing is running" the two
* mean very different things to someone whose dev server is definitely up.
*/
/** The base path a server is served under, or '' when it sits at the root. */
const pathLabel = (url: string): string => {
try {
const path = new URL(url).pathname;
return path === '/' ? '' : path;
} catch {
return '';
}
};
/** Re-checked while the panel is open: a project's servers appear seconds apart. */
const REFRESH_INTERVAL_MS = 2_000;
/**
* True when the listed servers are on another machine and this client has no
* way to reach them. The desktop shell tunnels a local port for exactly this
* case; a browser tab has no equivalent, and its `localhost` is its own.
*/
const isUnreachableFromHere = (): boolean => {
if (typeof window === 'undefined') return false;
if (window.__OPENCHAMBER_ELECTRON__) return false;
const baseUrl = getRuntimeApiBaseUrl();
if (!baseUrl) return false;
try {
return !isLoopbackUrl(new URL(baseUrl, window.location.href).toString());
} catch {
return false;
}
};
export const BrowserEmptyState: React.FC<{
onOpen: (url: string) => void;
directory?: string;
}> = ({ onOpen, directory = '' }) => {
const { t } = useI18n();
const [discovery, setDiscovery] = React.useState<DevServerDiscovery>({ kind: 'loading' });
const announced = useAnnouncedDevServers(directory);
const [remoteOnly] = React.useState(isUnreachableFromHere);
React.useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout> | null = null;
const controller = new AbortController();
const poll = () => {
void fetchDevServers(controller.signal).then((result) => {
if (!active) return;
setDiscovery(result);
// One look is a snapshot of whichever servers happened to be up first.
timer = setTimeout(poll, REFRESH_INTERVAL_MS);
});
};
poll();
return () => {
active = false;
if (timer) clearTimeout(timer);
controller.abort();
};
}, []);
const candidates = React.useMemo(() => mergeDevServerCandidates({
announced,
discovered: discovery.kind === 'ready' ? discovery.servers : null,
}), [announced, discovery]);
return (
// The whole panel must not scroll: a centred column that overflows clips its
// own top, and no amount of scrolling reaches it. Only the list of servers
// scrolls, and it shrinks to whatever room is left before it does.
<div className="absolute inset-0 flex flex-col items-center justify-center gap-5 overflow-hidden bg-background p-6 text-center">
<OpenChamberLogo width={110} height={110} className="shrink-0 opacity-20" />
<div className="flex shrink-0 flex-col gap-1">
<span className="typography-ui-header text-foreground">{t('contextPanel.browser.empty')}</span>
<span className="typography-micro text-muted-foreground">{t('contextPanel.browser.emptyHint')}</span>
</div>
{candidates.length > 0 ? (
<div className="flex min-h-0 w-full max-w-sm flex-col gap-1">
<span className="shrink-0 typography-micro text-left text-muted-foreground">
{announced.length > 0
? t('contextPanel.browser.devServers.justStarted')
: t('contextPanel.browser.devServers.title')}
</span>
{remoteOnly ? (
<span className="shrink-0 pb-1 text-left typography-micro text-muted-foreground">
{t('contextPanel.browser.devServers.remoteOnly')}
</span>
) : null}
<div className="flex min-h-0 flex-col gap-1 overflow-y-auto pr-0.5">
{candidates.map((candidate) => (
<Button
key={candidate.port}
type="button"
variant="outline"
size="sm"
className="w-full shrink-0 justify-start gap-2"
onClick={() => {
// The offer is answered; leaving it up would keep suggesting
// servers behind a page the user is already looking at.
clearAnnouncedDevServers(directory);
onOpen(candidate.url);
}}
>
<Icon name="global" className="size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{browserUrlLabel(candidate.url) || candidate.url}</span>
<span className="ml-auto truncate typography-micro text-muted-foreground">
{pathLabel(candidate.url)}
</span>
</Button>
))}
</div>
</div>
) : null}
{candidates.length === 0 && discovery.kind === 'unavailable' ? (
<span className="typography-micro text-muted-foreground">
{t('contextPanel.browser.devServers.unavailable')}
</span>
) : null}
</div>
);
};
@@ -0,0 +1,926 @@
import React from 'react';
import { toast } from '@/components/ui';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { invokeDesktopCommand } from '@/lib/desktopNative';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
import { useUIStore } from '@/stores/useUIStore';
import { BLANK_URL, isLoopbackUrl, isStartingServerFailure, normalizeBrowserUrl } from '@/lib/browser/url';
import { probeLoopbackStatus } from '@/lib/browser/devServers';
import {
cancelAnnotationSession,
runAnnotationSession,
type AnnotationHost,
type PageCapture,
} from '@/lib/browser/annotationSession';
import { resolveAnnotationOverlayTheme } from '@/lib/browser/overlayTheme';
import { registerBrowserController } from '@/lib/browser/controlClient';
import { suggestFromHistory } from '@/lib/browser/history';
import { selectBrowserHistory, useBrowserHistoryStore } from '@/stores/useBrowserHistoryStore';
import {
DevTunnelUnavailableError,
resolveBrowsableUrl,
shouldTunnelLoopbackUrl,
toDisplayUrl,
} from '@/lib/browser/devTunnel';
import {
buildClickScript,
buildInspectScript,
buildScrollScript,
buildSnapshotScript,
buildTypeScript,
} from '@/lib/browser/pageActions';
import { BrowserToolbar } from './BrowserToolbar';
import { BrowserDeviceBar, type BrowserColorScheme } from './BrowserDeviceBar';
import {
FILL_VIEWPORT,
fitViewport,
isViewportMode,
viewportForMode,
viewportSummary,
type BrowserViewport,
} from '@/lib/browser/viewport';
import { BrowserEmptyState } from './BrowserEmptyState';
import { useAnnotationAttach, useAnnotationOverlayLabels } from './useAnnotationAttach';
import { readEventPayload, useWebviewNavigation } from './useWebviewNavigation';
export type BrowserPaneProps = {
initialUrl: string;
directory: string;
tabID: string;
};
/**
* Chromium is the only host that can give us a real page: cookies, service
* workers, HMR sockets, DevTools, and same-document access for annotation. When
* it is unavailable the surface degrades to a plain iframe that can display a
* page but cannot inspect one, rather than pretending otherwise.
*/
const isChromiumHost = (): boolean => (
typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__)
);
/** How long to keep waiting for a dev server that is still coming up. */
const DEV_SERVER_WAIT_MS = 40_000;
/** Chromium's zoom is exponential: factor = 1.2 ^ level. */
const ZOOM_STEP = 0.5;
const ZOOM_MIN = -3;
const ZOOM_MAX = 4;
const BROWSER_PARTITION = 'persist:openchamber-browser';
/** Kept small: this rides along with every snapshot. */
const CONSOLE_PROBLEM_LIMIT = 20;
const DEV_SERVER_RETRY_DELAY_MS = 600;
/**
* A shorter budget for a server that *answers* but with a 5xx, which is what a
* dev gateway does while the app behind it is still starting. Kept short and
* applied only before the first good load, so a genuine server error a build
* failure page, say is shown promptly instead of being hidden behind a
* spinner.
*/
const GATEWAY_WAIT_MS = 20_000;
const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const webviewRef = React.useRef<WebviewElement | null>(null);
// Tracked in state as well as a ref: effects that attach listeners must re-run
// when the view appears, which a stable ref cannot tell them.
const [webviewElement, setWebviewElement] = React.useState<WebviewElement | null>(null);
const attachWebview = React.useCallback((node: WebviewElement | null) => {
webviewRef.current = node;
setWebviewElement(node);
}, []);
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
// Captured once: the webview owns its history from here on, and re-deriving
// this from props would drag the view back to where the tab started.
const initialUrlRef = React.useRef(normalizeBrowserUrl(initialUrl));
const startUrl = initialUrlRef.current !== BLANK_URL ? initialUrlRef.current : '';
// The view is created with its final URL already in `src`, never navigated
// into place afterwards. A tab opened in the background renders hidden, where
// an imperative navigation is lost, and mutating `src` after the element
// exists is not reliably honoured either — both leave a panel that never
// loads. `null` means "still resolving", and the view is not rendered yet.
const [initialSrc, setInitialSrc] = React.useState<string | null>(startUrl ? null : BLANK_URL);
const [address, setAddress] = React.useState(startUrl);
const [isAnnotating, setIsAnnotating] = React.useState(false);
const [isWaitingForServer, setIsWaitingForServer] = React.useState(false);
const [zoomLevel, setZoomLevel] = React.useState(0);
const [showDeviceBar, setShowDeviceBar] = React.useState(false);
const [viewport, setViewport] = React.useState<BrowserViewport>(FILL_VIEWPORT);
// Read inside agent actions, which are not re-created when the viewport
// changes and would otherwise report whatever it was when they were built.
const viewportRef = React.useRef(viewport);
viewportRef.current = viewport;
/**
* Errors and warnings the page logged, reported with the next snapshot.
*
* A page that looks right and is throwing looks identical to one that is
* fine, and finding out otherwise used to mean opening DevTools by hand.
*/
const consoleProblemsRef = React.useRef<Array<{ level: string; message: string; source: string }>>([]);
const [colorScheme, setColorScheme] = React.useState<BrowserColorScheme>('system');
const [stageSize, setStageSize] = React.useState({ width: 0, height: 0 });
const stageRef = React.useRef<HTMLDivElement | null>(null);
/** When the current run of retries began, per URL. */
const retryRef = React.useRef<{ url: string; startedAt: number } | null>(null);
/** Set once this tab has seen a page that was not a startup error. */
const servedOkRef = React.useRef(false);
const openedAtRef = React.useRef(Date.now());
const persistUrl = React.useCallback((url: string) => {
if (!url || url === BLANK_URL || !directory || !tabID) return;
setContextPanelTabTargetPath(directory, tabID, url);
}, [directory, tabID, setContextPanelTabTargetPath]);
const navigation = useWebviewNavigation(webviewElement, {
initialUrl: startUrl,
onUrlChange: React.useCallback((url: string) => {
const display = toDisplayUrl(url);
setAddress(display);
persistUrl(display);
}, [persistUrl]),
});
/** Set when a remote dev server could not be reached from this machine. */
const [tunnelFailedUrl, setTunnelFailedUrl] = React.useState<string | null>(null);
const attachAnnotation = useAnnotationAttach(directory);
const overlayLabels = useAnnotationOverlayLabels();
const isLoading = navigation.status.kind === 'loading';
const history = useBrowserHistoryStore(selectBrowserHistory(directory));
const recordHistoryVisit = useBrowserHistoryStore((state) => state.recordVisit);
const forgetHistoryVisit = useBrowserHistoryStore((state) => state.forget);
// Recorded once a page has actually loaded, and with the title it reported:
// an address that failed to open is not somewhere to offer going back to.
React.useEffect(() => {
if (navigation.status.kind !== 'ready') return;
recordHistoryVisit(directory, {
url: toDisplayUrl(navigation.status.url),
title: navigation.status.title,
});
}, [directory, navigation.status, recordHistoryVisit]);
const suggestions = React.useMemo(
() => suggestFromHistory(history, address),
[history, address],
);
const loadUrl = React.useCallback((value: string) => {
const next = normalizeBrowserUrl(value);
if (next === BLANK_URL) return;
// The address bar shows what the user asked for; a tunnel only changes
// where the bytes come from, and surfacing 127.0.0.1:<random> would be
// confusing and useless to copy.
setAddress(next);
setTunnelFailedUrl(null);
void resolveBrowsableUrl(next).then((target) => {
const webview = webviewRef.current;
if (!webview) {
setInitialSrc(target);
return;
}
try {
webview.loadURL(target);
} catch {
// Not attached yet: hand the navigation to the attribute, which
// Chromium applies once the view attaches.
setInitialSrc(target);
}
}).catch((error: unknown) => {
// Loading the address here anyway would answer from this machine while
// showing the remote one's address. Say what happened instead.
if (error instanceof DevTunnelUnavailableError) setTunnelFailedUrl(next);
});
}, []);
// Resolving through the tunnel is what lets a persisted loopback URL reach a
// dev server on a remote host; locally it returns the URL unchanged.
React.useEffect(() => {
if (!startUrl) return;
let active = true;
void resolveBrowsableUrl(startUrl)
.then((target) => { if (active) setInitialSrc(target); })
.catch((error: unknown) => {
if (!active) return;
if (error instanceof DevTunnelUnavailableError) {
// The view still needs a src or the panel stays blank forever; it
// gets a blank one, with the failure stated over it.
setTunnelFailedUrl(startUrl);
setInitialSrc(BLANK_URL);
return;
}
setInitialSrc(startUrl);
});
return () => { active = false; };
// Only ever the initial navigation; later changes come from the user.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const annotationHost = React.useMemo<AnnotationHost>(() => ({
executeJavaScript: async (code: string, userGesture?: boolean) => {
const webview = webviewRef.current;
if (!webview) throw new Error('Browser view is not available');
return webview.executeJavaScript(code, userGesture);
},
capturePage: async (): Promise<PageCapture | null> => {
const webview = webviewRef.current;
if (!webview) return null;
const webContentsId = webview.getWebContentsId();
if (!Number.isFinite(webContentsId)) return null;
return await invokeDesktopCommand<PageCapture>('desktop_browser_capture_page', { webContentsId });
},
}), []);
const handleAnnotate = React.useCallback(() => {
if (isAnnotating) {
setIsAnnotating(false);
void cancelAnnotationSession(annotationHost);
return;
}
if (!navigation.url) {
toast.error(t('contextPanel.browser.annotate.noPage'));
return;
}
const theme = resolveAnnotationOverlayTheme(
currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
);
setIsAnnotating(true);
void runAnnotationSession({
host: annotationHost,
theme,
labels: overlayLabels,
})
.then(async (result) => {
setIsAnnotating(false);
if (!result) return;
await attachAnnotation(result);
})
.catch(() => {
setIsAnnotating(false);
toast.error(t('contextPanel.browser.annotate.failed'));
});
}, [annotationHost, attachAnnotation, currentTheme, isAnnotating, navigation.url, overlayLabels, t]);
// Escape leaves annotation mode from the app side too: the overlay owns the
// in-page Escape, but the panel can be focused instead.
React.useEffect(() => {
if (!isAnnotating) return;
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopImmediatePropagation();
setIsAnnotating(false);
void cancelAnnotationSession(annotationHost);
};
window.addEventListener('keydown', handler, true);
return () => window.removeEventListener('keydown', handler, true);
}, [annotationHost, isAnnotating]);
// Agent-driven actions. Waiting for the page to settle after a navigation is
// deliberate: a snapshot taken mid-load describes a page that no longer
// exists by the time the agent reads it.
const waitForIdle = React.useCallback(async (timeoutMs = 8_000): Promise<boolean> => {
const startedAt = Date.now();
for (;;) {
const webview = webviewRef.current;
if (!webview) return false;
let busy = false;
try {
busy = webview.isLoading();
} catch {
return false;
}
if (!busy) return true;
// A page with a looping video or a long-lived stream can report loading
// indefinitely. Give up waiting and act on it anyway rather than letting
// the whole action expire.
if (Date.now() - startedAt > timeoutMs) return false;
await new Promise((resolve) => setTimeout(resolve, 120));
}
}, []);
const runControlAction = React.useCallback(async (
action: string,
parameters: Record<string, unknown>,
): Promise<unknown> => {
const webview = webviewRef.current;
if (!webview) throw new Error('The browser panel is not ready');
// Showing the bar when the agent sizes the page keeps the change visible:
// the user should see which layout is being looked at, not just that it
// suddenly narrowed.
const applyViewportParameter = (): void => {
if (!isViewportMode(parameters.viewport)) return;
setViewport(viewportForMode(parameters.viewport));
setShowDeviceBar(true);
};
if (action === 'browser.back' || action === 'browser.forward') {
const goingBack = action === 'browser.back';
const canMove = goingBack ? webview.canGoBack() : webview.canGoForward();
if (!canMove) {
throw new Error(goingBack
? 'There is nothing to go back to in this tab'
: 'There is nothing to go forward to in this tab');
}
if (goingBack) webview.goBack();
else webview.goForward();
await new Promise((resolve) => setTimeout(resolve, 150));
await waitForIdle();
let title = '';
try { title = webview.getTitle() || ''; } catch { title = ''; }
return { url: toDisplayUrl(webview.getURL()), title };
}
if (action === 'browser.capture') {
// A user may close the panel after browser.open. Chromium then removes
// the zero-width webview's composited surface and capturePage() fails
// with UnknownVizError. Reveal this existing browser tab again and let
// the layout paint before asking Electron for the image.
useUIStore.getState().openContextBrowser(directory, webview.getURL());
const surfaceDeadline = Date.now() + 1_200;
let previousWidth = 0;
let stableSamples = 0;
while (stableSamples < 2 && Date.now() < surfaceDeadline) {
const width = webview.getBoundingClientRect().width;
stableSamples = width >= 2 && Math.abs(width - previousWidth) < 0.5
? stableSamples + 1
: 0;
previousWidth = width;
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
// Wait for a settled page first: a screenshot of a half-painted layout is
// worse than none, because it looks like a finished one.
await waitForIdle();
const capture = await annotationHost.capturePage();
if (!capture) throw new Error('The page could not be captured');
let title = '';
try { title = webview.getTitle() || ''; } catch { title = ''; }
return {
...capture,
url: toDisplayUrl(webview.getURL()),
title,
viewport: viewportSummary(viewportRef.current),
};
}
if (action === 'browser.resize') {
if (!isViewportMode(parameters.viewport)) throw new Error('viewport is required');
applyViewportParameter();
// Let the resize land before reporting it, so a snapshot that follows
// describes the new layout rather than the old one.
await new Promise((resolve) => setTimeout(resolve, 200));
await waitForIdle();
return { viewport: viewportSummary(viewportForMode(parameters.viewport)) };
}
if (action === 'browser.open') {
const url = typeof parameters.url === 'string' ? parameters.url : '';
if (!url) throw new Error('url is required');
applyViewportParameter();
loadUrl(url);
await new Promise((resolve) => setTimeout(resolve, 150));
const settled = await waitForIdle(25_000);
let title = '';
try {
title = webview.getTitle() || '';
} catch {
title = '';
}
// `settled: false` means the page is still fetching, not that opening
// failed — the agent can snapshot it and decide for itself.
return {
url: normalizeBrowserUrl(url),
title,
opened: true,
settled,
viewport: viewportSummary(viewportRef.current),
};
}
await waitForIdle();
const asOptionalString = (value: unknown): string | undefined => (
typeof value === 'string' && value ? value : undefined
);
const buildScript = (): string | null => {
switch (action) {
case 'browser.snapshot':
return buildSnapshotScript({ selector: asOptionalString(parameters.selector) });
case 'browser.click':
return buildClickScript({
selector: asOptionalString(parameters.selector),
text: asOptionalString(parameters.text),
});
case 'browser.type':
return buildTypeScript({
selector: String(parameters.selector ?? ''),
value: String(parameters.value ?? ''),
submit: parameters.submit === true,
});
case 'browser.inspect':
return buildInspectScript({ selector: String(parameters.selector ?? '') });
case 'browser.scroll':
return buildScrollScript({
selector: asOptionalString(parameters.selector),
direction: asOptionalString(parameters.direction),
});
default:
return null;
}
};
const script = buildScript();
if (!script) throw new Error(`Unsupported browser action: ${action}`);
const result = await webview.executeJavaScript(script, true);
if (!result || typeof result !== 'object') {
throw new Error('The page returned no result');
}
const record = result as Record<string, unknown>;
if (record.ok !== true) {
throw new Error(typeof record.error === 'string' && record.error ? record.error : 'Browser action failed');
}
// A snapshot has to say which layout it describes, or the agent cannot tell
// a mobile rendering from a desktop one.
if (action === 'browser.snapshot') {
const problems = consoleProblemsRef.current;
return {
...record,
viewport: viewportSummary(viewportRef.current),
...(problems.length > 0 ? { consoleProblems: [...problems] } : {}),
};
}
// A click or a submit commonly starts a navigation; let it land so the
// agent's next snapshot sees the page the action produced.
if (action === 'browser.click' || (action === 'browser.type' && parameters.submit === true)) {
await new Promise((resolve) => setTimeout(resolve, 150));
await waitForIdle();
}
return result;
}, [annotationHost, directory, loadUrl, waitForIdle]);
React.useEffect(
() => registerBrowserController({ run: runControlAction }),
[runControlAction],
);
// Leaving the tab must not strand an overlay or live style overrides on the page.
React.useEffect(() => {
const host = annotationHost;
return () => { void cancelAnnotationSession(host); };
}, [annotationHost]);
React.useEffect(() => {
if (!webviewElement) return;
const onConsoleMessage = (event: Event) => {
const detail = event as unknown as { level?: number; message?: string; sourceId?: string; line?: number };
// 2 is warning, 3 is error; anything quieter is the page talking to itself.
if (typeof detail.level !== 'number' || detail.level < 2) return;
const source = detail.sourceId ? `${detail.sourceId}${detail.line ? `:${detail.line}` : ''}` : '';
consoleProblemsRef.current.push({
level: detail.level >= 3 ? 'error' : 'warning',
message: String(detail.message ?? '').slice(0, 400),
source,
});
if (consoleProblemsRef.current.length > CONSOLE_PROBLEM_LIMIT) {
consoleProblemsRef.current.splice(0, consoleProblemsRef.current.length - CONSOLE_PROBLEM_LIMIT);
}
};
// Each page gets its own record; carrying the last one over would blame a
// new page for the previous page's failures.
const onStartLoading = () => { consoleProblemsRef.current = []; };
webviewElement.addEventListener('console-message', onConsoleMessage);
webviewElement.addEventListener('did-start-loading', onStartLoading);
return () => {
webviewElement.removeEventListener('console-message', onConsoleMessage);
webviewElement.removeEventListener('did-start-loading', onStartLoading);
};
}, [webviewElement]);
/**
* Keeps loopback navigations on the machine the page came from.
*
* A tunnelled page can send the view to another local port a docs server
* behind a dev gateway, an API on its own port. That navigation happens
* inside the view, so nothing resolved it, and it would be looked for on this
* machine instead of the host.
*
* A link or a script navigation is caught before it happens. A server
* redirect cannot be: by the time the view reports it, it is already loading.
* That one is recovered from its failure instead, once per address, so a port
* that genuinely is not there still fails honestly.
*/
const retunneledUrlsRef = React.useRef(new Set<string>());
// Asking for an address again is a fresh request, so the recovery budget
// comes back with it. The automatic retry deliberately does not reset it.
const loadUrlFromUser = React.useCallback((value: string) => {
retunneledUrlsRef.current.clear();
loadUrl(value);
}, [loadUrl]);
React.useEffect(() => {
if (!webviewElement) return;
const onWillNavigate = (event: Event) => {
const detail = readEventPayload<{ url?: string }>(event);
const target = typeof detail.url === 'string' ? detail.url : '';
if (!target || !shouldTunnelLoopbackUrl(target)) return;
event.preventDefault();
loadUrl(target);
};
const onFailLoad = (event: Event) => {
const detail = readEventPayload<{
errorCode?: number;
validatedURL?: string;
isMainFrame?: boolean;
}>(event);
if (detail.isMainFrame === false) return;
// Superseded navigations are not failures.
if (detail.errorCode === -3) return;
const target = typeof detail.validatedURL === 'string' ? detail.validatedURL : '';
if (!target || !shouldTunnelLoopbackUrl(target)) return;
if (retunneledUrlsRef.current.has(target)) return;
retunneledUrlsRef.current.add(target);
loadUrl(target);
};
webviewElement.addEventListener('will-navigate', onWillNavigate);
webviewElement.addEventListener('did-fail-load', onFailLoad);
return () => {
webviewElement.removeEventListener('will-navigate', onWillNavigate);
webviewElement.removeEventListener('did-fail-load', onFailLoad);
};
}, [loadUrl, webviewElement]);
// Popups open in place; a detached window would escape the panel entirely.
React.useEffect(() => {
if (!webviewElement) return;
const onNewWindow = (event: Event) => {
const detail = (event as CustomEvent<{ url?: string }>).detail;
event.preventDefault();
if (detail?.url) loadUrl(detail.url);
};
webviewElement.addEventListener('new-window', onNewWindow);
return () => webviewElement.removeEventListener('new-window', onNewWindow);
}, [loadUrl, webviewElement]);
const applyZoom = React.useCallback((level: number) => {
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level));
setZoomLevel(next);
try {
webviewRef.current?.setZoomLevel(next);
} catch {
// Not attached yet; the next change applies it.
}
}, []);
const clearBrowsingData = React.useCallback((what: 'cookies' | 'cache') => {
void invokeDesktopCommand('desktop_browser_clear_data', {
partition: BROWSER_PARTITION,
cookies: what === 'cookies',
cache: what === 'cache',
})
.then(() => {
toast.success(t(what === 'cookies'
? 'contextPanel.browser.clearedCookies'
: 'contextPanel.browser.clearedCache'));
// Cleared storage only shows in a page that reloads without it.
try { webviewRef.current?.reloadIgnoringCache(); } catch { /* not attached */ }
})
.catch(() => toast.error(t('contextPanel.browser.clearFailed')));
}, [t]);
// The stage is measured rather than assumed: the panel is resizable, and a
// viewport that fitted a moment ago may not fit now.
React.useEffect(() => {
const stage = stageRef.current;
if (!stage || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver((entries) => {
const rect = entries[0]?.contentRect;
if (rect) setStageSize({ width: rect.width, height: rect.height });
});
observer.observe(stage);
return () => observer.disconnect();
}, []);
const applyColorScheme = React.useCallback((scheme: BrowserColorScheme) => {
setColorScheme(scheme);
const webview = webviewRef.current;
if (!webview) return;
let webContentsId = -1;
try {
webContentsId = webview.getWebContentsId();
} catch {
return;
}
void invokeDesktopCommand('desktop_browser_set_color_scheme', { webContentsId, scheme })
.catch((error: unknown) => {
setColorScheme('system');
toast.error(error instanceof Error ? error.message : t('contextPanel.browser.device.schemeFailed'));
});
}, [t]);
const handleReload = React.useCallback(() => {
try {
if (isLoading) webviewRef.current?.stop();
else webviewRef.current?.reload();
} catch {
// Not attached yet.
}
}, [isLoading]);
// A page opened the moment its dev server launched is not ready twice over:
// first nothing is listening at all, then a gateway answers while the app
// behind it is still starting. Neither is an error the user can act on, and
// both used to leave them pressing reload. Both are waited out here.
const status = navigation.status;
React.useEffect(() => {
const reloadSoon = (): (() => void) => {
setIsWaitingForServer(true);
const timer = setTimeout(() => {
try {
webviewRef.current?.reload();
} catch {
// View went away; the next mount starts over.
}
}, DEV_SERVER_RETRY_DELAY_MS);
return () => clearTimeout(timer);
};
// Nothing is listening yet.
if (status.kind === 'failed') {
if (!isStartingServerFailure(status.code, status.url)) {
setIsWaitingForServer(false);
return;
}
const now = Date.now();
const run = retryRef.current?.url === status.url
? retryRef.current
: { url: status.url, startedAt: now };
retryRef.current = run;
if (now - run.startedAt > DEV_SERVER_WAIT_MS) {
setIsWaitingForServer(false);
return;
}
return reloadSoon();
}
// Mid-navigation: leave whatever state the previous decision set, so a
// retry does not flash the page behind the waiting screen and back.
if (status.kind === 'loading') return;
retryRef.current = null;
if (status.kind !== 'ready' || !status.url || !isLoopbackUrl(status.url)) {
servedOkRef.current = true;
setIsWaitingForServer(false);
return;
}
if (servedOkRef.current || Date.now() - openedAtRef.current > GATEWAY_WAIT_MS) {
servedOkRef.current = true;
setIsWaitingForServer(false);
return;
}
// The page loaded, but a 5xx here means the server answered on behalf of an
// app that is not up yet. Checked by status rather than by reading the page:
// guessing from its contents would mean encoding what each dev server's
// error page looks like, which is exactly the trap this panel came out of.
let cancelled = false;
let cancelReload: (() => void) | null = null;
// Probe the address on the host, not the local tunnel port: the check runs
// on the server, where our ephemeral port means nothing. Asking about it
// failed every time, which read as "settled" and left the page on the error
// until a manual reload.
void probeLoopbackStatus(toDisplayUrl(status.url)).then((httpStatus) => {
if (cancelled) return;
if (httpStatus === null || httpStatus < 500) {
servedOkRef.current = true;
setIsWaitingForServer(false);
return;
}
cancelReload = reloadSoon();
});
return () => {
cancelled = true;
cancelReload?.();
};
}, [status]);
const failed = navigation.status.kind === 'failed' && !isWaitingForServer ? navigation.status : null;
const layout = fitViewport(viewport, stageSize);
return (
<div className="absolute inset-0 flex flex-col bg-background">
<BrowserToolbar
address={address}
onAddressChange={setAddress}
onSubmit={loadUrlFromUser}
suggestions={suggestions}
onForgetSuggestion={(url) => forgetHistoryVisit(directory, url)}
onBack={() => { try { webviewRef.current?.goBack(); } catch { /* not attached */ } }}
onForward={() => { try { webviewRef.current?.goForward(); } catch { /* not attached */ } }}
onReload={handleReload}
onOpenExternal={() => void openExternalUrl(navigation.url || address)}
canGoBack={navigation.canGoBack}
canGoForward={navigation.canGoForward}
isLoading={isLoading}
onAnnotate={handleAnnotate}
isAnnotating={isAnnotating}
onOpenDevTools={() => { try { webviewRef.current?.openDevTools(); } catch { /* not attached */ } }}
onHardReload={() => { try { webviewRef.current?.reloadIgnoringCache(); } catch { /* not attached */ } }}
onZoomIn={() => applyZoom(zoomLevel + ZOOM_STEP)}
onZoomOut={() => applyZoom(zoomLevel - ZOOM_STEP)}
onZoomReset={() => applyZoom(0)}
zoomPercent={Math.round(Math.pow(1.2, zoomLevel) * 100)}
onClearCookies={() => clearBrowsingData('cookies')}
onClearCache={() => clearBrowsingData('cache')}
onToggleDeviceBar={() => setShowDeviceBar((current) => !current)}
isDeviceBarOpen={showDeviceBar}
/>
{showDeviceBar ? (
<BrowserDeviceBar
viewport={viewport}
onViewportChange={setViewport}
colorScheme={colorScheme}
onColorSchemeChange={applyColorScheme}
scale={layout?.scale ?? 1}
/>
) : null}
<div
ref={stageRef}
className={cn(
'relative min-h-0 flex-1 bg-background',
// A sized viewport sits on a backdrop so its edges are visible; at
// fill there is nothing to frame.
layout && 'flex items-center justify-center overflow-hidden bg-[var(--surface-muted)]',
)}
>
{initialSrc !== null ? (
<webview
ref={attachWebview}
src={initialSrc}
partition="persist:openchamber-browser"
allowpopups
style={layout
? {
// Laid out at the chosen size and scaled visually: the page must
// measure itself at the width being tested, not at the panel's.
width: `${layout.width}px`,
height: `${layout.height}px`,
transform: `scale(${layout.scale})`,
border: 'none',
flex: 'none',
boxShadow: '0 2px 18px rgba(0,0,0,.28)',
}
: { width: '100%', height: '100%', border: 'none' }}
/>
) : null}
{initialSrc !== null && !startUrl && !navigation.url && !isLoading ? (
<BrowserEmptyState onOpen={loadUrlFromUser} directory={directory} />
) : null}
{isWaitingForServer ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background p-6 text-center">
<span className="typography-ui-header text-foreground">{t('contextPanel.browser.waitingForServer')}</span>
<span className="typography-micro text-muted-foreground">{t('contextPanel.browser.waitingForServerHint')}</span>
</div>
) : null}
{tunnelFailedUrl ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background p-6 text-center">
<span className="typography-ui-header text-foreground">{t('contextPanel.browser.tunnelFailed')}</span>
<span className="typography-micro text-muted-foreground">
{t('contextPanel.browser.tunnelFailedHint', { url: tunnelFailedUrl })}
</span>
</div>
) : null}
{failed && !tunnelFailedUrl ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background p-6 text-center">
<span className="typography-ui-header text-foreground">
{failed.crashed ? t('contextPanel.browser.crashed') : t('contextPanel.browser.loadFailed')}
</span>
<span className="typography-micro text-muted-foreground">
{failed.crashed
? t('contextPanel.browser.crashedHint')
: failed.description || t('contextPanel.browser.loadFailedUnknown')}
</span>
</div>
) : null}
{isLoading ? (
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 overflow-hidden">
<div className="h-full w-1/3 animate-[browser-progress_1.1s_ease-in-out_infinite] bg-[var(--primary)]" />
</div>
) : null}
</div>
</div>
);
};
/**
* Non-Chromium runtimes get a plain iframe. Same-origin policy makes the page
* opaque to us here: no navigation events, no annotation, no console. The
* toolbar reflects that instead of offering controls that would silently fail.
*/
const IframeBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
const { t } = useI18n();
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
const normalized = normalizeBrowserUrl(initialUrl);
const startUrl = normalized !== BLANK_URL ? normalized : '';
const [address, setAddress] = React.useState(startUrl);
const [loadedUrl, setLoadedUrl] = React.useState(startUrl);
const [history, setHistory] = React.useState<string[]>(startUrl ? [startUrl] : []);
const [historyIndex, setHistoryIndex] = React.useState(startUrl ? 0 : -1);
const [reloadNonce, bumpReload] = React.useReducer((value: number) => value + 1, 0);
const persistUrl = React.useCallback((url: string) => {
if (!url || url === BLANK_URL || !directory || !tabID) return;
setContextPanelTabTargetPath(directory, tabID, url);
}, [directory, tabID, setContextPanelTabTargetPath]);
const visitedAddresses = useBrowserHistoryStore(selectBrowserHistory(directory));
const recordHistoryVisit = useBrowserHistoryStore((state) => state.recordVisit);
const forgetHistoryVisit = useBrowserHistoryStore((state) => state.forget);
const suggestions = React.useMemo(
() => suggestFromHistory(visitedAddresses, address),
[visitedAddresses, address],
);
const navigate = React.useCallback((value: string) => {
const next = normalizeBrowserUrl(value);
if (next === BLANK_URL) return;
setAddress(next);
setLoadedUrl(next);
persistUrl(next);
// The page is opaque here, so there is no load event and no title to wait
// for; what was asked for is the only thing this runtime can record.
recordHistoryVisit(directory, { url: next });
setHistory((current) => {
const kept = historyIndex >= 0 ? current.slice(0, historyIndex + 1) : [];
if (kept[kept.length - 1] === next) {
setHistoryIndex(kept.length - 1);
return kept;
}
setHistoryIndex(kept.length);
return [...kept, next];
});
}, [directory, historyIndex, persistUrl, recordHistoryVisit]);
const goTo = React.useCallback((index: number) => {
const next = history[index];
if (!next) return;
setHistoryIndex(index);
setAddress(next);
setLoadedUrl(next);
persistUrl(next);
}, [history, persistUrl]);
return (
<div className="absolute inset-0 flex flex-col bg-background">
<BrowserToolbar
address={address}
onAddressChange={setAddress}
onSubmit={navigate}
suggestions={suggestions}
onForgetSuggestion={(url) => forgetHistoryVisit(directory, url)}
onBack={() => goTo(historyIndex - 1)}
onForward={() => goTo(historyIndex + 1)}
onReload={bumpReload}
onOpenExternal={() => void openExternalUrl(loadedUrl || address)}
canGoBack={historyIndex > 0}
canGoForward={historyIndex >= 0 && historyIndex < history.length - 1}
isLoading={false}
/>
<div className="relative min-h-0 flex-1 bg-background">
{loadedUrl ? (
<iframe
key={`${loadedUrl}|${reloadNonce}`}
src={loadedUrl}
title={t('contextPanel.browser.frameTitle')}
className="h-full w-full border-none bg-white"
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
/>
) : (
<BrowserEmptyState onOpen={navigate} />
)}
</div>
</div>
);
};
export const BrowserPane: React.FC<BrowserPaneProps> = (props) => {
const [chromium] = React.useState(isChromiumHost);
return chromium ? <WebviewBrowser {...props} /> : <IframeBrowser {...props} />;
};
@@ -0,0 +1,241 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { BrowserAddressSuggestions } from './BrowserAddressSuggestions';
import type { BrowserHistoryEntry } from '@/lib/browser/history';
import { cn } from '@/lib/utils';
import type { IconName } from '@/components/icon/icons';
type ToolbarButtonProps = {
icon: IconName;
label: string;
onClick: () => void;
disabled?: boolean;
pressed?: boolean;
};
const ToolbarButton: React.FC<ToolbarButtonProps> = ({ icon, label, onClick, disabled, pressed }) => (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={pressed ? 'secondary' : 'ghost'}
size="xs"
className={cn(
'w-6 shrink-0 rounded-full px-0 text-muted-foreground',
'hover:text-foreground',
pressed && 'text-foreground',
)}
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={pressed}
>
<Icon name={icon} className="size-3.5" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{label}</TooltipContent>
</Tooltip>
);
export type BrowserToolbarProps = {
address: string;
/** Addresses already visited in this project, offered while typing. */
suggestions?: readonly BrowserHistoryEntry[];
onForgetSuggestion?: (url: string) => void;
onAddressChange: (value: string) => void;
onSubmit: (value: string) => void;
onBack: () => void;
onForward: () => void;
onReload: () => void;
onOpenExternal: () => void;
canGoBack: boolean;
canGoForward: boolean;
isLoading: boolean;
/** These need a real Chromium host; hidden without one. */
onAnnotate?: () => void;
onOpenDevTools?: () => void;
isAnnotating?: boolean;
onHardReload?: () => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
onZoomReset?: () => void;
/** Whole percent, e.g. 110. Controls hide at 100 to keep the bar quiet. */
zoomPercent?: number;
onClearCookies?: () => void;
onClearCache?: () => void;
onToggleDeviceBar?: () => void;
isDeviceBarOpen?: boolean;
};
export const BrowserToolbar: React.FC<BrowserToolbarProps> = ({
address,
suggestions = [],
onForgetSuggestion,
onAddressChange,
onSubmit,
onBack,
onForward,
onReload,
onOpenExternal,
canGoBack,
canGoForward,
isLoading,
onAnnotate,
onOpenDevTools,
isAnnotating,
onHardReload,
onZoomIn,
onZoomOut,
onZoomReset,
zoomPercent = 100,
onClearCookies,
onClearCache,
onToggleDeviceBar,
isDeviceBarOpen,
}) => {
const { t } = useI18n();
const [isAddressFocused, setIsAddressFocused] = React.useState(false);
const [activeSuggestion, setActiveSuggestion] = React.useState(-1);
const visibleSuggestions = isAddressFocused ? suggestions : [];
// A new list is a new choice; keeping an old index would highlight whatever
// happens to sit in that position now.
React.useEffect(() => {
setActiveSuggestion(-1);
}, [address, isAddressFocused]);
const submitAddress = (value: string) => {
setIsAddressFocused(false);
setActiveSuggestion(-1);
onSubmit(value);
};
const onAddressKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (visibleSuggestions.length === 0) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const step = event.key === 'ArrowDown' ? 1 : -1;
const count = visibleSuggestions.length;
// Wraps through "nothing selected", so the typed address stays reachable.
setActiveSuggestion((current) => {
const next = current + step;
if (next < -1) return count - 1;
if (next >= count) return -1;
return next;
});
return;
}
if (event.key === 'Escape' && activeSuggestion >= 0) {
event.preventDefault();
setActiveSuggestion(-1);
return;
}
if (event.key === 'Enter' && activeSuggestion >= 0) {
event.preventDefault();
const chosen = visibleSuggestions[activeSuggestion];
if (chosen) submitAddress(chosen.url);
}
};
return (
<div className="flex items-center gap-1 border-b border-border bg-[var(--surface-background)] px-2 py-1">
<ToolbarButton icon="arrow-left" label={t('contextPanel.browser.back')} onClick={onBack} disabled={!canGoBack} />
<ToolbarButton icon="arrow-right" label={t('contextPanel.browser.forward')} onClick={onForward} disabled={!canGoForward} />
<ToolbarButton
icon="refresh"
label={isLoading ? t('contextPanel.browser.stop') : t('contextPanel.browser.reload')}
onClick={onReload}
/>
{onHardReload ? (
<ToolbarButton icon="restart" label={t('contextPanel.browser.hardReload')} onClick={onHardReload} />
) : null}
<form
className="relative min-w-0 flex-1"
onSubmit={(event) => {
event.preventDefault();
submitAddress(address);
}}
>
<input
value={address}
onChange={(event) => onAddressChange(event.target.value)}
onFocus={() => setIsAddressFocused(true)}
onBlur={() => setIsAddressFocused(false)}
onKeyDown={onAddressKeyDown}
spellCheck={false}
autoComplete="off"
role="combobox"
aria-expanded={visibleSuggestions.length > 0}
aria-controls="openchamber-browser-address-suggestions"
className={cn(
'h-6 w-full rounded-full border border-border/50 bg-[var(--surface-elevated)] px-3',
'typography-micro text-foreground outline-none focus:border-[var(--interactive-focus-ring)]',
)}
aria-label={t('contextPanel.browser.addressAria')}
/>
<div id="openchamber-browser-address-suggestions">
<BrowserAddressSuggestions
entries={visibleSuggestions}
activeIndex={activeSuggestion}
onSelect={submitAddress}
onForget={(url) => onForgetSuggestion?.(url)}
onHighlight={setActiveSuggestion}
/>
</div>
</form>
{onZoomOut && onZoomIn ? (
<div className="flex shrink-0 items-center">
<ToolbarButton icon="subtract" label={t('contextPanel.browser.zoomOut')} onClick={onZoomOut} />
{zoomPercent !== 100 && onZoomReset ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="shrink-0 rounded-full px-1.5 typography-micro tabular-nums text-muted-foreground"
onClick={onZoomReset}
aria-label={t('contextPanel.browser.zoomReset')}
>
{zoomPercent}%
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{t('contextPanel.browser.zoomReset')}</TooltipContent>
</Tooltip>
) : null}
<ToolbarButton icon="add" label={t('contextPanel.browser.zoomIn')} onClick={onZoomIn} />
</div>
) : null}
{onClearCookies ? (
<ToolbarButton icon="delete-bin" label={t('contextPanel.browser.clearCookies')} onClick={onClearCookies} />
) : null}
{onClearCache ? (
<ToolbarButton icon="database-2" label={t('contextPanel.browser.clearCache')} onClick={onClearCache} />
) : null}
{onToggleDeviceBar ? (
<ToolbarButton
icon="smartphone"
label={t('contextPanel.browser.deviceToolbar')}
onClick={onToggleDeviceBar}
pressed={isDeviceBarOpen}
/>
) : null}
{onAnnotate ? (
<ToolbarButton
icon="markup"
label={t('contextPanel.browser.annotate.toggle')}
onClick={onAnnotate}
pressed={isAnnotating}
/>
) : null}
{onOpenDevTools ? (
<ToolbarButton icon="terminal-box" label={t('contextPanel.browser.devTools')} onClick={onOpenDevTools} />
) : null}
<ToolbarButton icon="external-link" label={t('contextPanel.browser.openExternal')} onClick={onOpenExternal} />
</div>
);
};
@@ -0,0 +1,71 @@
import React from 'react';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { formatBrowserAnnotationPrompt } from '@/lib/browser/annotationPrompt';
import type { AnnotationSessionResult } from '@/lib/browser/annotationSession';
import type { BrowserAnnotationOverlayLabels } from '@/lib/browser/annotationOverlay';
/**
* Attaches a finished annotation to the active composer.
*
* The screenshot is attached before the text so that a failed image upload
* cannot leave a prompt claiming an attachment that never arrived the text
* states what actually happened.
*/
export const useAnnotationAttach = (directory: string) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft);
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
return React.useCallback(async (result: AnnotationSessionResult): Promise<void> => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!sessionKey) {
toast.error(t('contextPanel.browser.annotate.noSession'));
return;
}
let screenshotAttached = false;
if (result.screenshot) {
try {
await addAttachedFile(result.screenshot);
screenshotAttached = true;
} catch {
screenshotAttached = false;
}
}
addInlineCommentDraft({ directory, sessionKey }, {
source: 'preview-annotation',
fileLabel: result.payload.pageUrl || 'browser',
startLine: 1,
endLine: 1,
code: formatBrowserAnnotationPrompt({
payload: result.payload,
screenshotAttached,
intro: t('contextPanel.browser.annotate.intro'),
}),
language: 'markdown',
text: '',
});
toast.success(t('contextPanel.browser.annotate.attached'));
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, directory, newSessionDraftOpen, t]);
};
/** Overlay labels, resolved through i18n so the in-page UI follows the app locale. */
export const useAnnotationOverlayLabels = (): BrowserAnnotationOverlayLabels => {
const { t } = useI18n();
return React.useMemo(() => ({
select: t('contextPanel.browser.annotate.tool.element'),
marquee: t('contextPanel.browser.annotate.tool.region'),
draw: t('contextPanel.browser.annotate.tool.draw'),
commentPlaceholder: t('contextPanel.browser.annotate.commentPlaceholder'),
submit: t('contextPanel.browser.annotate.submit'),
}), [t]);
};
@@ -0,0 +1,237 @@
import React from 'react';
import { IDLE_NAV_STATUS, type BrowserNavStatus } from '@/lib/browser/contract';
import { useBrowserFaviconStore } from '@/stores/useBrowserFaviconStore';
import {
INITIAL_CRASH_RECOVERY_STATE,
planCrashRecovery,
type CrashRecoveryState,
} from '@/lib/browser/crashRecovery';
/**
* Translates `<webview>` lifecycle events into a single navigation status.
*
* Chromium reports failures and successes through separate events that can
* arrive in either order, and it emits `did-fail-load` for sub-resources as
* well as for the main frame. Both are handled here so the panel only ever
* sees one authoritative state:
*
* - Sub-frame failures are ignored; only the main frame changes the status.
* - `ERR_ABORTED` is not a failure. It is what Chromium reports when a
* navigation is superseded by the next one, and treating it as an error puts
* an error screen over a page that is loading perfectly well.
*
* Takes the element rather than a ref so the listeners attach when the view
* actually appears. A ref is stable, so an effect keyed on it runs once and
* silently attaches nothing at all when the view mounts a render later.
*
* `<webview>` puts its event payload directly on the event object rather than
* under `detail`, so reading `detail` yields a failure with no code and no
* description an error screen that says nothing. Both shapes are read here.
*
* A lost renderer is handled here too. It is reported by neither of the above:
* the page simply stops existing, and the panel would otherwise stay blank with
* no indication that anything happened.
*/
/** Chromium's code for "this navigation was replaced by another one". */
const ERR_ABORTED = -3;
/**
* `about:blank` is the view's resting state, not somewhere the user went. It
* arrives through `did-navigate` like any other address, and taking it at face
* value puts it in the address bar and makes the panel look like it is showing
* a page which hides the empty state that would otherwise offer somewhere to
* go.
*/
const isRealPageUrl = (url: unknown): url is string => (
typeof url === 'string' && url.length > 0 && url !== 'about:blank'
);
type FailLoadDetail = {
errorCode?: number;
errorDescription?: string;
validatedURL?: string;
isMainFrame?: boolean;
};
/** Reads a webview event payload, whichever shape this Electron version uses. */
export const readEventPayload = <T extends object>(event: Event): Partial<T> => {
const record = event as unknown as { detail?: unknown };
if (record.detail && typeof record.detail === 'object') return record.detail as Partial<T>;
return event as unknown as Partial<T>;
};
export type WebviewNavigation = {
readonly status: BrowserNavStatus;
readonly url: string;
readonly title: string;
readonly canGoBack: boolean;
readonly canGoForward: boolean;
};
export const useWebviewNavigation = (
webview: WebviewElement | null,
{ initialUrl, onUrlChange }: { initialUrl: string; onUrlChange: (url: string) => void },
): WebviewNavigation => {
const [status, setStatus] = React.useState<BrowserNavStatus>(
initialUrl ? { kind: 'loading', url: initialUrl } : IDLE_NAV_STATUS,
);
const [url, setUrl] = React.useState(initialUrl);
const [title, setTitle] = React.useState('');
const [canGoBack, setCanGoBack] = React.useState(false);
const [canGoForward, setCanGoForward] = React.useState(false);
const urlChangeRef = React.useRef(onUrlChange);
urlChangeRef.current = onUrlChange;
// Survives re-attaches so a view that keeps crashing cannot restart its own
// budget by being remounted.
const crashStateRef = React.useRef<CrashRecoveryState>(INITIAL_CRASH_RECOVERY_STATE);
React.useEffect(() => {
if (!webview) return;
const readCurrentUrl = (): string => {
try {
const value = webview.getURL();
return isRealPageUrl(value) ? value : '';
} catch {
return '';
}
};
const syncHistory = () => {
try {
setCanGoBack(webview.canGoBack());
setCanGoForward(webview.canGoForward());
} catch {
// Webview not attached yet; the next event resyncs.
}
};
const commitUrl = (next: string) => {
if (!isRealPageUrl(next)) return;
setUrl(next);
urlChangeRef.current(next);
};
const onStartLoading = () => {
const current = readCurrentUrl();
setStatus({ kind: 'loading', url: current });
};
const onStopLoading = () => {
const current = readCurrentUrl();
let pageTitle = '';
try {
pageTitle = webview.getTitle() || '';
} catch {
pageTitle = '';
}
setTitle(pageTitle);
commitUrl(current);
syncHistory();
// A failure already produced a terminal status; do not overwrite it with
// the `did-stop-loading` that always follows.
setStatus((previous) => (
previous.kind === 'failed' && previous.url === current
? previous
: { kind: 'ready', url: current, title: pageTitle }
));
};
const onNavigate = (event: Event) => {
const detail = readEventPayload<{ url?: string }>(event);
if (isRealPageUrl(detail.url)) {
commitUrl(detail.url);
syncHistory();
}
};
const onFaviconUpdated = (event: Event) => {
const detail = readEventPayload<{ favicons?: string[] }>(event);
const icon = Array.isArray(detail.favicons) ? detail.favicons.find(Boolean) : '';
const page = readCurrentUrl();
if (icon && page) useBrowserFaviconStore.getState().resolve(page, icon);
};
const onTitleUpdated = (event: Event) => {
const detail = readEventPayload<{ title?: string }>(event);
if (typeof detail.title === 'string') setTitle(detail.title);
};
const onFailLoad = (event: Event) => {
const detail = readEventPayload<FailLoadDetail>(event);
if (detail.isMainFrame === false) return;
const code = typeof detail.errorCode === 'number' ? detail.errorCode : 0;
if (code === ERR_ABORTED) return;
setStatus({
kind: 'failed',
url: isRealPageUrl(detail.validatedURL) ? detail.validatedURL : readCurrentUrl(),
code,
description: typeof detail.errorDescription === 'string' ? detail.errorDescription : '',
});
};
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
const onCrashed = () => {
const target = readCurrentUrl();
const plan = planCrashRecovery(crashStateRef.current, Date.now());
if (!plan) {
// Out of attempts: say what happened rather than reload again. The
// toolbar's own reload stays available, which is the user's call.
setStatus({ kind: 'failed', url: target, code: 0, description: '', crashed: true });
return;
}
crashStateRef.current = plan.state;
setStatus({ kind: 'loading', url: target });
recoveryTimer = setTimeout(() => {
recoveryTimer = null;
try {
webview.reload();
} catch {
setStatus({ kind: 'failed', url: target, code: 0, description: '', crashed: true });
}
}, plan.delayMs);
};
webview.addEventListener('did-start-loading', onStartLoading);
webview.addEventListener('did-stop-loading', onStopLoading);
webview.addEventListener('did-navigate', onNavigate);
webview.addEventListener('did-navigate-in-page', onNavigate);
webview.addEventListener('page-title-updated', onTitleUpdated);
webview.addEventListener('page-favicon-updated', onFaviconUpdated);
webview.addEventListener('did-fail-load', onFailLoad);
// Electron renamed this event; older builds still emit only the old name.
webview.addEventListener('render-process-gone', onCrashed);
webview.addEventListener('crashed', onCrashed);
// The webview may already be settled by the time this effect runs. Only
// treat it as settled when a page is actually loaded: a freshly created
// view reports "not loading" before its guest attaches, and settling on
// that would declare an empty page ready and hide the real one behind an
// empty state.
try {
if (!webview.isLoading() && readCurrentUrl()) onStopLoading();
} catch {
// Not attached yet.
}
return () => {
if (recoveryTimer !== null) clearTimeout(recoveryTimer);
webview.removeEventListener('render-process-gone', onCrashed);
webview.removeEventListener('crashed', onCrashed);
webview.removeEventListener('did-start-loading', onStartLoading);
webview.removeEventListener('did-stop-loading', onStopLoading);
webview.removeEventListener('did-navigate', onNavigate);
webview.removeEventListener('did-navigate-in-page', onNavigate);
webview.removeEventListener('page-title-updated', onTitleUpdated);
webview.removeEventListener('page-favicon-updated', onFaviconUpdated);
webview.removeEventListener('did-fail-load', onFailLoad);
};
}, [webview]);
return { status, url, title, canGoBack, canGoForward };
};
@@ -0,0 +1,46 @@
import React from 'react';
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '@/lib/i18n';
mock.module('@/components/ui/dialog', () => ({
Dialog: ({ children }: React.PropsWithChildren) => <>{children}</>,
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogDescription: ({ children }: React.PropsWithChildren) => <p>{children}</p>,
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogTitle: ({ children }: React.PropsWithChildren) => <h2>{children}</h2>,
}));
const { AppLinkConfirmDialog } = await import('./AppLinkConfirmDialog');
const {
getAppLinkConfirmationSnapshot,
openAppLinkWithConfirmation,
settleAppLinkConfirmation,
} = await import('./appLinkConfirmation');
describe('AppLinkConfirmDialog', () => {
beforeEach(() => {
if (getAppLinkConfirmationSnapshot()) {
settleAppLinkConfirmation('cancel');
}
});
test('keeps cancel visible and focused beside both open choices', () => {
void openAppLinkWithConfirmation('obsidian://open?vault=Notebook');
const markup = renderToStaticMarkup(
<I18nProvider>
<AppLinkConfirmDialog />
</I18nProvider>,
);
expect(markup).toContain('>Cancel</button>');
expect(markup).toContain('autofocus=""');
expect(markup).toContain('>Open once</button>');
expect(markup).toContain('>Trust and open</button>');
settleAppLinkConfirmation('cancel');
});
});
@@ -0,0 +1,77 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useI18n } from '@/lib/i18n';
import { getUrlScheme } from '@/lib/url';
import {
getAppLinkConfirmationSnapshot,
settleAppLinkConfirmation,
subscribeAppLinkConfirmation,
type AppLinkConfirmationChoice,
} from './appLinkConfirmation';
/**
* App-level dialog confirming application deep links (obsidian://, vscode://,
* ...) rendered in chat markdown before the OS is asked to open them.
* Dismissing via the close button, Escape, or the backdrop cancels the open.
*/
export const AppLinkConfirmDialog = () => {
const { t } = useI18n();
const request = React.useSyncExternalStore(
subscribeAppLinkConfirmation,
getAppLinkConfirmationSnapshot,
getAppLinkConfirmationSnapshot,
);
const url = request?.url ?? '';
const scheme = getUrlScheme(url) ?? '';
const settle = React.useCallback((choice: AppLinkConfirmationChoice) => {
settleAppLinkConfirmation(choice);
}, []);
return (
<Dialog
open={Boolean(request)}
onOpenChange={(open: boolean) => {
if (!open) {
settle('cancel');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('chat.appLink.confirm.title')}</DialogTitle>
<DialogDescription>
{scheme
? t('chat.appLink.confirm.description', { scheme: `${scheme}://` })
: t('chat.appLink.confirm.descriptionPlain')}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg bg-[var(--surface-muted)] px-3 py-2 text-[13px] leading-relaxed break-all text-[var(--surface-foreground)]">
{url}
</div>
<DialogFooter>
<Button variant="ghost" autoFocus onClick={() => settle('cancel')}>
{t('chat.appLink.confirm.cancel')}
</Button>
<Button variant="outline" onClick={() => settle('trust')}>
{t('chat.appLink.confirm.trustAndOpen')}
</Button>
<Button variant="default" onClick={() => settle('open')}>
{t('chat.appLink.confirm.open')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15 -207
View File
@@ -12,8 +12,8 @@ import { useSelectionStore } from '@/sync/selection-store';
import { useDeviceInfo } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import MessageBody from './message/MessageBody';
import type { AgentMentionInfo } from './message/types';
import type { StreamPhase, ToolPopupContent } from './message/types';
@@ -21,7 +21,7 @@ import { deriveMessageRole } from './message/messageRole';
import { filterVisibleParts, normalizeParts } from './message/partUtils';
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
import { isHiddenUserMessage } from './message/hiddenUserMessage';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import { flattenAssistantTextParts, flattenUserTextParts } from '@/lib/messages/messageText';
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
@@ -37,6 +37,7 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages';
import { setContextObligatoryMessage } from '@/sync/session-actions';
import { isVSCodeRuntime } from '@/lib/desktop';
import { focusChatInput } from './composer/editor/dom';
const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog'));
@@ -130,8 +131,6 @@ interface ChatMessageProps {
info: Message;
parts: Part[];
};
onContentChange?: (reason?: ContentChangeReason) => void;
animationHandlers?: AnimationHandlers;
scrollToBottom?: () => void;
turnGroupingContext?: TurnGroupingContext;
assistantHeaderMessageId?: string;
@@ -146,8 +145,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
message,
previousMessage,
nextMessage,
onContentChange,
animationHandlers,
turnGroupingContext,
assistantHeaderMessageId,
isInActiveTurn = false,
@@ -201,6 +198,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
const isUser = messageRole.isUser;
const chatSurfaceMode = useChatSurfaceMode();
const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader);
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
@@ -416,6 +414,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
createdAt: messageCreatedAt,
role: isUser ? 'user' : 'assistant',
}, !isPinnedIntoContext);
// Return focus to the composer so the user can keep typing right
// after adding the message to context (matches the refocus pattern
// used by the model/agent selectors).
requestAnimationFrame(focusChatInput);
} catch (error) {
console.error('[chat-message] failed to update context pin', error);
toast.error(t('chat.messageBody.actions.contextPinFailed'));
@@ -455,13 +457,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}, [chatRenderMode, isMessageCompleted, isUser, visibleParts]);
const assistantTextParts = React.useMemo(() => {
if (isUser) {
return [];
}
return visibleParts.filter((part) => part.type === 'text');
}, [isUser, visibleParts]);
const toolParts = React.useMemo(() => {
if (isUser) {
return [];
@@ -543,19 +538,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const shouldHideUserMessage = isUser && displayParts.length === 0;
// Message is considered to have an "open step" if info.finish is not yet present
const hasOpenStep = typeof messageFinish !== 'string';
const shouldCoordinateRendering = React.useMemo(() => {
if (isUser) {
return false;
}
if (assistantTextParts.length === 0 || toolParts.length === 0) {
return hasOpenStep;
}
return true;
}, [assistantTextParts.length, toolParts.length, hasOpenStep, isUser]);
const themeVariant = currentTheme?.metadata.variant;
const isDarkTheme = React.useMemo(() => {
if (themeVariant) {
@@ -698,67 +680,29 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}
if (errorName === 'SessionRetry') {
return {
text: `Opencode failed to send a message. Retry attempt info: \n\`${detail}\``,
variant: 'info' as const,
text: `Opencode failed to send a message. Retry attempt info: ${detail}`,
};
}
if (isLikelyProviderAuthFailure(detail)) {
return {
text: PROVIDER_AUTH_FAILURE_MESSAGE,
variant: 'error' as const,
};
}
if (detail.trim().toLowerCase() === 'aborted') {
return {
text: 'The running turn was stopped before OpenCode could send the next message.',
variant: 'info' as const,
};
}
return {
text: `Opencode failed to send message with error:\n\`${detail}\``,
variant: 'error' as const,
text: `Opencode failed to send message with error: ${detail}`,
};
}, [isUser, message.info]);
const assistantErrorText = assistantError?.text;
const assistantErrorVariant = assistantError?.variant;
const messageTextContent = React.useMemo(() => {
if (isUser) {
const shellOutputs = displayParts
.filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text')
.map((part) => {
const output = part.shellAction?.output;
return typeof output === 'string' ? output.trim() : '';
})
.filter((output) => output.length > 0);
if (shellOutputs.length > 0) {
return shellOutputs.join('\n\n');
}
const shellCommands = displayParts
.filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text')
.map((part) => {
const command = part.shellAction?.command;
return typeof command === 'string' ? command.trim() : '';
})
.filter((command) => command.length > 0);
if (shellCommands.length > 0) {
return shellCommands.join('\n');
}
const textParts = displayParts
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
.map((part) => {
const text = part.text || part.content || '';
return text.trim();
})
.filter((text) => text.length > 0);
const combined = textParts.join('\n');
return combined.replace(/\n\s*\n+/g, '\n');
return flattenUserTextParts(displayParts);
}
if (assistantErrorText && assistantErrorText.trim().length > 0) {
@@ -848,35 +792,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
});
}, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]);
const resolvedAnimationHandlers = animationHandlers ?? null;
const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
const animationCompletedRef = React.useRef(false);
const hasRequestedReservationRef = React.useRef(false);
const animationStartNotifiedRef = React.useRef(false);
const hasTriggeredReservationOnceRef = React.useRef(false);
const hasEverStreamedRef = React.useRef(false);
React.useEffect(() => {
animationCompletedRef.current = false;
hasRequestedReservationRef.current = false;
animationStartNotifiedRef.current = false;
hasTriggeredReservationOnceRef.current = false;
hasAnnouncedAuxiliaryScrollRef.current = false;
hasEverStreamedRef.current = false;
}, [message.info.id]);
const handleAuxiliaryContentComplete = React.useCallback(() => {
if (isUser) {
return;
}
if (hasAnnouncedAuxiliaryScrollRef.current) {
return;
}
hasAnnouncedAuxiliaryScrollRef.current = true;
onContentChange?.('structural');
}, [isUser, onContentChange]);
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
@@ -899,114 +820,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
hasEverStreamedRef.current = true;
}
const hasReasoningParts = React.useMemo(() => {
if (isUser) {
return false;
}
return visibleParts.some((part) => part.type === 'reasoning');
}, [isUser, visibleParts]);
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current;
const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering;
React.useEffect(() => {
if (!resolvedAnimationHandlers?.onStreamingCandidate) {
return;
}
if (!shouldReserveAnimationSpace) {
if (hasRequestedReservationRef.current) {
if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) {
resolvedAnimationHandlers.onReasoningBlock();
} else if (resolvedAnimationHandlers?.onReservationCancelled) {
resolvedAnimationHandlers.onReservationCancelled();
}
hasRequestedReservationRef.current = false;
}
return;
}
if (hasTriggeredReservationOnceRef.current) {
return;
}
hasTriggeredReservationOnceRef.current = true;
resolvedAnimationHandlers.onStreamingCandidate();
hasRequestedReservationRef.current = true;
}, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]);
React.useEffect(() => {
if (!resolvedAnimationHandlers?.onAnimationStart) {
return;
}
if (!allowAnimation) {
return;
}
if (animationStartNotifiedRef.current) {
return;
}
resolvedAnimationHandlers.onAnimationStart();
animationStartNotifiedRef.current = true;
}, [resolvedAnimationHandlers, allowAnimation]);
React.useEffect(() => {
if (isUser) {
return;
}
const handler = resolvedAnimationHandlers?.onAnimatedHeightChange;
if (!handler) {
return;
}
const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace;
if (!shouldTrackHeight) {
return;
}
const element = messageContainerRef.current;
if (!element) {
return;
}
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
handler(element.getBoundingClientRect().height);
return;
}
let rafId: number | null = null;
const notifyHeight = (height: number) => {
if (typeof window === 'undefined') {
handler(height);
return;
}
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(() => {
handler(height);
});
};
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
notifyHeight(entry.contentRect.height);
});
observer.observe(element);
notifyHeight(element.getBoundingClientRect().height);
return () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
observer.disconnect();
};
}, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
if (shouldHideUserMessage) {
return null;
@@ -1039,7 +853,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
respectReducedMotion
>
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined)}>
{/* peek: the action row under the bubble is suppressed, so
reserve its gap to the next message here, OUTSIDE the
bubble background. */}
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined, chatSurfaceMode === 'peek' ? 'pb-3' : undefined)}>
<div
style={{
backgroundColor: 'var(--chat-user-message-bg)',
@@ -1065,13 +882,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
@@ -1079,7 +894,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
contextPinPending={pinPending}
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
errorMessage={assistantErrorText}
errorVariant={assistantErrorVariant}
userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'}
stickyUserHeaderEnabled={stickyUserHeader}
/>
@@ -1102,13 +916,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
@@ -1116,7 +928,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
contextPinPending={pinPending}
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
errorMessage={assistantErrorText}
errorVariant={assistantErrorVariant}
userActionsMode="external-actions"
stickyUserHeaderEnabled={stickyUserHeader}
/>
@@ -1149,17 +960,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={shouldShowHeader}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
showReasoningTraces={showReasoningTraces}
agentMention={agentMention}
turnGroupingContext={turnGroupingContext}
errorMessage={assistantErrorText}
errorVariant={assistantErrorVariant}
reviewTransferDirection={reviewTransferDirection}
footerProviderID={headerProviderID}
footerModelName={headerModelName}
@@ -1,7 +1,6 @@
import React from 'react';
import { cn, fuzzyMatch } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessages } from '@/sync/sync-context';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -11,6 +10,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
type CommandSource = 'openchamber' | 'opencode' | 'skill';
@@ -65,8 +65,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
}, ref) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionMessages = useSessionMessages(currentSessionId ?? '');
const hasMessagesInCurrentSession = sessionMessages.length > 0;
const hasSession = Boolean(currentSessionId);
const hasNewSessionDraft = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const canStartSessionCommand = hasSession || hasNewSessionDraft;
@@ -84,7 +82,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const keyboardNavigationRef = React.useRef(false);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
const ignoreClickRef = React.useRef(false);
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
const pointerMovedRef = React.useRef(false);
@@ -139,7 +137,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
}));
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
...(hasSession
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
: []
),
@@ -152,6 +150,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
@@ -195,10 +197,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
];
const allCommands = mergeCommandAutocompleteItems(builtInCommands, customCommands, skillCommands);
const allowInitCommand = !hasMessagesInCurrentSession;
const filtered = (searchQuery
const filtered = searchQuery
? allCommands.filter(cmd => commandMatchesSearch(cmd, searchQuery))
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
: allCommands;
filtered.sort((a, b) => {
const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase());
@@ -211,9 +212,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
setCommands(filtered);
} catch {
const allowInitCommand = !hasMessagesInCurrentSession;
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
...(hasSession
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
: []
),
@@ -226,6 +226,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
@@ -268,12 +272,12 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
),
];
const filtered = (searchQuery
const filtered = searchQuery
? builtInCommands.filter(cmd =>
fuzzyMatch(cmd.name, searchQuery) ||
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
)
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
: builtInCommands;
setCommands(filtered);
} finally {
@@ -282,7 +286,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
};
loadCommands();
}, [searchQuery, hasMessagesInCurrentSession, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
}, [searchQuery, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
React.useEffect(() => {
setSelectedIndex(0);
@@ -376,6 +380,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const isSystem = command.isBuiltIn;
const isOpenChamberBadge = command.isOpenChamber;
return (
<AutocompleteRowTooltip description={command.description} active={!isMobile && index === selectedIndex}>
<div
key={command.id}
ref={(el) => { itemRefs.current[index] = el; }}
@@ -471,13 +476,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
</span>
)}
</div>
{command.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{command.description}
</div>
)}
</div>
</div>
</AutocompleteRowTooltip>
);
})}
{commands.length === 0 && (
@@ -0,0 +1,294 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { cn } from "@/lib/utils";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
import { useUIStore } from "@/stores/useUIStore";
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Icon } from "@/components/icon/Icon";
import { useI18n } from "@/lib/i18n";
// The bar that sits in the composer stack: pending-changes accessory, abort
// status, and the todos dropdown. Deliberately a separate component from
// StatusRow — that one is the floating assistant-status chip above the
// composer, and sharing markup meant every restyle of the chip (glass,
// placement) silently restyled this bar and its dropdown too.
type TodoItem = Todo & { id?: string };
const COMPOSER_STATUS_BAR_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "composer-status-bar" };
const statusConfig = {
in_progress: { textClassName: "text-foreground" },
pending: { textClassName: "text-foreground" },
completed: { textClassName: "text-muted-foreground line-through" },
cancelled: { textClassName: "text-muted-foreground line-through" },
};
const priorityClassName = {
high: "text-[var(--status-warning)]",
medium: "text-muted-foreground",
low: "text-muted-foreground/70",
};
const priorityIcon = {
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true" />,
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true" />,
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true" />,
};
const statusLabelKey = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabelKey = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
// SAFETY: todo.status / todo.priority arrive from the SDK as open strings;
// lookups treat them as candidate keys and every call site falls back to a
// default entry when the value is outside the known set.
const knownStatus = (status: string) =>
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
status as keyof typeof statusConfig;
const knownPriority = (priority: string) =>
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
priority as keyof typeof priorityClassName;
const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[knownStatus(todo.status)] || statusConfig.pending;
// SAFETY: the label keys are literal members of the i18n dictionary; the
// lookup narrows an open SDK string with a known fallback, and t() accepts
// only the generated key union.
const statusKey = (statusLabelKey[knownStatus(todo.status)] ?? statusLabelKey.pending) as Parameters<typeof t>[0];
// SAFETY: same literal-member narrowing as statusKey above.
const priorityKey = (priorityLabelKey[knownPriority(todo.priority)] ?? priorityLabelKey.medium) as Parameters<typeof t>[0];
const statusIcon =
todo.status === "in_progress" ? (
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true" />
) : todo.status === "completed" ? (
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true" />
) : (
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
);
return (
<div className="flex items-center min-w-0 py-0.5 gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{t(statusKey)}
</TooltipContent>
</Tooltip>
<span className={cn("flex-1 typography-ui-label", config.textClassName)}>
{todo.content}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
priorityClassName[knownPriority(todo.priority)] ?? priorityClassName.medium,
)}
>
{priorityIcon[knownPriority(todo.priority)] ?? priorityIcon.medium}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{t(priorityKey)}
</TooltipContent>
</Tooltip>
</div>
);
};
const EMPTY_TODOS: TodoItem[] = [];
interface ComposerStatusBarProps {
showTodos?: boolean;
leftAccessory?: React.ReactNode;
}
export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
showTodos = true,
leftAccessory,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const liveTodos = useDirectorySync(
React.useCallback(
(state) => {
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
return state.todo[currentSessionId] ?? EMPTY_TODOS;
},
[currentSessionId, showTodos],
),
);
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (showTodos && currentSessionId && currentSessionDirectory
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
: undefined),
[currentSessionDirectory, currentSessionId, showTodos],
),
);
const todos: TodoItem[] = React.useMemo(() => {
if (!currentSessionId) return EMPTY_TODOS;
if (liveTodos.length > 0) return liveTodos;
return persistedSessionTodos ?? EMPTY_TODOS;
}, [liveTodos, persistedSessionTodos, currentSessionId]);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
const visibleTodos = React.useMemo(() => {
return todos.filter((todo) => todo.status !== "cancelled");
}, [todos]);
const activeTodo = React.useMemo(() => {
return (
visibleTodos.find((todo) => todo.status === "in_progress") ||
visibleTodos.find((todo) => todo.status === "pending") ||
null
);
}, [visibleTodos]);
const progress = React.useMemo(() => {
const total = todos.filter((todo) => todo.status !== "cancelled").length;
const completed = todos.filter((todo) => todo.status === "completed").length;
return { completed, total };
}, [todos]);
const statusSummary = React.useMemo(() => {
const active = visibleTodos.filter((todo) => todo.status === "in_progress").length;
const left = visibleTodos.filter((todo) => todo.status === "in_progress" || todo.status === "pending").length;
return { active, left };
}, [visibleTodos]);
const hasTodoContent = showTodos && statusSummary.left > 0;
const hasLeftAccessory = Boolean(leftAccessory);
const hasContent = hasTodoContent || hasLeftAccessory;
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
// SAFETY: mousedown targets are DOM nodes; contains() only needs Node.
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
active: statusSummary.active,
left: statusSummary.left,
});
const todoTrigger = hasTodoContent ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
aria-label={todoSummaryLabel}
title={todoSummaryLabel}
>
{!isCompact && activeTodo ? (
<span className="composer-status-bar__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
<span className="flex items-center gap-0.5">
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
{statusSummary.active}
</span>
<span>·</span>
<span className="flex items-center gap-0.5">
<Icon name="time" className="h-3.5 w-3.5" />
{statusSummary.left}
</span>
</span>
{isExpanded ? (
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
) : (
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
)}
</button>
) : null;
if (!hasContent) {
return null;
}
return (
<div className="mb-2" style={COMPOSER_STATUS_BAR_CONTAINER_STYLE}>
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
{/* Left: abort status | pending-changes accessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{leftAccessory ?? null}
</div>
{/* Right: todos dropdown */}
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory && "pr-1.5")} ref={popoverRef}>
{todoTrigger}
{isExpanded && hasTodoContent && (
<div
style={{
maxWidth: "min(28rem, calc(100cqw - 4ch))",
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
}}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px] rounded-xl p-1",
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150",
)}
>
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
<div className="px-1 max-h-[200px] overflow-y-auto">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
+29 -13
View File
@@ -2,18 +2,30 @@ import React from 'react';
import { cn } from '@/lib/utils';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
import {
useWorkerHighlightedLines,
type WorkerHighlightedLinesResult,
} from '@/components/code/useWorkerHighlightedLines';
import { parseDiffToUnified } from './message/toolRenderers';
// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls
// back to plain text while loading or on failure.
const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) =>
html !== undefined ? (
<span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<span className="whitespace-pre-wrap break-all">{content}</span>
);
// Keep the line's layout stable while a cold worker request finishes. Plain
// text appears only if highlighting fails, avoiding a visible color flash.
interface CodeLineContentProps {
content: string;
html: string | undefined;
status: WorkerHighlightedLinesResult['status'];
}
const CodeLineContent: React.FC<CodeLineContentProps> = ({ content, html, status }) => {
if (status === 'ready' && html !== undefined) {
return <span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />;
}
if (status === 'loading') {
return <span aria-hidden className="invisible whitespace-pre-wrap break-all">{content}</span>;
}
return <span className="whitespace-pre-wrap break-all">{content}</span>;
};
interface DiffPreviewProps {
diff: string;
@@ -44,7 +56,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
<div>
{hunk.lines.map((line, lineIdx) => {
const html = highlighted?.[lineCursor];
const html = highlighted.lines?.[lineCursor];
lineCursor += 1;
return (
<div
@@ -67,7 +79,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
{line.lineNumber || ''}
</span>
<div className="flex-1 min-w-0">
<CodeLineContent content={line.content} html={html} />
<CodeLineContent content={line.content} html={html} status={highlighted.status} />
</div>
</div>
);
@@ -106,7 +118,11 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, filePath })
{lineIdx + 1}
</span>
<div className="flex-1 min-w-0">
<CodeLineContent content={line || ' '} html={highlighted?.[lineIdx]} />
<CodeLineContent
content={line || ' '}
html={highlighted.lines?.[lineIdx]}
status={highlighted.status}
/>
</div>
</div>
))}
@@ -2,6 +2,7 @@ import React, { useRef, memo } from 'react';
import { useInputStore } from '@/sync/input-store';
import type { AttachedFile } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
@@ -833,7 +834,10 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
<button
type="button"
onClick={() => {
useUIStore.getState().navigateToDiagram(filePath);
const directory = useDirectoryStore.getState().currentDirectory;
if (directory) {
useUIStore.getState().openContextFile(directory, filePath);
}
}}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left cursor-pointer",
@@ -14,6 +14,9 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { mentionServerQuery, rankFileMentionResults } from './fileMentionResults';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
type FileInfo = ProjectFileSearchHit;
type AgentInfo = {
@@ -80,7 +83,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
const normalizedSearchQuery = (searchQuery ?? '').trim();
const recentFiles = React.useMemo(() => {
if (!projectRoot || !projectTabs) {
@@ -93,14 +96,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
].filter((value): value is string => typeof value === 'string' && value.length > 0);
const seen = new Set<string>();
const queryLower = normalizedSearchQuery.toLowerCase();
const mapped = ordered
.filter((filePath) => {
if (seen.has(filePath)) return false;
seen.add(filePath);
const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath;
if (!queryLower) return true;
return relative.toLowerCase().includes(queryLower);
return matchesRankQuery([relative], normalizedSearchQuery);
})
.slice(0, 6)
.map((filePath) => {
@@ -123,9 +124,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
() => normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2),
[agents, normalizedSearchQuery.length],
);
const visibleDirectories = directories;
const visibleRecentFiles = recentFiles;
const visibleFiles = files;
const visibleResults = React.useMemo(
() => rankFileMentionResults(files, directories, normalizedSearchQuery, 20),
[files, directories, normalizedSearchQuery],
);
React.useEffect(() => {
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
@@ -151,13 +154,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return;
}
const normalizedQuery = (debouncedQuery ?? '').trim();
const normalizedQueryLower = normalizedQuery
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase();
const serverQuery = mentionServerQuery(debouncedQuery ?? '');
if (!normalizedQueryLower) {
if (!serverQuery) {
setFiles([]);
return;
}
@@ -166,7 +165,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
pendingSearchRef.current++;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 80, {
searchFiles(currentDirectory, serverQuery, 80, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
type: 'file',
@@ -177,7 +176,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
const recentSet = new Set(recentFiles.map((file) => file.path));
setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15));
setFiles(hits.filter((hit) => !recentSet.has(hit.path)));
})
.catch(() => {
if (!cancelled) {
@@ -209,13 +208,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return;
}
const normalizedQuery = (debouncedQuery ?? '').trim();
const normalizedQueryLower = normalizedQuery
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase();
const serverQuery = mentionServerQuery(debouncedQuery ?? '');
if (!normalizedQueryLower) {
if (!serverQuery) {
setDirectories([]);
return;
}
@@ -224,14 +219,14 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
pendingSearchRef.current++;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 20, {
searchFiles(currentDirectory, serverQuery, 20, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
type: 'directory',
})
.then((hits) => {
if (!cancelled) {
setDirectories(hits.slice(0, 10));
setDirectories(hits);
}
})
.catch(() => {
@@ -260,28 +255,22 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
React.useEffect(() => {
const visibleAgents = getVisibleAgents();
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
const filtered = visibleAgents
const subagents = visibleAgents
.filter((agent) => agent.mode && agent.mode !== 'primary')
.filter((agent) => {
if (!normalizedQuery) return true;
const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase();
return haystack.includes(normalizedQuery);
})
.map((agent) => ({
name: agent.name,
description: agent.description,
mode: agent.mode,
}))
.sort((a, b) => a.name.localeCompare(b.name));
setAgents(filtered);
setAgents(rankByQuery(subagents, searchQuery ?? '', (agent) => [agent.name, agent.description]));
}, [getVisibleAgents, searchQuery]);
React.useEffect(() => {
setSelectedIndex(0);
setOverflowMap({});
setMarqueeDurations({});
}, [visibleFiles, visibleDirectories, visibleRecentFiles.length, visibleAgents.length]);
}, [visibleResults, visibleRecentFiles.length, visibleAgents.length]);
React.useEffect(() => {
selectedIndexRef.current = selectedIndex;
@@ -331,7 +320,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
window.removeEventListener('resize', updateOverflow);
};
}, [visibleFiles, visibleDirectories]);
}, [visibleResults]);
React.useEffect(() => {
const labelNode = labelRefs.current[selectedIndex];
@@ -375,7 +364,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return;
}
const total = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + visibleFiles.length;
const total = visibleAgents.length + visibleRecentFiles.length + visibleResults.length;
if (total === 0) {
return;
}
@@ -399,24 +388,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
return;
}
const dirIndex = safeIndex - visibleAgents.length;
if (dirIndex < visibleDirectories.length) {
const dir = visibleDirectories[dirIndex];
if (dir) {
handleFileSelect(dir);
}
return;
}
const fileIndex = dirIndex - visibleDirectories.length;
const selectedFile = fileIndex < visibleRecentFiles.length
? visibleRecentFiles[fileIndex]
: visibleFiles[fileIndex - visibleRecentFiles.length];
const recentIndex = safeIndex - visibleAgents.length;
const selectedFile = recentIndex < visibleRecentFiles.length
? visibleRecentFiles[recentIndex]
: visibleResults[recentIndex - visibleRecentFiles.length];
if (selectedFile) {
handleFileSelect(selectedFile);
}
}
}
}), [visibleFiles, visibleDirectories, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]);
}), [visibleResults, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]);
const getFileIcon = (file: FileInfo) => {
const ext = file.extension?.toLowerCase();
@@ -458,6 +439,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
{visibleAgents.map((agent, index) => {
const isSelected = selectedIndex === index;
return (
<AutocompleteRowTooltip description={agent.description} active={!isMobile && isSelected}>
<div
key={`agent-${agent.name}`}
ref={(el) => { itemRefs.current[index] = el; }}
@@ -470,11 +452,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
>
<div className="min-w-0 flex-1">
<div className="font-semibold truncate">@{agent.name}</div>
{agent.description && !isMobile ? (
<div className="typography-meta text-muted-foreground truncate">{agent.description}</div>
) : null}
</div>
</div>
</AutocompleteRowTooltip>
);
})}
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
@@ -482,38 +462,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
{t('chat.fileMentionAutocomplete.searchMoreAgents')}
</div>
)}
{visibleAgents.length > 0 && (visibleDirectories.length > 0 || visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
<div className="my-1 border-t border-border/60" />
)}
{visibleDirectories.map((dir, index) => {
const rowIndex = visibleAgents.length + index;
const relativePath = dir.relativePath || dir.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
return (
<div
key={`dir-${dir.path}`}
ref={(el) => { itemRefs.current[rowIndex] = el; }}
className={cn(
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
isSelected && "bg-interactive-selection"
)}
onClick={() => handleFileSelect(dir)}
onMouseMove={() => setSelectedIndex(rowIndex)}
>
<Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
<span className="flex-1 min-w-0 truncate" aria-label={relativePath}>
{displayPath}
</span>
</div>
);
})}
{visibleDirectories.length > 0 && (visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
{visibleAgents.length > 0 && (visibleRecentFiles.length > 0 || visibleResults.length > 0) && (
<div className="my-1 border-t border-border/60" />
)}
{visibleRecentFiles.map((file, index) => {
const rowIndex = visibleAgents.length + visibleDirectories.length + index;
const rowIndex = visibleAgents.length + index;
const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
@@ -561,11 +514,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
</div>
);
})}
{visibleRecentFiles.length > 0 && visibleFiles.length > 0 && (
{visibleRecentFiles.length > 0 && visibleResults.length > 0 && (
<div className="my-1 border-t border-border/60" />
)}
{visibleFiles.map((file, index) => {
const rowIndex = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + index;
{visibleResults.map((file, index) => {
const rowIndex = visibleAgents.length + visibleRecentFiles.length + index;
const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
@@ -582,7 +535,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
onClick={() => handleFileSelect(file)}
onMouseMove={() => setSelectedIndex(rowIndex)}
>
{getFileIcon(file)}
{file.kind === 'directory'
? <Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
: getFileIcon(file)}
<span
ref={(el) => { labelRefs.current[rowIndex] = el; }}
className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container"
@@ -613,12 +568,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
);
return (
<React.Fragment key={file.path}>
<React.Fragment key={`${file.kind}-${file.path}`}>
{item}
</React.Fragment>
);
})}
{visibleFiles.length === 0 && visibleDirectories.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
{visibleResults.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
{t('chat.fileMentionAutocomplete.empty')}
</div>
@@ -0,0 +1,294 @@
import React from 'react';
import { toast } from 'sonner';
import { Icon } from '@/components/icon/Icon';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useI18n } from '@/lib/i18n';
import {
acquireRuntimeUrlAuthToken,
refreshRuntimeUrlAuthToken,
subscribeRuntimeUrlAuthToken,
} from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { isVSCodeRuntime } from '@/lib/desktop';
import type { ToolPopupContent } from './message/types';
import {
extractMarkdownImageCandidates,
MAX_MARKDOWN_IMAGE_COUNT,
type MarkdownImageCandidate,
} from './markdown/markdownCore';
import {
getPreparedMarkdownImageUrl,
isLocalMarkdownImageSource,
prepareLocalMarkdownImages,
resolveMarkdownImageSource,
resolveWorkspaceMarkdownImageSource,
type PreparedMarkdownImage,
} from './markdown/markdownImageAssets';
const useAssetAuth = (enabled: boolean): { ready: boolean; nonce: number } => {
const [ready, setReady] = React.useState(false);
const [nonce, setNonce] = React.useState(0);
const apiBaseUrl = getRuntimeApiBaseUrl();
React.useEffect(() => {
if (!enabled) {
setReady(false);
return;
}
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
const release = acquireRuntimeUrlAuthToken(apiBaseUrl);
const unsubscribe = subscribeRuntimeUrlAuthToken(() => {
if (!cancelled) setNonce((current) => current + 1);
});
const refresh = () => {
void refreshRuntimeUrlAuthToken(apiBaseUrl)
.then(() => {
if (!cancelled) setReady(true);
})
.catch(() => {
if (!cancelled) retryTimer = setTimeout(refresh, 1000);
});
};
refresh();
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
release();
unsubscribe();
};
}, [apiBaseUrl, enabled]);
return { ready: !enabled || ready, nonce };
};
const MarkdownImageThumbnail: React.FC<{
candidate: MarkdownImageCandidate;
preparation?: PreparedMarkdownImage;
directory: string;
assetAuthReady: boolean;
assetAuthNonce: number;
useWorkspaceFsBridge: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
}> = ({
candidate,
preparation,
directory,
assetAuthReady,
assetAuthNonce,
useWorkspaceFsBridge,
onShowPopup,
}) => {
const { t } = useI18n();
const thumbnailRef = React.useRef<HTMLButtonElement>(null);
const [shouldLoad, setShouldLoad] = React.useState(false);
const [image, setImage] = React.useState<{ url: string; status: 'loading' | 'ready' | 'error' }>({
url: '',
status: 'loading',
});
const local = isLocalMarkdownImageSource(candidate.source);
React.useEffect(() => {
const thumbnail = thumbnailRef.current;
if (!thumbnail || shouldLoad) return;
if (typeof IntersectionObserver === 'undefined') {
setShouldLoad(true);
return;
}
const observer = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
setShouldLoad(true);
observer.disconnect();
}, { rootMargin: '200px' });
observer.observe(thumbnail);
return () => observer.disconnect();
}, [shouldLoad]);
React.useEffect(() => {
if (!shouldLoad || (local && !useWorkspaceFsBridge && !preparation)) return;
if (local && useWorkspaceFsBridge) {
const controller = new AbortController();
setImage({ url: '', status: 'loading' });
void resolveWorkspaceMarkdownImageSource(candidate.source, directory, controller.signal).then((url) => {
if (controller.signal.aborted) return;
setImage({ url, status: 'loading' });
}).catch(() => {
if (controller.signal.aborted) return;
setImage({ url: '', status: 'error' });
});
return () => controller.abort();
}
if (local) {
if (preparation?.status !== 'ready') {
setImage({ url: '', status: 'error' });
return;
}
if (!assetAuthReady) return;
setImage({ url: getPreparedMarkdownImageUrl(preparation, directory), status: 'loading' });
return;
}
const controller = new AbortController();
setImage({ url: '', status: 'loading' });
void resolveMarkdownImageSource(candidate.source, controller.signal).then((url) => {
if (controller.signal.aborted) return;
setImage({ url, status: 'loading' });
}).catch(() => {
if (controller.signal.aborted) return;
setImage({ url: '', status: 'error' });
});
return () => controller.abort();
}, [assetAuthNonce, assetAuthReady, candidate.source, directory, local, preparation, shouldLoad, useWorkspaceFsBridge]);
const openPreview = React.useCallback(() => {
if (image.status === 'error') {
toast.error(t('filesView.error.previewUnavailable'));
return;
}
if (image.status !== 'ready' || !onShowPopup) return;
onShowPopup({
open: true,
title: candidate.filename,
content: '',
metadata: { tool: 'markdown-image-preview', filename: candidate.filename },
image: { url: image.url, filename: candidate.filename },
});
}, [candidate.filename, image, onShowPopup, t]);
return (
<button
ref={thumbnailRef}
type="button"
className="w-[100px] shrink-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
aria-label={candidate.filename}
disabled={image.status === 'loading'}
onClick={openPreview}
data-openchamber-markdown-image-action="true"
data-openchamber-markdown-image-source={candidate.source}
data-openchamber-markdown-image-filename={candidate.filename}
>
<span className="flex h-[72px] w-[100px] items-center justify-center overflow-hidden rounded-lg border border-border/40 bg-muted/10">
{image.url && image.status !== 'error' ? (
<img
src={image.url}
alt={candidate.filename}
className="h-full w-full object-contain"
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onLoad={() => setImage((current) => ({ ...current, status: 'ready' }))}
onError={() => setImage({ url: '', status: 'error' })}
data-openchamber-markdown-image="true"
data-openchamber-markdown-image-thumbnail="true"
data-openchamber-markdown-image-state={image.status}
/>
) : (
<Icon name="file-image" className="h-5 w-5 text-muted-foreground" />
)}
</span>
<span
className="mt-1 flex w-[100px] items-center justify-center gap-1 text-muted-foreground"
title={candidate.filename}
data-openchamber-markdown-image-caption="true"
>
<Icon name="file-image" className="h-3 w-3 shrink-0" />
<span className="min-w-0 truncate typography-meta">{candidate.filename}</span>
</span>
</button>
);
};
export const MarkdownImageGallery: React.FC<{
sessionId?: string;
messageId: string;
contents: readonly string[];
onShowPopup?: (content: ToolPopupContent) => void;
}> = ({ sessionId, messageId, contents, onShowPopup }) => {
const directory = useEffectiveDirectory() ?? '';
const galleryRef = React.useRef<HTMLDivElement>(null);
const [shouldPrepare, setShouldPrepare] = React.useState(false);
const [prepared, setPrepared] = React.useState<Map<string, PreparedMarkdownImage> | null>(null);
const [prepareEpoch, setPrepareEpoch] = React.useState(0);
const useWorkspaceFsBridge = isVSCodeRuntime();
const candidates = React.useMemo(
() => extractMarkdownImageCandidates(contents, MAX_MARKDOWN_IMAGE_COUNT),
[contents],
);
const serverPreparationSources = React.useMemo(
() => useWorkspaceFsBridge
? []
: candidates
.filter((candidate) => isLocalMarkdownImageSource(candidate.source))
.map((candidate) => candidate.source),
[candidates, useWorkspaceFsBridge],
);
React.useEffect(() => {
if (serverPreparationSources.length === 0 || shouldPrepare) return;
const gallery = galleryRef.current;
if (!gallery || typeof IntersectionObserver === 'undefined') {
setShouldPrepare(true);
return;
}
const observer = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
setShouldPrepare(true);
observer.disconnect();
}, { rootMargin: '200px' });
observer.observe(gallery);
return () => observer.disconnect();
}, [serverPreparationSources.length, shouldPrepare]);
React.useEffect(() => {
if (!shouldPrepare || !sessionId || serverPreparationSources.length === 0) return;
const controller = new AbortController();
void prepareLocalMarkdownImages({
sources: serverPreparationSources,
directory,
sessionId,
messageId,
signal: controller.signal,
}).then((result) => {
if (controller.signal.aborted) return;
setPrepared(result);
}).catch(() => {
if (!controller.signal.aborted) {
setPrepared(new Map(serverPreparationSources.map((source) => [source, { status: 'error' }])));
}
});
return () => controller.abort();
}, [directory, messageId, prepareEpoch, serverPreparationSources, sessionId, shouldPrepare]);
React.useEffect(() => {
const nextExpiry = Math.min(...[...(prepared?.values() ?? [])]
.filter((value): value is Extract<PreparedMarkdownImage, { status: 'ready' }> => value.status === 'ready')
.map((value) => value.expiresAt ?? Number.POSITIVE_INFINITY));
if (!Number.isFinite(nextExpiry)) return;
const timer = setTimeout(() => setPrepareEpoch((current) => current + 1), Math.max(0, nextExpiry - Date.now()));
return () => clearTimeout(timer);
}, [prepared]);
const visibleCandidates = candidates.filter((candidate) => prepared?.get(candidate.source)?.status !== 'missing');
const hasPreparedAssets = [...(prepared?.values() ?? [])].some((value) => value.status === 'ready');
const assetAuth = useAssetAuth(hasPreparedAssets);
if (visibleCandidates.length === 0) return null;
return (
<div
ref={galleryRef}
className="mt-3 flex max-w-full gap-2 overflow-x-auto pb-1"
data-openchamber-markdown-image-gallery="true"
>
{visibleCandidates.map((candidate) => (
<MarkdownImageThumbnail
key={candidate.source}
candidate={candidate}
preparation={prepared?.get(candidate.source)}
directory={directory}
assetAuthReady={assetAuth.ready}
assetAuthNonce={assetAuth.nonce}
useWorkspaceFsBridge={useWorkspaceFsBridge}
onShowPopup={onShowPopup}
/>
))}
</div>
);
};
@@ -17,6 +17,10 @@ const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer }))
);
const MarkdownImageGalleryLazy = lazyWithChunkRecovery(() =>
import('./MarkdownImageGallery').then((m) => ({ default: m.MarkdownImageGallery }))
);
const fallback = <div className="break-words w-full min-w-0" />;
const fallbackContentClassName = (variant: unknown): string => {
@@ -43,8 +47,18 @@ export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof Ma
</React.Suspense>
);
export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy>> = (props) => (
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
type SimpleMarkdownRendererProps = React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy> & {
fallbackContent?: React.ReactNode;
};
export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ fallbackContent, ...props }) => (
<React.Suspense fallback={fallbackContent ?? <MobileMarkdownFallback {...props} />}>
<SimpleMarkdownRendererLazy {...props} />
</React.Suspense>
);
export const MarkdownImageGallery: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownImageGalleryLazy>> = (props) => (
<React.Suspense fallback={null}>
<MarkdownImageGalleryLazy {...props} />
</React.Suspense>
);
@@ -0,0 +1,562 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { TextPart } from '@opencode-ai/sdk/v2';
type OperationCounts = {
innerHTMLWrites: number;
spriteIconInnerHTMLWrites: number;
querySelectorAllCalls: number;
appendCalls: number;
replaceCalls: number;
removeCalls: number;
getBoundingClientRectCalls: number;
viewBoxWrites: number;
resizeObserverCreates: number;
resizeObserverObserveCalls: number;
geometrySequence: Array<'read' | 'write'>;
};
type FixtureMetrics = OperationCounts & {
renderers: number;
markdownBlocks: number;
mermaidBlocks: number;
mermaidRenderedCount: number;
mermaidSvgCount: number;
};
const fixture = [
'# Synthetic mount fixture',
'',
'A paragraph with **bold text**, a table, and a stable link.',
'',
'| name | value |',
'| --- | ---: |',
'| alpha | 1 |',
'| beta | 2 |',
'',
'```typescript',
'const answer = 42;',
'console.log(answer);',
'```',
'',
'```mermaid',
'graph TD',
' A[Start] --> B[Finish]',
'```',
'',
'```mermaid',
'graph LR',
' Client[Client] --> Server[Server]',
'```',
].join('\n');
const fixtureWorkload = {
rendererCount: 3,
domBlocksPerRenderer: 1,
mermaidBlocksPerRenderer: 2,
};
let windowInstance: Window;
let previousGlobals: Map<string, PropertyDescriptor | undefined>;
let activeCounts: OperationCounts | null = null;
let animationFrameQueue: FrameRequestCallback[] = [];
let notifyResize: ((entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) | null = null;
let MarkdownRenderer: React.ComponentType<{
content: string;
messageId: string;
part?: TextPart;
isAnimated?: boolean;
isStreaming?: boolean;
enableFileReferences?: boolean;
}>;
let clearDetachedMarkdownDomCache: () => void;
let detachedMarkdownDomCacheStats: () => { sessions: number; entries: number };
const makeCounts = (): OperationCounts => ({
innerHTMLWrites: 0,
spriteIconInnerHTMLWrites: 0,
querySelectorAllCalls: 0,
appendCalls: 0,
replaceCalls: 0,
removeCalls: 0,
getBoundingClientRectCalls: 0,
viewBoxWrites: 0,
resizeObserverCreates: 0,
resizeObserverObserveCalls: 0,
geometrySequence: [],
});
const installGlobal = (name: string, value: Window[keyof Window]): void => {
previousGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
const waitForSettledEffects = async (): Promise<void> => {
await new Promise<void>((resolve) => setTimeout(resolve, 25));
await Promise.resolve();
};
const flushAnimationFrame = async (): Promise<void> => {
const callbacks = animationFrameQueue;
animationFrameQueue = [];
await act(async () => {
for (const callback of callbacks) callback(windowInstance.performance.now());
await Promise.resolve();
});
};
const flushDeferredMermaidInitialization = async (): Promise<void> => {
await flushAnimationFrame();
await flushAnimationFrame();
};
const mountFixture = async (rendererCount: number): Promise<{
root: Root;
host: HTMLDivElement;
operations: OperationCounts;
counts: FixtureMetrics;
}> => {
const counts = makeCounts();
activeCounts = counts;
const host = document.createElement('div');
document.body.replaceChildren(host);
const root = createRoot(host);
await act(async () => {
root.render(
<>
{Array.from({ length: rendererCount }, (_, index) => (
<MarkdownRenderer
key={`fixture-${index}`}
content={fixture}
messageId={`fixture-message-${index}`}
isAnimated={false}
enableFileReferences={false}
/>
))}
</>,
);
await waitForSettledEffects();
});
await act(async () => waitForSettledEffects());
const mermaidBlocks = host.querySelectorAll('[data-markdown="mermaid-block"]').length;
const mermaidRenderedCount = host.querySelectorAll('[data-mermaid-render]').length;
const mermaidSvgCount = host.querySelectorAll('[data-markdown="mermaid"] svg').length;
return {
root,
host,
operations: counts,
counts: {
...counts,
renderers: rendererCount,
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
mermaidBlocks,
mermaidRenderedCount,
mermaidSvgCount,
},
};
};
const runFixture = async (rendererCount: number): Promise<FixtureMetrics> => {
const { root, host, operations } = await mountFixture(rendererCount);
await flushDeferredMermaidInitialization();
const counts: FixtureMetrics = {
...operations,
renderers: rendererCount,
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
mermaidBlocks: host.querySelectorAll('[data-markdown="mermaid-block"]').length,
mermaidRenderedCount: host.querySelectorAll('[data-mermaid-render]').length,
mermaidSvgCount: host.querySelectorAll('[data-markdown="mermaid"] svg').length,
};
await act(async () => root.unmount());
return counts;
};
const initializePerformanceDom = async (): Promise<void> => {
windowInstance = new Window({ url: 'http://localhost/' });
windowInstance.document.write('<!doctype html><html><head></head><body></body></html>');
windowInstance.document.close();
previousGlobals = new Map();
installGlobal('window', windowInstance);
installGlobal('document', windowInstance.document);
installGlobal('navigator', windowInstance.navigator);
installGlobal('customElements', windowInstance.customElements);
for (const name of ['Document', 'Element', 'HTMLElement', 'SVGElement', 'Node', 'Text', 'NodeFilter', 'MutationObserver', 'DOMParser', 'XMLSerializer', 'HTMLAnchorElement', 'HTMLButtonElement']) {
// SAFETY: these names are the DOM constructors installed by this happy-dom Window.
const globalValue = windowInstance[name as keyof Window];
if (globalValue === undefined) throw new Error(`happy-dom global is unavailable: ${name}`);
installGlobal(name, globalValue);
}
Object.defineProperty(windowInstance, 'matchMedia', { configurable: true, value: () => ({ matches: false, media: '', onchange: null, addListener: () => undefined, removeListener: () => undefined, addEventListener: () => undefined, removeEventListener: () => undefined, dispatchEvent: () => false }) });
Object.defineProperty(windowInstance, 'requestAnimationFrame', { configurable: true, value: (callback: FrameRequestCallback) => {
animationFrameQueue.push(callback);
return animationFrameQueue.length;
} });
Object.defineProperty(windowInstance, 'cancelAnimationFrame', { configurable: true, value: () => undefined });
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const elementPrototype = Element.prototype;
const nodePrototype = Node.prototype;
const documentPrototype = Document.prototype;
const innerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
if (!innerHTMLDescriptor?.set || !innerHTMLDescriptor.get) throw new Error('happy-dom innerHTML descriptor unavailable');
Object.defineProperty(Element.prototype, 'innerHTML', {
configurable: true,
get: innerHTMLDescriptor.get,
set(value: string) {
if (activeCounts) {
activeCounts.innerHTMLWrites += 1;
if (value.includes('href="#oc-')) activeCounts.spriteIconInnerHTMLWrites += 1;
}
innerHTMLDescriptor.set?.call(this, value);
},
});
const originalQuerySelectorAll = elementPrototype.querySelectorAll;
Object.defineProperty(elementPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
return originalQuerySelectorAll.call(this, selectors);
} });
const originalDocumentQuerySelectorAll = documentPrototype.querySelectorAll;
Object.defineProperty(documentPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
return originalDocumentQuerySelectorAll.call(this, selectors);
} });
const originalAppendChild = nodePrototype.appendChild;
Object.defineProperty(nodePrototype, 'appendChild', { configurable: true, value: function (node: Node): Node {
if (activeCounts) activeCounts.appendCalls += 1;
return originalAppendChild.call(this, node);
} });
const originalReplaceWith = elementPrototype.replaceWith;
Object.defineProperty(elementPrototype, 'replaceWith', { configurable: true, value: function (...nodes: (Node | string)[]): void {
if (activeCounts) activeCounts.replaceCalls += 1;
return originalReplaceWith.apply(this, nodes);
} });
const originalRemove = elementPrototype.remove;
Object.defineProperty(elementPrototype, 'remove', { configurable: true, value: function (): void {
if (activeCounts) activeCounts.removeCalls += 1;
return originalRemove.call(this);
} });
const originalGetBoundingClientRect = elementPrototype.getBoundingClientRect;
Object.defineProperty(elementPrototype, 'getBoundingClientRect', { configurable: true, value: function (): DOMRect {
if (activeCounts) {
activeCounts.getBoundingClientRectCalls += 1;
activeCounts.geometrySequence.push('read');
}
return originalGetBoundingClientRect.call(this);
} });
const svgSetAttribute = SVGElement.prototype.setAttribute;
Object.defineProperty(SVGElement.prototype, 'setAttribute', { configurable: true, value: function (name: string, value: string): void {
if (name === 'viewBox' && activeCounts && this.closest('[data-markdown="mermaid"]')) {
activeCounts.viewBoxWrites += 1;
activeCounts.geometrySequence.push('write');
}
return svgSetAttribute.call(this, name, value);
} });
class CountingResizeObserver {
constructor(callback: (entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) {
if (activeCounts) activeCounts.resizeObserverCreates += 1;
notifyResize = callback;
}
observe(): void {
if (activeCounts) activeCounts.resizeObserverObserveCalls += 1;
}
unobserve(): void {}
disconnect(): void {}
}
installGlobal('ResizeObserver', CountingResizeObserver);
const fakeState = {
openContextPreview: () => undefined,
codeBlockLineWrap: false,
mermaidRenderingMode: 'svg',
};
type UIStateSelection = typeof fakeState[keyof typeof fakeState];
const { mock } = await import('bun:test');
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => null }));
mock.module('@/stores/useUIStore', () => ({ useUIStore: Object.assign((selector: (state: typeof fakeState) => UIStateSelection) => selector(fakeState), { getState: () => fakeState }) }));
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
mock.module('@/lib/url', () => ({ getUrlScheme: () => null, isAppLinkUrl: () => false, isExternalHttpUrl: () => false, openConfirmedAppLinkUrl: async () => false, openExternalUrl: async () => undefined, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => false }));
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '', normalizeFilePath: (value: string) => value, isAbsoluteFilePath: (value: string) => value.startsWith('/') }));
mock.module('@/lib/clipboard', () => ({ copyTextToClipboard: async () => undefined }));
mock.module('beautiful-mermaid', () => ({
renderMermaidASCII: () => 'diagram',
renderMermaidSVG: () => '<svg viewBox="0 0 240 120" width="240" height="120"><path d="M0 0h1v1z" /></svg>',
}));
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
mock.module('./markdown/markdown-worker', () => ({
highlightCodeInWorker: async () => null,
highlightLinesInWorker: async () => null,
highlightTokensInWorker: async () => null,
}));
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: React.ReactNode }) => children }));
const imported = await import('./MarkdownRendererImpl');
MarkdownRenderer = imported.MarkdownRenderer;
const { detachedMarkdownDomCache } = await import('./markdown/detachedMarkdownDomCache');
clearDetachedMarkdownDomCache = () => detachedMarkdownDomCache.clear();
detachedMarkdownDomCacheStats = () => detachedMarkdownDomCache.stats();
};
await initializePerformanceDom();
afterAll(() => {
for (const [name, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
});
describe('MarkdownRenderer DOM mount performance contract', () => {
test('builds Markdown sprite controls without parsing SVG markup', async () => {
const mounted = await mountFixture(1);
const spriteControlCount = mounted.host.querySelectorAll('[data-md-action] use[href^="#oc-"]').length;
const spriteIconInnerHTMLWrites = mounted.operations.spriteIconInnerHTMLWrites;
await act(async () => mounted.root.unmount());
expect(spriteControlCount).toBeGreaterThan(0);
expect(spriteIconInnerHTMLWrites).toBe(0);
});
test('reuses settled Markdown DOM without parsing or decorating it again', async () => {
clearDetachedMarkdownDomCache();
const content = '# Cached viewport\n\nA settled paragraph.';
const part: TextPart = {
id: 'part-cache',
sessionID: 'session-cache',
messageID: 'message-cache',
type: 'text',
text: content,
time: { start: 0, end: 1 },
};
const host = document.createElement('div');
document.body.replaceChildren(host);
const render = (root: Root) => root.render(
<MarkdownRenderer
content={content}
messageId="message-cache"
part={part}
isAnimated={false}
enableFileReferences={false}
/>,
);
const firstCounts = makeCounts();
activeCounts = firstCounts;
const firstRoot = createRoot(host);
await act(async () => {
render(firstRoot);
await waitForSettledEffects();
});
const originalBlock = host.querySelector('[data-md-block]');
expect(originalBlock).not.toBeNull();
expect(firstCounts.innerHTMLWrites).toBeGreaterThan(0);
await act(async () => firstRoot.unmount());
const secondCounts = makeCounts();
activeCounts = secondCounts;
const secondRoot = createRoot(host);
await act(async () => {
render(secondRoot);
await waitForSettledEffects();
});
expect(host.querySelector('[data-md-block]')).toBe(originalBlock);
expect(secondCounts.innerHTMLWrites).toBe(0);
await act(async () => secondRoot.unmount());
clearDetachedMarkdownDomCache();
});
test('does not cache streaming, unfinished, or Mermaid DOM', async () => {
clearDetachedMarkdownDomCache();
const host = document.createElement('div');
document.body.replaceChildren(host);
const renderScoped = (
root: Root,
content: string,
partId: string,
isStreaming = false,
) => root.render(
<MarkdownRenderer
content={content}
messageId="message-cache"
part={{
id: partId,
sessionID: 'session-cache',
messageID: 'message-cache',
type: 'text',
text: content,
time: { start: 0, end: 1 },
}}
isAnimated={false}
isStreaming={isStreaming}
enableFileReferences={false}
/>,
);
const streamingRoot = createRoot(host);
await act(async () => {
renderScoped(streamingRoot, 'streaming content', 'part-streaming', true);
await waitForSettledEffects();
});
await act(async () => streamingRoot.unmount());
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
const unfinalizedRoot = createRoot(host);
await act(async () => {
unfinalizedRoot.render(
<MarkdownRenderer
content="unfinalized content"
messageId="message-unfinalized"
part={{
id: 'part-unfinalized',
sessionID: 'session-cache',
messageID: 'message-unfinalized',
type: 'text',
text: 'unfinalized content',
}}
isAnimated={false}
enableFileReferences={false}
/>,
);
await waitForSettledEffects();
});
await act(async () => unfinalizedRoot.unmount());
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
const mermaidRoot = createRoot(host);
await act(async () => {
renderScoped(mermaidRoot, '```mermaid\ngraph TD\nA --> B\n```', 'part-mermaid');
await waitForSettledEffects();
});
await act(async () => mermaidRoot.unmount());
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
clearDetachedMarkdownDomCache();
});
test('does not detach Markdown DOM that intersects the active selection', async () => {
clearDetachedMarkdownDomCache();
const content = 'selected content';
const host = document.createElement('div');
document.body.replaceChildren(host);
const root = createRoot(host);
await act(async () => {
root.render(
<MarkdownRenderer
content={content}
messageId="message-selected"
part={{
id: 'part-selected',
sessionID: 'session-selected',
messageID: 'message-selected',
type: 'text',
text: content,
time: { start: 0, end: 1 },
}}
isAnimated={false}
enableFileReferences={false}
/>,
);
await waitForSettledEffects();
});
const markdown = host.querySelector<HTMLElement>('[data-markdown-content]');
if (!markdown) throw new Error('Expected mounted Markdown content');
const originalGetSelection = window.getSelection;
Object.defineProperty(window, 'getSelection', {
configurable: true,
value: () => ({
rangeCount: 1,
isCollapsed: false,
getRangeAt: () => ({ intersectsNode: (node: Node) => node === markdown }),
}),
});
try {
await act(async () => root.unmount());
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
} finally {
Object.defineProperty(window, 'getSelection', { configurable: true, value: originalGetSelection });
clearDetachedMarkdownDomCache();
}
});
test('defers and batches Mermaid controller initialization after Markdown mount', async () => {
const mounted = await mountFixture(fixtureWorkload.rendererCount);
const critical = mounted.counts;
expect(critical.getBoundingClientRectCalls).toBe(0);
expect(critical.viewBoxWrites).toBe(0);
expect(critical.resizeObserverCreates).toBe(0);
expect(mounted.host.querySelectorAll('[data-markdown="mermaid"] svg')).toHaveLength(6);
await flushDeferredMermaidInitialization();
const metrics = {
...mounted.operations,
renderers: fixtureWorkload.rendererCount,
markdownBlocks: mounted.host.querySelectorAll('[data-md-block]').length,
mermaidBlocks: mounted.host.querySelectorAll('[data-markdown="mermaid-block"]').length,
mermaidRenderedCount: mounted.host.querySelectorAll('[data-mermaid-render]').length,
mermaidSvgCount: mounted.host.querySelectorAll('[data-markdown="mermaid"] svg').length,
};
expect(metrics.renderers).toBe(3);
expect(metrics.markdownBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.domBlocksPerRenderer);
expect(metrics.mermaidBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.mermaidBlocksPerRenderer);
expect(metrics.mermaidRenderedCount).toBeGreaterThan(0);
expect(metrics.innerHTMLWrites).toBeGreaterThan(0);
expect(metrics.querySelectorAllCalls).toBeGreaterThan(0);
expect(metrics.appendCalls).toBeGreaterThan(0);
expect(metrics.getBoundingClientRectCalls).toBe(metrics.mermaidRenderedCount);
expect(metrics.viewBoxWrites).toBe(metrics.mermaidRenderedCount);
expect(metrics.resizeObserverCreates).toBe(1);
expect(metrics.resizeObserverObserveCalls).toBe(metrics.mermaidRenderedCount);
expect(metrics.geometrySequence.lastIndexOf('read')).toBeLessThan(metrics.geometrySequence.indexOf('write'));
const viewport = mounted.host.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]');
if (!viewport || !notifyResize) throw new Error('Expected initialized Mermaid viewport and shared observer');
const readsBeforeResize = mounted.operations.getBoundingClientRectCalls;
const writesBeforeResize = mounted.operations.viewBoxWrites;
notifyResize([{ target: viewport, contentRect: { width: 320, height: 180 } }]);
expect(mounted.operations.getBoundingClientRectCalls).toBe(readsBeforeResize);
expect(mounted.operations.viewBoxWrites).toBe(writesBeforeResize + 1);
console.log(JSON.stringify({ fixture: fixtureWorkload, baseline: metrics }));
await act(async () => mounted.root.unmount());
});
test('cancels deferred Mermaid initialization when the renderer unmounts first', async () => {
const mounted = await mountFixture(1);
await act(async () => mounted.root.unmount());
await flushDeferredMermaidInitialization();
expect(mounted.operations.getBoundingClientRectCalls).toBe(0);
expect(mounted.operations.viewBoxWrites).toBe(0);
expect(mounted.operations.resizeObserverCreates).toBe(0);
});
test('keeps DOM operation fanout linear when renderer count doubles', async () => {
const three = await runFixture(3);
const six = await runFixture(6);
expect(six.mermaidBlocks).toBe(three.mermaidBlocks * 2);
expect(six.mermaidRenderedCount).toBe(three.mermaidRenderedCount * 2);
expect(six.innerHTMLWrites).toBeLessThanOrEqual(three.innerHTMLWrites * 2 + 6);
expect(six.querySelectorAllCalls).toBeLessThanOrEqual(three.querySelectorAllCalls * 2 + 12);
expect(six.appendCalls).toBeLessThanOrEqual(three.appendCalls * 2 + 12);
expect(six.getBoundingClientRectCalls).toBe(three.getBoundingClientRectCalls * 2);
expect(six.viewBoxWrites).toBe(three.viewBoxWrites * 2);
expect(three.resizeObserverCreates).toBe(1);
expect(six.resizeObserverCreates).toBe(1);
expect(six.resizeObserverObserveCalls).toBe(three.resizeObserverObserveCalls * 2);
});
});
@@ -1,9 +1,377 @@
import { describe, expect, test } from 'bun:test';
import { describe, expect, mock, test } from 'bun:test';
import { parseFileReference, type ParsedFileReference } from './fileReferenceParser';
import { localPathFromFileUrl, parseFileReference, type ParsedFileReference } from './fileReferenceParser';
const parse = (value: string): ParsedFileReference | null => parseFileReference(value);
type FakeElement = {
childNodes: FakeElement[];
children: FakeElement[];
parentNode: FakeElement | null;
attributes: Map<string, string>;
style: { display: string; setProperty: () => void };
innerHTML: string;
setAttribute: (name: string, value: string) => void;
getAttribute: (name: string) => string | null;
appendChild: (child: FakeElement) => FakeElement;
replaceWith: (replacement: FakeElement) => void;
remove: () => void;
querySelector: (selector: string) => FakeElement | null;
querySelectorAll: <T>(selector: string) => T[];
addEventListener: () => void;
removeEventListener: () => void;
contains: (child: FakeElement) => boolean;
isEqualNode: () => boolean;
};
type FakeDocument = { createElement: () => FakeElement };
type FakeJsxProps = {
ref?: { current: FakeElement | null };
children?: FakeElement | FakeElement[];
className?: string;
'data-markdown-content'?: boolean;
};
let syncRenderCalls = 0;
let morphCalls = 0;
let decorateCalls = 0;
let mermaidRegistryCreates = 0;
let mermaidRegistryCleanups = 0;
let cachedRendererBlocks: Array<{ id: string; html: string }> | null = null;
let renderedRendererBlocks: Array<{ id: string; html: string }> = [];
let renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
let currentContextVersion = 0;
const layoutEffects: Array<() => void> = [];
const passiveEffects: Array<() => void | (() => void)> = [];
let hookCursor = 0;
let hookStates: Array<{ current: null } | undefined> = [];
let activeFakeDocument: FakeDocument | null = null;
const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => {
void ownerDocument;
let html = '';
const element: FakeElement = {
childNodes: [],
children: [],
parentNode: null,
attributes: new Map(),
style: { display: '', setProperty: () => undefined },
get innerHTML() {
return html;
},
set innerHTML(value: string) {
html = value;
},
setAttribute(name, value) {
this.attributes.set(name, value);
},
getAttribute(name) {
return this.attributes.get(name) ?? null;
},
appendChild(child) {
child.parentNode = this;
this.childNodes.push(child);
this.children.push(child);
return child;
},
replaceWith(replacement) {
if (!this.parentNode) return;
const parent = this.parentNode;
const index = parent.children.indexOf(this);
if (index < 0) return;
replacement.parentNode = parent;
parent.children[index] = replacement;
parent.childNodes[index] = replacement;
this.parentNode = null;
},
remove() {
if (!this.parentNode) return;
const parent = this.parentNode;
parent.children = parent.children.filter((child) => child !== this);
parent.childNodes = parent.childNodes.filter((child) => child !== this);
this.parentNode = null;
},
querySelector(selector) {
if (selector === '[data-markdown-content]') {
return this.children.find((child) => child.getAttribute('data-markdown-content') === '') ?? null;
}
if (selector === '[data-markdown="mermaid-block"]' && html.includes('data-markdown="mermaid-block"')) {
return this;
}
for (const child of this.children) {
const match = child.querySelector(selector);
if (match) return match;
}
return null;
},
querySelectorAll: () => [],
addEventListener: () => undefined,
removeEventListener: () => undefined,
contains(child) {
return child === this || this.children.some((candidate) => candidate.contains(child));
},
isEqualNode: () => false,
};
return element;
};
const installRendererDom = () => {
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousMutationObserver = Object.getOwnPropertyDescriptor(globalThis, 'MutationObserver');
const documentStub: FakeDocument = { createElement: () => makeFakeElement(documentStub) };
activeFakeDocument = documentStub;
Object.defineProperty(globalThis, 'document', { configurable: true, value: documentStub });
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
matchMedia: () => ({ matches: false }),
setTimeout,
clearTimeout,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
disconnect() {}
},
});
return () => {
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument);
else Reflect.deleteProperty(globalThis, 'document');
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
if (previousMutationObserver) Object.defineProperty(globalThis, 'MutationObserver', previousMutationObserver);
else Reflect.deleteProperty(globalThis, 'MutationObserver');
activeFakeDocument = null;
};
};
const rendererThemes = [{
metadata: { id: 'renderer-test' },
colors: {
surface: { elevated: '#fff', foreground: '#000', mutedForeground: '#666', muted: '#eee' },
interactive: { border: '#ccc' },
primary: { base: '#00f' },
},
}, {
metadata: { id: 'renderer-test-next' },
colors: {
surface: { elevated: '#eee', foreground: '#111', mutedForeground: '#555', muted: '#ddd' },
interactive: { border: '#bbb' },
primary: { base: '#f00' },
},
}];
let rendererThemeIndex = 0;
const rendererTheme = () => rendererThemes[rendererThemeIndex] ?? rendererThemes[0];
const rendererUiState = {
codeBlockLineWrap: false,
mermaidRenderingMode: 'svg',
setCodeBlockLineWrap: () => undefined,
openContextPreview: () => undefined,
};
const fakeReact = {
useCallback: <T>(callback: T): T => {
hookCursor += 1;
return callback;
},
useEffect: (effect: () => void | (() => void)) => { passiveEffects.push(effect); },
useLayoutEffect: (effect: () => void) => { layoutEffects.push(effect); },
useMemo: <T>(factory: () => T): T => {
hookCursor += 1;
return factory();
},
useRef: <T>(current: T) => {
void current;
const index = hookCursor;
hookCursor += 1;
if (!hookStates[index]) hookStates[index] = { current: null };
// SAFETY: this test hook preserves one mutable ref slot per hook index.
return hookStates[index] as { current: T };
},
memo: <T>(component: T): T => component,
};
const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => {
const ref = props?.ref;
// SAFETY: the renderer test installs the typed fake document before JSX is
// evaluated; this branch only supplies its fake element factory.
const fakeDocument = activeFakeDocument;
if (!fakeDocument) throw new Error('Renderer fake document is not installed');
const element = ref?.current ?? makeFakeElement(fakeDocument);
if (!ref?.current) {
element.childNodes.length = 0;
element.children.length = 0;
}
if (props) {
if (ref) ref.current = element;
if (props.className) element.setAttribute('class', props.className);
if (props['data-markdown-content']) element.setAttribute('data-markdown-content', '');
}
const jsxChildren = props?.children;
const allChildren = jsxChildren === undefined ? children : Array.isArray(jsxChildren) ? jsxChildren : [jsxChildren];
for (const child of allChildren) {
if (child) element.appendChild(child);
}
return element;
};
mock.module('react', () => ({ default: fakeReact }));
mock.module('react/jsx-runtime', () => ({ jsx: fakeJsx, jsxs: fakeJsx, Fragment: 'fragment' }));
mock.module('react/jsx-dev-runtime', () => ({ jsxDEV: fakeJsx, Fragment: 'fragment' }));
mock.module('beautiful-mermaid', () => ({
renderMermaidASCII: () => '',
renderMermaidSVG: (_source: string, colors: { bg: string }) => colors.bg,
}));
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => `${key}:${currentContextVersion}` }) }));
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
mock.module('@/lib/url', () => ({
getUrlScheme: () => null,
isAppLinkUrl: () => false,
isExternalHttpUrl: () => false,
openConfirmedAppLinkUrl: async () => false,
openExternalUrl: async () => undefined,
}));
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => ({ currentTheme: rendererTheme() }) }));
mock.module('@/lib/theme/themes', () => ({ getDefaultTheme: () => rendererTheme() }));
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: FakeElement | FakeElement[] }) => children }));
type RendererUiSelectorResult = boolean | string | (() => void);
const fakeUseUIStore = Object.assign(
(selector: (state: typeof rendererUiState) => RendererUiSelectorResult) => selector(rendererUiState),
{ getState: () => rendererUiState },
);
mock.module('@/stores/useUIStore', () => ({ useUIStore: fakeUseUIStore }));
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '' }));
mock.module('./markdown/markdownCore', () => ({
getCachedMarkdownBlocks: () => cachedRendererBlocks,
renderMarkdownBlocks: () => renderMarkdownBlocksForTest(),
renderMarkdownSync: () => {
syncRenderCalls += 1;
return '<p>cold</p>';
},
}));
mock.module('./markdown/markdownTheme', () => ({ ensureMarkdownShikiTheme: () => undefined }));
mock.module('./markdown/markdownSyntaxVars', () => ({ getMarkdownSyntaxVars: () => ({}) }));
mock.module('./markdown/detachedMarkdownDomCache', () => ({
detachedMarkdownDomCache: {
take: () => null,
store: () => undefined,
},
}));
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'runtime' }));
type TestDecorateContext = {
labels: { copy: string };
codeBlockLineWrap: boolean;
renderMermaid: (source: string) => { svg?: string };
};
mock.module('./markdown/decorate', () => ({
attachMarkdownInteractions: () => () => undefined,
applyMarkdownCodeBlockWrapState: () => undefined,
decorateMarkdown: (root: FakeElement, ctx: TestDecorateContext) => {
decorateCalls += 1;
if (root.getAttribute('data-test-decoration-marker') === 'true') return;
root.setAttribute('data-test-decoration-marker', 'true');
root.setAttribute(
'data-test-decoration',
`${ctx.labels.copy}|${ctx.codeBlockLineWrap}|${ctx.renderMermaid('test').svg ?? ''}`,
);
},
getMarkdownCodeText: () => '',
}));
mock.module('./markdown/textPosition', () => ({ findTextPosition: () => null }));
mock.module('./markdown/mermaidViewer', () => ({
createMermaidViewerRegistry: () => {
mermaidRegistryCreates += 1;
return {
refresh: () => undefined,
cleanup: () => { mermaidRegistryCleanups += 1; },
};
},
MERMAID_BLOCK_SELECTOR: '[data-markdown="mermaid-block"]',
shouldRefreshMermaidViewers: (container: Pick<FakeElement, 'querySelector'>) => container.querySelector('[data-markdown="mermaid-block"]') !== null,
}));
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
mock.module('morphdom', () => ({ default: () => { morphCalls += 1; } }));
const { MarkdownRenderer } = await import('./MarkdownRendererImpl');
const resetRendererTestState = () => {
cachedRendererBlocks = null;
renderedRendererBlocks = [];
renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
syncRenderCalls = 0;
morphCalls = 0;
decorateCalls = 0;
mermaidRegistryCreates = 0;
mermaidRegistryCleanups = 0;
hookCursor = 0;
hookStates = [];
layoutEffects.length = 0;
passiveEffects.length = 0;
currentContextVersion = 0;
rendererThemeIndex = 0;
rendererUiState.codeBlockLineWrap = false;
};
const beginRendererRender = () => {
hookCursor = 0;
return renderMarkdownForTest();
};
const rendererRoot = (value: ReturnType<typeof renderMarkdownForTest>): FakeElement => {
if (!(value instanceof Object) || !('childNodes' in value) || !('getAttribute' in value)) {
throw new Error('Renderer test did not return its fake JSX root');
}
// SAFETY: the structural check confirms this ReactNode is the object
// returned by the mocked JSX runtime.
const candidate = value as object;
// SAFETY: the mocked JSX runtime creates the complete FakeElement shape.
return candidate as FakeElement;
};
const runRendererLayoutEffects = () => {
const pending = layoutEffects.splice(0);
for (const effect of pending) effect();
};
const runRendererPassiveEffects = () => passiveEffects.splice(0).map((effect) => effect());
const findBlock = (root: FakeElement, id: string): FakeElement | null => {
if (root.getAttribute('data-md-id') === id) return root;
for (const child of root.children) {
const match = findBlock(child, id);
if (match) return match;
}
return null;
};
const renderMarkdownForTest = () => MarkdownRenderer({
content: 'cached markdown',
messageId: 'message-1',
isAnimated: false,
isStreaming: false,
});
const withRendererDom = async (run: () => void | Promise<void>): Promise<void> => {
const restoreDom = installRendererDom();
const previousThemeIndex = rendererThemeIndex;
try {
await run();
} finally {
rendererThemeIndex = previousThemeIndex;
restoreDom();
}
};
describe('parseFileReference', () => {
test('returns null for empty or whitespace input', () => {
expect(parse('')).toBeNull();
@@ -72,11 +440,7 @@ describe('parseFileReference', () => {
});
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,
});
expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
});
test('preserves hash form', () => {
@@ -96,3 +460,134 @@ describe('parseFileReference', () => {
expect(result).toEqual({ path: 'src/foo.ts', line: 42, endLine: 58 });
});
});
describe('localPathFromFileUrl', () => {
test('converts local file URLs to absolute paths', () => {
expect(localPathFromFileUrl('file:///private/tmp/report%20viewer.html')).toBe('/private/tmp/report viewer.html');
expect(localPathFromFileUrl('file://localhost/private/tmp/REPORT.md')).toBe('/private/tmp/REPORT.md');
expect(localPathFromFileUrl('file:///C:/Users/test/report.html')).toBe('C:/Users/test/report.html');
});
test('rejects non-file URLs and remote file hosts', () => {
expect(localPathFromFileUrl('https://example.com/report.html')).toBeNull();
expect(localPathFromFileUrl('file://remote-host/share/report.html')).toBeNull();
expect(localPathFromFileUrl('file:///tmp/bad%ZZpath')).toBeNull();
});
});
describe('MarkdownRenderer warm settled path', () => {
test('installs cached blocks without sync fallback and skips same-ID morph', async () => {
await withRendererDom(async () => {
resetRendererTestState();
cachedRendererBlocks = [{ id: 'full:cached', html: '<p>cached</p>' }];
renderedRendererBlocks = cachedRendererBlocks;
syncRenderCalls = 0;
morphCalls = 0;
decorateCalls = 0;
// SAFETY: the test JSX adapter returns the fake element assigned to the
// renderer container ref and exposes the DOM members used below.
const root = rendererRoot(beginRendererRender());
runRendererLayoutEffects();
expect(syncRenderCalls).toBe(0);
const block = findBlock(root, 'full:cached');
expect(block).not.toBeNull();
expect(block?.innerHTML).toBe('<p>cached</p>');
expect(block?.getAttribute('data-md-block')).toBe('');
expect(block?.getAttribute('data-md-id')).toBe('full:cached');
expect(block?.style.display).toBe('contents');
expect(decorateCalls).toBe(1);
runRendererPassiveEffects();
await Promise.resolve();
expect(morphCalls).toBe(0);
});
});
test('recreates the Mermaid registry after StrictMode-like cleanup without remounting blocks', () => {
return withRendererDom(() => {
resetRendererTestState();
const mermaidHtml = '<div data-markdown="mermaid-block"><svg></svg></div>';
cachedRendererBlocks = [{ id: 'full:mermaid', html: mermaidHtml }];
renderedRendererBlocks = cachedRendererBlocks;
mermaidRegistryCreates = 0;
mermaidRegistryCleanups = 0;
morphCalls = 0;
const root = rendererRoot(beginRendererRender());
runRendererLayoutEffects();
expect(mermaidRegistryCreates).toBe(1);
const cleanups = runRendererPassiveEffects();
for (const cleanup of cleanups) cleanup?.();
expect(mermaidRegistryCleanups).toBe(1);
beginRendererRender();
runRendererLayoutEffects();
expect(mermaidRegistryCreates).toBe(2);
expect(findBlock(root, 'full:mermaid')).not.toBeNull();
expect(morphCalls).toBe(0);
});
});
test('redecorates a same-ID block when decoration context changes before async completion', async () => {
await withRendererDom(async () => {
resetRendererTestState();
cachedRendererBlocks = [{
id: 'full:context',
html: '<div data-markdown="mermaid-block"><p>cached</p></div>',
}];
renderedRendererBlocks = cachedRendererBlocks;
const root = rendererRoot(beginRendererRender());
runRendererLayoutEffects();
const block = findBlock(root, 'full:context');
const firstDecorationId = block?.getAttribute('data-md-decoration-id');
expect(firstDecorationId).not.toBeNull();
const firstDecorateCalls = decorateCalls;
rendererThemeIndex = 1;
currentContextVersion = 1;
rendererUiState.codeBlockLineWrap = true;
beginRendererRender();
runRendererLayoutEffects();
runRendererPassiveEffects();
await Promise.resolve();
expect(decorateCalls).toBeGreaterThan(firstDecorateCalls);
expect(syncRenderCalls).toBe(0);
expect(morphCalls).toBe(0);
const updatedBlock = findBlock(root, 'full:context');
expect(updatedBlock?.getAttribute('data-md-decoration-id')).not.toBe(firstDecorationId);
expect(updatedBlock?.getAttribute('data-test-decoration')).toContain(':1|true|#eee');
expect(updatedBlock?.getAttribute('data-test-decoration-marker')).toBe('true');
expect(mermaidRegistryCleanups).toBeGreaterThan(0);
expect(mermaidRegistryCreates).toBeGreaterThan(1);
});
});
test('rejects an older async render after a newer layout commit', async () => {
await withRendererDom(async () => {
resetRendererTestState();
cachedRendererBlocks = [{ id: 'full:initial', html: '<p>initial</p>' }];
let resolveOldRender: ((blocks: Array<{ id: string; html: string }>) => void) | undefined;
const oldRender = new Promise<Array<{ id: string; html: string }>>((resolve) => {
resolveOldRender = resolve;
});
renderMarkdownBlocksForTest = () => oldRender;
beginRendererRender();
runRendererLayoutEffects();
runRendererPassiveEffects();
cachedRendererBlocks = [{ id: 'full:new', html: '<p>new</p>' }];
beginRendererRender();
runRendererLayoutEffects();
expect(resolveOldRender).toBeDefined();
resolveOldRender?.([{ id: 'full:old-late', html: '<p>old late</p>' }]);
await Promise.resolve();
expect(morphCalls).toBe(0);
});
});
});
@@ -4,11 +4,12 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
import { openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getDefaultTheme } from '@/lib/theme/themes';
import type { Theme } from '@/types/theme';
import { openAppLinkWithConfirmation } from './appLinkConfirmation';
import { attachAppLinkInteractions } from './appLinkInteractions';
import type { ToolPopupContent } from './message/types';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import { useUIStore } from '@/stores/useUIStore';
@@ -19,8 +20,14 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/l
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
import {
getCachedMarkdownBlocks,
renderMarkdownBlocks,
renderMarkdownSync,
type MarkdownImageMode,
} from './markdown/markdownCore';
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
import {
attachMarkdownInteractions,
applyMarkdownCodeBlockWrapState,
@@ -36,11 +43,15 @@ import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMerma
import {
BLOCK_PATH_TOKEN_RE,
isAbsoluteReferencePath,
localPathFromFileUrl,
normalizeReferencePath,
parseFileReference,
type ParsedFileReference,
} from './fileReferenceParser';
import { fileReferenceExists } from './fileReferenceStat';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
import { getRuntimeKey } from '@/lib/runtime-switch';
const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem();
@@ -53,7 +64,7 @@ const useCurrentMermaidTheme = () => {
: fallbackLight);
};
const useExternalLinkInteractions = ({
const useLinkInteractions = ({
containerRef,
enabled,
}: {
@@ -61,48 +72,16 @@ const useExternalLinkInteractions = ({
enabled?: boolean;
}) => {
React.useEffect(() => {
if (enabled === false) {
return;
}
const container = containerRef.current;
if (!container) {
return;
}
const handleClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
return;
}
const href = anchor.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) {
return;
}
event.preventDefault();
event.stopPropagation();
void openExternalUrl(href);
};
container.addEventListener('click', handleClick);
return () => {
container.removeEventListener('click', handleClick);
};
return attachAppLinkInteractions(container, {
allowExternalHttp: enabled !== false,
openAppLink: (href) => void openAppLinkWithConfirmation(href),
openExternalHttp: (href) => void openExternalUrl(href),
});
}, [containerRef, enabled]);
};
@@ -149,19 +128,9 @@ const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
// 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;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_LINK_LIMIT = 80;
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
const getFileReferenceLinkLimit = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
@@ -244,6 +213,10 @@ const unwrapBlockCodePathTokens = (container: HTMLElement): void => {
const extractPathCandidateFromElement = (element: HTMLElement): string => {
if (element.tagName.toLowerCase() === 'a') {
const href = element.getAttribute('href')?.trim();
const fileUrlPath = href ? localPathFromFileUrl(href) : null;
if (fileUrlPath) {
return fileUrlPath;
}
if (href && isLikelyFilePath(href)) {
return href;
}
@@ -355,61 +328,6 @@ const getResolvedReference = (rawValue: string, effectiveDirectory: string): (Pa
};
};
const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const normalizedPath = normalizePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
})
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
return request;
};
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
};
@@ -434,6 +352,13 @@ const useFileReferenceInteractions = ({
if (!container) {
return;
}
// Wait for the real directory: annotating against an empty/fallback
// directory issues stat probes under the wrong cache key (and the wrong
// server directory), and the pass reruns anyway once the directory
// resolves — every link ended up verified twice.
if (enabled && !effectiveDirectory) {
return;
}
let cancelled = false;
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
// On mobile surfaces, file-reference highlighting is disabled entirely — not
@@ -487,6 +412,19 @@ const useFileReferenceInteractions = ({
};
const annotateFileLinks = () => {
annotationWriteDepth += 1;
try {
annotateFileLinksInner();
} finally {
// Let the mutation events from our own writes flush before the
// observer starts listening for real content changes again.
queueMicrotask(() => {
annotationWriteDepth -= 1;
});
}
};
const annotateFileLinksInner = () => {
if (fileReferencesEnabled) {
wrapBlockCodePathTokens(container);
}
@@ -515,7 +453,7 @@ const useFileReferenceInteractions = ({
&& !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
const existsPromise = canGrantOutsideFile
? Promise.resolve(true)
: fileReferenceExists(resolved.resolvedPath);
: fileReferenceExists(resolved.resolvedPath, effectiveDirectory);
void existsPromise.then((exists) => {
if (cancelled || !exists || !container.contains(candidate)) {
@@ -615,7 +553,12 @@ const useFileReferenceInteractions = ({
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
// Our own annotation writes (path-token wrapping, attribute updates) fire
// childList mutations too; observing them re-ran the whole pass — every
// link was scanned and verified twice per render.
let annotationWriteDepth = 0;
const observer = new MutationObserver(() => {
if (annotationWriteDepth > 0) return;
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
});
observer.observe(container, {
@@ -748,6 +691,19 @@ const useMermaidInlineInteractions = ({
// so a stable diagram is laid out once and served from cache thereafter.
const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>();
const MERMAID_RENDER_CACHE_MAX = 100;
const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id';
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
let nextMarkdownDecorationId = 0;
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
const getMarkdownDecorationId = (ctx: DecorateContext): string => {
const existing = MARKDOWN_DECORATION_IDS.get(ctx);
if (existing) return existing;
const id = `decoration-${nextMarkdownDecorationId}`;
nextMarkdownDecorationId += 1;
MARKDOWN_DECORATION_IDS.set(ctx, id);
return id;
};
const cachedMermaidRender = (key: string, compute: () => MermaidRender): MermaidRender => {
const existing = MERMAID_RENDER_CACHE.get(key);
@@ -829,22 +785,31 @@ const useMorphdomMarkdown = ({
containerRef,
text,
streaming,
cacheKey,
imageMode = 'inline',
syntaxVars,
ctx,
domCacheKey,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
text: string;
streaming: boolean;
cacheKey: string;
imageMode?: MarkdownImageMode;
syntaxVars: Record<string, string>;
ctx: DecorateContext;
domCacheKey?: DetachedMarkdownDomKey | null;
}) => {
React.useEffect(() => {
ensureMarkdownShikiTheme();
}, []);
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
const renderRevisionRef = React.useRef(0);
// Only DOM that was actually restored or completed by the async pipeline is
// eligible for capture. A fallback from an earlier content revision is not.
const mountedDomRef = React.useRef<{
key: DetachedMarkdownDomKey;
copiedLabel: string;
} | null>(null);
const refreshMermaidViewers = React.useCallback(() => {
const container = containerRef.current;
if (!container) {
@@ -860,6 +825,63 @@ const useMorphdomMarkdown = ({
mermaidViewerRef.current.refresh();
}, [containerRef]);
React.useLayoutEffect(() => {
renderRevisionRef.current += 1;
mountedDomRef.current = null;
}, [ctx, imageMode, streaming, text]);
React.useLayoutEffect(() => {
if (!domCacheKey) return;
const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
if (!target || target.childNodes.length > 0) return;
const cached = detachedMarkdownDomCache.take(domCacheKey);
if (cached) {
target.appendChild(cached);
const decorationId = getMarkdownDecorationId(ctx);
for (const block of Array.from(target.children)) {
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
}
for (const [key, value] of Object.entries(syntaxVars)) target.style.setProperty(key, value);
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
mountedDomRef.current = {
key: domCacheKey,
copiedLabel: ctx.labels.copied,
};
streamPerfCount('ui.markdown_renderer.dom_cache.hit');
}
}, [containerRef, ctx, domCacheKey, syntaxVars, text.length]);
// Restoration follows the cache identity above, but capture must only happen
// when this renderer lifecycle ends. Combining both in one keyed effect would
// detach the live DOM on ordinary content, theme, or locale updates.
React.useLayoutEffect(() => {
const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
if (!target) return;
return () => {
const mountedDom = mountedDomRef.current;
if (!mountedDom) return;
// Viewer controllers and transient interaction state belong to the
// current renderer instance and must not cross the cache boundary.
if (target.childNodes.length === 0 || shouldRefreshMermaidViewers(target)) return;
if (Array.from(target.children).some((block) => !block.hasAttribute('data-md-id'))) return;
if (target.querySelector('[data-md-copy-pending]')) return;
const selection = window.getSelection();
if (selection?.rangeCount && !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(target)) return;
const openMenu = target.querySelector<HTMLElement>('[data-md-menu]:not(.hidden)');
const copiedButton = Array.from(target.querySelectorAll<HTMLButtonElement>('[data-md-action]'))
.some((button) => button.getAttribute('title') === mountedDom.copiedLabel);
if (openMenu || copiedButton) return;
const fragment = document.createDocumentFragment();
fragment.append(...Array.from(target.childNodes));
detachedMarkdownDomCache.store({ ...mountedDom.key, fragment });
streamPerfCount('ui.markdown_renderer.dom_cache.capture');
};
}, [containerRef]);
// Synchronous first paint: while the async parse is in-flight, show escaped
// plain text immediately so there is no blank frame on initial mount. Only
// runs when the target is empty — subsequent updates keep the prior rich DOM
@@ -869,25 +891,40 @@ const useMorphdomMarkdown = ({
const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
if (!target) return;
const decorationId = getMarkdownDecorationId(ctx);
if (text && target.childNodes.length === 0) {
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
// `display:contents` keeps margin-collapsing/spacing identical to a flat
// HTML body — the wrapper exists only for per-block reconciliation.
block.style.display = 'contents';
block.innerHTML = renderMarkdownSync(text);
// Decorate synchronously too: wrap code blocks in their framed card,
// mark inline code, build table controls, etc. The async pass re-decorates
// its own DOM before morphing, so without this the first paint shows bare
// <pre>/tables that "snap" into their decorated form a tick later. Matching
// the structure here keeps the async morph to syntax colors only.
decorateMarkdown(block, ctx);
target.appendChild(block);
if (shouldRefreshMermaidViewers(block)) {
refreshMermaidViewers();
const cachedBlocks = !streaming ? getCachedMarkdownBlocks(text, imageMode) : null;
if (cachedBlocks) {
let hasMermaidBlock = false;
for (const cachedBlock of cachedBlocks) {
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
block.style.display = 'contents';
block.innerHTML = cachedBlock.html;
decorateMarkdown(block, ctx);
block.setAttribute('data-md-id', cachedBlock.id);
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
hasMermaidBlock ||= shouldRefreshMermaidViewers(block);
target.appendChild(block);
}
if (hasMermaidBlock) refreshMermaidViewers();
} else {
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
block.style.display = 'contents';
block.innerHTML = renderMarkdownSync(text, imageMode);
decorateMarkdown(block, ctx);
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
target.appendChild(block);
if (shouldRefreshMermaidViewers(block)) refreshMermaidViewers();
}
} else if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(target)) {
// StrictMode re-runs this setup after the cleanup probe. The DOM remains,
// but the viewer registry does not, so recreate it without reinstalling
// or re-decorating ordinary blocks.
refreshMermaidViewers();
}
}, [containerRef, text, ctx, refreshMermaidViewers]);
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup();
@@ -899,27 +936,70 @@ const useMorphdomMarkdown = ({
if (!container) return;
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
let active = true;
const renderRevision = renderRevisionRef.current;
const decorationId = getMarkdownDecorationId(ctx);
void renderMarkdownBlocks(text, streaming, cacheKey).then((blocks) => {
if (!active) return;
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[];
// Reconcile per block: only re-morph blocks whose content changed, leaving
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded
// to the trailing (growing) block instead of the whole message.
let enteredThisPass = 0;
blocks.forEach((block, index) => {
let el = existing[index];
let isNewBlock = false;
if (!el) {
el = document.createElement('div');
el.setAttribute('data-md-block', '');
el.style.display = 'contents';
target.appendChild(el);
isNewBlock = true;
}
if (el.getAttribute('data-md-id') === block.id) {
if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
const hasMermaidBlock = shouldRefreshMermaidViewers(el);
if (hasMermaidBlock) {
mermaidViewerRef.current?.cleanup();
mermaidViewerRef.current = null;
}
const replacement = document.createElement('div');
replacement.setAttribute('data-md-block', '');
replacement.style.display = 'contents';
replacement.innerHTML = block.html;
decorateMarkdown(replacement, ctx);
replacement.setAttribute('data-md-id', block.id);
replacement.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
el.replaceWith(replacement);
if (hasMermaidBlock || shouldRefreshMermaidViewers(replacement)) refreshMermaidViewers();
}
if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(el)) {
refreshMermaidViewers();
}
return;
}
if (el.getAttribute('data-md-id') === block.id) return;
const temp = document.createElement('div');
temp.innerHTML = block.html;
decorateMarkdown(temp, ctx);
if (isNewBlock && streaming && index > 0) {
// A freshly committed block enters with a short reveal. The class
// goes on the block's children — the wrapper is display:contents
// and cannot animate — and the transform never changes layout, so
// row measurement stays exact. Skipped for the first block so a
// full initial render does not shimmer. Several blocks committed
// in one tick cascade with a small stagger instead of popping in
// together.
const delayMs = Math.min(enteredThisPass, 4) * 55;
enteredThisPass += 1;
for (const child of Array.from(temp.children)) {
child.classList.add('oc-md-block-enter');
if (delayMs > 0 && child instanceof HTMLElement) {
child.style.setProperty('--oc-md-enter-delay', `${delayMs}ms`);
}
}
}
const hadMermaidBlock = shouldRefreshMermaidViewers(el);
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
morphdom(el, temp, {
@@ -927,12 +1007,12 @@ const useMorphdomMarkdown = ({
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
});
el.setAttribute('data-md-id', block.id);
el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
refreshMermaidViewers();
}
});
// Remove any trailing block elements no longer present.
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
let removedMermaidBlock = false;
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
@@ -945,13 +1025,15 @@ const useMorphdomMarkdown = ({
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
refreshMermaidViewers();
}
mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
});
return () => {
active = false;
};
}, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]);
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
React.useEffect(() => {
const container = containerRef.current;
@@ -1028,13 +1110,49 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
preferRuntimeEditor: runtime.isVSCode,
enabled: enableFileReferences && !isStreaming,
});
useExternalLinkInteractions({ containerRef });
useLinkInteractions({ containerRef });
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
const { locale } = useI18n();
const imageMode: MarkdownImageMode = variant === 'assistant' ? 'label' : 'inline';
const settledPart = part
&& (part.type === 'text' || part.type === 'reasoning')
&& part.time?.end !== undefined
? part
: null;
const runtimeKey = getRuntimeKey();
// Memoized on scalar identities, not the part object: sync-store reducers
// recreate part objects on unrelated updates, and an object-identity dep
// re-ran the async render pipeline for identical content.
const settledSessionID = settledPart?.sessionID;
const settledMessageID = settledPart?.messageID;
const settledPartID = settledPart?.id;
const domCacheKey = React.useMemo<DetachedMarkdownDomKey | null>(() => {
// Streaming, unfinished, oversized, and identity-less Markdown continues
// through the normal rendering pipeline and never retains detached DOM.
if (isStreaming || !settledSessionID || !settledMessageID || !settledPartID || content.length === 0 || content.length > MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS) return null;
// content.length is a cheap fingerprint: an edited or reverted part that
// re-materializes under the same id must not restore the old DOM.
return {
scope: `${runtimeKey}\0${settledSessionID}`,
id: `${settledMessageID}\0${settledPartID}\0${imageMode}\0${content.length}`,
locale,
directory: effectiveDirectory,
};
}, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledSessionID, settledMessageID, settledPartID]);
// Identity for the fade-in wrapper: a new part/message restarts the animation.
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx });
useMorphdomMarkdown({
containerRef,
text: content,
streaming: live,
imageMode,
syntaxVars,
ctx,
domCacheKey,
});
const markdownContent = (
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
@@ -1044,7 +1162,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
if (isAnimated) {
return (
<FadeInOnReveal key={cacheKey} skipAnimation={skipFadeIn}>
<FadeInOnReveal key={fadeKey} skipAnimation={skipFadeIn}>
{markdownContent}
</FadeInOnReveal>
);
@@ -1071,6 +1189,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
content: string;
className?: string;
variant?: MarkdownVariant;
// App links remain confirmed even where ordinary HTTP link handling is off.
disableLinkSafety?: boolean;
stripFrontmatter?: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
@@ -1112,7 +1231,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
preferRuntimeEditor: runtime.isVSCode,
enabled: enableFileReferences,
});
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
useLinkInteractions({ containerRef, enabled: !disableLinkSafety });
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
@@ -1121,7 +1240,6 @@ const SimpleMarkdownRendererImpl: React.FC<{
containerRef,
text: renderedContent,
streaming: false,
cacheKey: `simple:${variant}`,
syntaxVars,
ctx,
});
File diff suppressed because it is too large Load Diff
+117 -122
View File
@@ -25,7 +25,8 @@ import { useDeviceInfo } from '@/lib/device';
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay';
import { getEditModeColors } from '@/lib/permissions/editModeColors';
import { cn, fuzzyMatch } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { useContextStore } from '@/stores/contextStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -57,6 +58,28 @@ type MobileVariantTarget = { providerId: string; modelId: string };
const buildModelRefKey = (providerID: string, modelID: string) => `${providerID}:${modelID}`;
const MAX_INLINE_MOBILE_VARIANT_OPTIONS = 6;
const AgentDescriptionTooltip: React.FC<{
description?: string;
children: React.ReactElement;
}> = ({ description, children }) => {
if (!description) {
return children;
}
return (
<Tooltip delayDuration={450}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
>
<span className="typography-meta text-muted-foreground">{description}</span>
</TooltipContent>
</Tooltip>
);
};
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
if (!Array.isArray(value)) {
return null;
@@ -301,7 +324,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentVariant = useConfigStore((state) => state.currentVariant);
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
const currentVariant = currentVariantSelection.override ?? undefined;
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
@@ -309,6 +334,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const setModel = useConfigStore((state) => state.setModel);
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride);
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
const setAgent = useConfigStore((state) => state.setAgent);
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
@@ -506,13 +532,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const sortedAndFilteredAgents = React.useMemo(() => {
const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name));
if (!agentSearchQuery.trim()) {
return sorted;
}
return sorted.filter((agent) =>
fuzzyMatch(agent.name, agentSearchQuery) ||
(agent.description && fuzzyMatch(agent.description, agentSearchQuery))
);
return rankByQuery(sorted, agentSearchQuery, (agent) => [agent.name, agent.description]);
}, [selectableDesktopAgents, agentSearchQuery]);
const defaultAgentName = React.useMemo(() => {
@@ -558,38 +578,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return result;
}, [providers, hiddenModels]);
const normalizeModelSearchValue = React.useCallback((value: string) => {
const lower = value.toLowerCase().trim();
const compact = lower.replace(/[^a-z0-9]/g, '');
const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean);
return { lower, compact, tokens };
}, []);
const matchesModelSearch = React.useCallback((candidate: string, query: string) => {
const normalizedQuery = normalizeModelSearchValue(query);
if (!normalizedQuery.lower) {
return true;
}
const normalizedCandidate = normalizeModelSearchValue(candidate);
if (normalizedCandidate.lower.includes(normalizedQuery.lower)) {
return true;
}
if (normalizedQuery.compact.length >= 2 && normalizedCandidate.compact.includes(normalizedQuery.compact)) {
return true;
}
if (normalizedQuery.tokens.length === 0) {
return false;
}
return normalizedQuery.tokens.every((queryToken) =>
normalizedCandidate.tokens.some((candidateToken) =>
candidateToken.startsWith(queryToken) || candidateToken.includes(queryToken)
)
);
}, [normalizeModelSearchValue]);
const matchesModelSearch = React.useCallback(
(candidate: string, query: string) => matchesRankQuery([candidate], query),
[],
);
const currentModelForMetadata = currentModelId
? models.find((model: ProviderModel) => model.id === currentModelId)
@@ -641,7 +633,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
];
const prevAgentNameRef = React.useRef<string | undefined>(undefined);
const explicitAgentSwitchRef = React.useRef<string | null>(null);
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
@@ -704,6 +695,30 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return variants ? Object.keys(variants) : [];
}, [providers]);
const resolveInheritedVariantForModel = React.useCallback((providerId: string, modelId: string, agentName?: string | null) => {
const variantOptions = getModelVariantOptions(providerId, modelId);
if (variantOptions.length === 0) return undefined;
let currentInherited: string | undefined;
if (currentProviderId === providerId && currentModelId === modelId) {
currentInherited = currentVariantSelection.inherited
?? (currentVariantSelection.override === null || currentVariantSelection.override === undefined
? effectiveCurrentVariant
: undefined);
}
const effectiveAgentName = agentName ?? uiAgentName ?? currentAgentName;
const agent = effectiveAgentName ? agents.find((candidate) => candidate.name === effectiveAgentName) : undefined;
const agentVariant = (
agent?.model?.providerID === providerId
&& agent.model.modelID === modelId
) ? agent.variant : undefined;
const candidates = currentSessionId
? [agentVariant, settingsDefaultVariant, currentInherited]
: [currentInherited, agentVariant, settingsDefaultVariant];
return candidates.find((candidate) => candidate !== undefined && variantOptions.includes(candidate));
}, [agents, currentAgentName, currentModelId, currentProviderId, currentSessionId, currentVariantSelection, effectiveCurrentVariant, getModelVariantOptions, settingsDefaultVariant, uiAgentName]);
const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string) => {
const variantOptions = getModelVariantOptions(providerId, modelId);
if (variantOptions.length === 0) {
@@ -722,10 +737,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return currentVariant;
}
if (!currentSessionId && settingsDefaultVariant && variantOptions.includes(settingsDefaultVariant)) {
return settingsDefaultVariant;
}
return undefined;
}, [
currentAgentName,
@@ -735,7 +746,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentVariant,
getAgentModelVariantForSession,
getModelVariantOptions,
settingsDefaultVariant,
uiAgentName,
]);
@@ -759,7 +769,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
manualVariantSelectionRef.current = true;
setCurrentVariant(variant);
setCurrentVariantOverride(
variant ?? null,
resolveInheritedVariantForModel(providerId, modelId, agentNameOverride),
);
addRecentEffort(providerId, modelId, variant);
const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName();
@@ -770,9 +783,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
addRecentEffort,
currentSessionId,
getModelVariantOptions,
resolveInheritedVariantForModel,
resolveLiveAgentName,
saveAgentModelVariantForSession,
setCurrentVariant,
setCurrentVariantOverride,
]);
const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => {
@@ -893,25 +908,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
? useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
: null;
if (savedAgentName) {
if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
const savedModel = getAgentModelForSession(currentSessionId, savedAgentName);
if (savedModel) {
const result = tryApplyModelSelection(savedModel.providerId, savedModel.modelId, savedAgentName);
if (result === 'applied') {
if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
return 'resolved';
}
if (result === 'provider-missing') {
return 'waiting';
}
} else if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
}
if (savedSessionModel) {
const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined);
if (result === 'applied') {
if (savedAgentName && currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
return 'resolved';
}
if (result === 'provider-missing') {
@@ -925,16 +944,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
continue;
}
if (currentAgentName !== agent.name) {
setAgent(agent.name);
}
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, agent.name);
}
const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name);
if (result === 'applied') {
if (currentAgentName !== agent.name) {
setAgent(agent.name);
}
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, agent.name);
}
return 'resolved';
}
if (result === 'provider-missing') {
@@ -1032,9 +1050,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
prevAgentNameRef.current = currentAgentName;
if (currentAgentName && currentSessionId) {
const shouldPreferAgentModel = explicitAgentSwitchRef.current === currentAgentName;
explicitAgentSwitchRef.current = null;
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 50);
abortController.signal.addEventListener('abort', () => {
@@ -1047,33 +1062,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return;
}
const selectedAgent = shouldPreferAgentModel
? agents.find((agent) => agent.name === currentAgentName)
: undefined;
if (selectedAgent?.model?.providerID && selectedAgent.model.modelID) {
const result = tryApplyModelSelection(
selectedAgent.model.providerID,
selectedAgent.model.modelID,
currentAgentName,
);
if (result === 'applied' || result === 'provider-missing') {
if (result === 'applied') {
saveSessionModelSelection(
currentSessionId,
selectedAgent.model.providerID,
selectedAgent.model.modelID,
);
saveAgentModelForSession(
currentSessionId,
currentAgentName,
selectedAgent.model.providerID,
selectedAgent.model.modelID,
);
}
return;
}
}
const persistedChoice = getAgentModelForSession(currentSessionId, currentAgentName);
if (persistedChoice) {
@@ -1099,12 +1087,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
abortController.abort();
};
}, [
agents,
currentAgentName,
currentSessionId,
getAgentModelForSession,
saveAgentModelForSession,
saveSessionModelSelection,
tryApplyModelSelection,
contextHydrated,
]);
@@ -1129,18 +1114,21 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
if (currentVariant && !availableVariants.includes(currentVariant)) {
setCurrentVariant(undefined);
setCurrentVariantOverride(
null,
resolveInheritedVariantForModel(currentProviderId, currentModelId),
);
return;
}
// Draft state (no session yet): seed from settings default, but don't override
// user selection while drafting.
if (!currentSessionId) {
if (!currentVariant && !manualVariantSelectionRef.current) {
if (currentVariantSelection.override === undefined && !manualVariantSelectionRef.current) {
const desired = settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
? settingsDefaultVariant
: undefined;
setCurrentVariant(desired);
setCurrentVariantOverride(desired ?? null, desired);
}
return;
}
@@ -1152,13 +1140,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentModelId,
);
const resolvedSaved = savedVariant && availableVariants.includes(savedVariant)
? savedVariant
: settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
? settingsDefaultVariant
: undefined;
setCurrentVariant(resolvedSaved);
const inheritedVariant = resolveInheritedVariantForModel(currentProviderId, currentModelId);
if (savedVariant && availableVariants.includes(savedVariant)) {
setCurrentVariantOverride(savedVariant, inheritedVariant);
} else if (currentVariantSelection.override === null) {
setCurrentVariantOverride(null, inheritedVariant);
} else {
setCurrentVariant(inheritedVariant);
}
manualVariantSelectionRef.current = false;
}, [
availableVariants,
@@ -1168,8 +1157,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentProviderId,
currentModelId,
currentVariant,
currentVariantSelection.override,
effectiveCurrentVariant,
getAgentModelVariantForSession,
resolveInheritedVariantForModel,
setCurrentVariant,
setCurrentVariantOverride,
settingsDefaultVariant,
]);
@@ -1185,7 +1178,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => {
try {
explicitAgentSwitchRef.current = agentName;
setAgent(agentName);
addRecentAgent(agentName);
if (options?.closeModelSelector ?? true) {
@@ -2256,7 +2248,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
: 'Default';
return (
<span className={cn('typography-micro whitespace-nowrap', wasAdjusted ? 'text-foreground' : 'text-muted-foreground')}>
<span className={cn(
'typography-micro whitespace-nowrap',
isHighlighted
? (wasAdjusted ? 'text-interactive-selection-foreground' : 'text-interactive-selection-foreground/70')
: (wasAdjusted ? 'text-foreground' : 'text-muted-foreground'),
)}>
Thinking: {displayLabel}
</span>
);
@@ -2316,9 +2313,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col"
side="top"
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col overflow-hidden"
align="end"
alignOffset={-40}
constrainToMain
collisionAvoidance={{ side: 'none', align: 'shift' }}
onKeyDownCapture={handleModelShortcutKeyDownCapture}
>
<div className="p-1 border-b border-border/40">
@@ -2375,6 +2375,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
);
}}
maxHeightClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1"
tooltipsEnabled={agentMenuOpen}
onEscape={() => setAgentMenuOpen(false)}
/>
@@ -2618,7 +2619,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuContent side="top" align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">{t('chat.modelControls.thinking')}</DropdownMenuLabel>
<DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
@@ -2708,7 +2709,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col">
<DropdownMenuContent side="top" align="end" alignOffset={-40} constrainToMain collisionAvoidance={{ side: 'none', align: 'shift' }} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col overflow-hidden">
<div className="p-2 border-b border-border/40">
<div className="relative">
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
@@ -2724,7 +2725,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
/>
</div>
</div>
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1">
<div className="p-1">
{!agentSearchQuery.trim() && defaultAgentName && (
<>
@@ -2746,12 +2747,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
) : (
sortedAndFilteredAgents.map((agent) => (
<DropdownMenuItem
key={agent.name}
className="typography-meta"
onSelect={() => handleAgentChange(agent.name)}
>
<div className="flex flex-col gap-0.5">
<AgentDescriptionTooltip key={agent.name} description={agent.description}>
<DropdownMenuItem
className="typography-meta"
onSelect={() => handleAgentChange(agent.name)}
>
<div className="flex items-center gap-1.5">
<div className={cn(
'h-1 w-1 rounded-full agent-dot',
@@ -2759,13 +2759,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
)} />
<span className="font-medium">{capitalizeAgentName(agent.name)}</span>
</div>
{agent.description && (
<span className="typography-meta text-muted-foreground max-w-[200px] ml-2.5 break-words">
{agent.description}
</span>
)}
</div>
</DropdownMenuItem>
</DropdownMenuItem>
</AgentDescriptionTooltip>
))
)}
</div>
@@ -107,7 +107,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
>
<Icon name="file-edit" className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">
<span className="composer-status-bar__changed-label min-w-0 typography-ui-label text-foreground truncate">
{t('chat.pendingChanges.changedInWorkspace')}
</span>
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
@@ -10,6 +10,10 @@ import { Icon } from "@/components/icon/Icon";
import { DiffPreview, WritePreview } from './DiffPreview';
import { useI18n } from '@/lib/i18n';
import { getVisiblePermissionPatterns } from './permissionCardPatterns';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
// Newest pending card owns the keyboard; older cards wait their turn.
const activePermissionCardIds: string[] = [];
const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = {
margin: 0,
@@ -66,6 +70,14 @@ const getToolIcon = (toolName: string) => {
return <Icon name="global" className={iconClass} />;
}
if (tool === 'linear' || tool.startsWith('linear_')) {
return <Icon name="linear" className={iconClass} />;
}
if (tool === 'cloudflare' || tool.startsWith('cloudflare_') || tool === 'claudflare' || tool.startsWith('claudflare_')) {
return <Icon name="cloudflare" className={iconClass} />;
}
return <Icon name="tools" className={iconClass} />;
};
@@ -118,6 +130,33 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
}
};
const handleResponseRef = React.useRef(handleResponse);
handleResponseRef.current = handleResponse;
React.useEffect(() => {
if (hasResponded) return;
activePermissionCardIds.push(permission.id);
const handleKeyDown = (event: KeyboardEvent) => {
if (activePermissionCardIds.at(-1) !== permission.id) return;
if (!event.altKey || event.metaKey || event.ctrlKey) return;
const response = event.key === 'Enter'
? (event.shiftKey ? 'always' as const : 'once' as const)
: event.key === 'Backspace' && !event.shiftKey
? 'reject' as const
: null;
if (!response) return;
event.preventDefault();
event.stopPropagation();
void handleResponseRef.current(response);
};
window.addEventListener('keydown', handleKeyDown, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
const index = activePermissionCardIds.lastIndexOf(permission.id);
if (index !== -1) activePermissionCardIds.splice(index, 1);
};
}, [hasResponded, permission.id]);
if (hasResponded) {
return null;
}
@@ -372,6 +411,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
>
<Icon name="check" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
Allow Once
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+enter')}</kbd>
</button>
{permission.always.length > 0 ? (
@@ -428,6 +468,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
>
<Icon name="time" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
Always Allow
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+shift+enter')}</kbd>
</button>
)}
@@ -451,6 +492,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
>
<Icon name="close" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
Deny
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+backspace')}</kbd>
</button>
{isResponding && (
@@ -15,6 +15,7 @@ import * as sessionActions from '@/sync/session-actions';
import { useI18n } from '@/lib/i18n';
import { serializeQuestionAsJson, serializeQuestionAsMarkdown } from './questionSerializers';
import { QUESTION_CUSTOM_TEXTAREA_MIN_HEIGHT, getQuestionCustomTextareaHeight } from './questionTextareaSizing';
import { QuestionMarkdown } from './QuestionMarkdown';
interface QuestionCardProps {
question: QuestionRequest;
@@ -423,7 +424,11 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
</div>
) : activeQuestion ? (
<>
<div className="typography-meta font-medium text-foreground mb-1.5">{activeQuestion.question}</div>
<QuestionMarkdown
content={activeQuestion.question}
size="meta"
className="font-medium text-foreground mb-1.5"
/>
{isMultiple ? (
<div className="typography-micro text-muted-foreground mb-1.5">{t('chat.questionCard.selectMultiple')}</div>
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { QuestionMarkdown } from './QuestionMarkdown';
// The markdown renderer is lazy, so a synchronous server render always emits the
// Suspense fallback QuestionMarkdown supplies. That fallback is the surface that
// has to keep the exact question text and the question typography classes.
describe('QuestionMarkdown', () => {
test('renders the question content verbatim', () => {
const content = 'Choose **one** from `mode`: [details](https://example.com)';
const html = renderToStaticMarkup(<QuestionMarkdown content={content} size="meta" />);
expect(html).toBe(
`<div class="question-markdown typography-meta whitespace-pre-wrap">${content}</div>`,
);
});
test('applies meta typography and caller classes', () => {
const html = renderToStaticMarkup(
<QuestionMarkdown content="Meta" size="meta" className="font-medium text-foreground" />,
);
expect(html).toContain('class="question-markdown typography-meta font-medium text-foreground whitespace-pre-wrap"');
});
test('applies micro typography and caller classes', () => {
const html = renderToStaticMarkup(
<QuestionMarkdown content="Micro" size="micro" className="text-muted-foreground" />,
);
expect(html).toContain('class="question-markdown typography-micro text-muted-foreground whitespace-pre-wrap"');
});
});
@@ -0,0 +1,23 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from './MarkdownRenderer';
interface QuestionMarkdownProps {
content: string;
size: 'meta' | 'micro';
className?: string;
}
export function QuestionMarkdown({ content, size, className }: QuestionMarkdownProps) {
const classes = cn('question-markdown', size === 'meta' ? 'typography-meta' : 'typography-micro', className);
return (
<SimpleMarkdownRenderer
content={content}
variant="tool"
className={classes}
fallbackContent={<div className={cn(classes, 'whitespace-pre-wrap')}>{content}</div>}
/>
);
}
@@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useSessionGoal } from '@/hooks/useSessionGoal';
import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore';
import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata';
import { sessionGoalStatusColor } from '@/lib/sessionGoalPresentation';
import { SessionGoalDialog } from '@/components/chat/SessionGoalDialog';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
@@ -50,12 +51,13 @@ export const SessionGoalButton: React.FC<SessionGoalButtonProps> = React.memo(({
const liveGoal = goal && goal.status !== 'complete' ? goal : null;
const isEngaged = armed || Boolean(liveGoal);
const colorClass = (() => {
if (goal?.status === 'complete') return 'text-[var(--status-success)]';
if (goal?.status === 'blocked' || goal?.status === 'budgetLimited') return 'text-[var(--status-error)]';
if (armed || goal?.status === 'active' || goal?.status === 'paused') return 'text-[var(--status-info)]';
return '';
})();
// One mapping for every goal surface. This button used to carry its own,
// which painted `paused` the same info colour as `active` — so a paused goal
// was indistinguishable from a running one — and `blocked` as an error rather
// than a warning. `armed` is not a goal status, so it keeps its own case.
const iconColor = goal
? sessionGoalStatusColor[goal.status]
: (armed ? 'var(--status-info)' : undefined);
const label = goal
? t('chat.goal.button.manageAria')
@@ -74,7 +76,8 @@ export const SessionGoalButton: React.FC<SessionGoalButtonProps> = React.memo(({
const button = (
<button
type="button"
className={cn(footerIconButtonClass, colorClass)}
className={footerIconButtonClass}
style={iconColor ? { color: iconColor } : undefined}
onClick={handleClick}
// Same guard as PermissionAutoAcceptButton, but only for the ARM
// toggle: arming happens mid-typing (the next message IS the
@@ -4,6 +4,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
import { useUIStore } from '@/stores/useUIStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
interface SkillInfo {
name: string;
@@ -31,7 +32,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
}, ref) => {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const keyboardNavigationRef = React.useRef(false);
@@ -126,6 +127,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
const isProject = skill.scope === 'project';
const source = skill.source || 'opencode';
return (
<AutocompleteRowTooltip description={skill.description} active={!isMobile && index === selectedIndex}>
<div
key={`${skill.name}-${skill.scope}`}
ref={(el) => {
@@ -157,13 +159,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
{source}
</span>
</div>
{skill.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{skill.description}
</div>
)}
</div>
</div>
</AutocompleteRowTooltip>
);
};
@@ -32,7 +32,7 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]);
+19 -327
View File
@@ -1,141 +1,25 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { cn } from "@/lib/utils";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
// Compat aliases for old TodoItem shape
type TodoItem = Todo & { id?: string };
type TodoStatus = string;
type TodoPriority = string;
import { useUIStore } from "@/stores/useUIStore";
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Icon } from "@/components/icon/Icon";
import { useI18n } from "@/lib/i18n";
// The floating assistant-status chip that hovers above the composer while the
// agent works ("Claude is working…"). ONLY that. The composer's
// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
// to share this component, and every restyle of this chip (glass, placement)
// silently dragged the composer bar and its dropdown along with it.
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
in_progress: {
textClassName: "text-foreground",
},
pending: {
textClassName: "text-foreground",
},
completed: {
textClassName: "text-muted-foreground line-through",
},
cancelled: {
textClassName: "text-muted-foreground line-through",
},
};
const priorityClassName: Record<TodoPriority, string> = {
high: "text-[var(--status-warning)]",
medium: "text-muted-foreground",
low: "text-muted-foreground/70",
};
const priorityIcon: Record<TodoPriority, React.ReactNode> = {
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true"/>,
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
};
const statusLabelKey: Record<TodoStatus, string> = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabelKey: Record<TodoPriority, string> = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
interface TodoItemRowProps {
todo: TodoItem;
}
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[todo.status] || statusConfig.pending;
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
const statusIcon =
todo.status === "in_progress" ? (
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true"/>
) : todo.status === "completed" ? (
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true"/>
) : (
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true"/>
);
return (
<div className="flex items-center min-w-0 py-0.5 gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{t(statusKey as never)}
</TooltipContent>
</Tooltip>
<span
className={cn(
"flex-1 typography-ui-label",
config.textClassName
)}
>
{todo.content}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
priorityClassName[todo.priority] ?? priorityClassName.medium
)}
>
{priorityIcon[todo.priority] ?? priorityIcon.medium}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{t(priorityKey as never)}
</TooltipContent>
</Tooltip>
</div>
);
};
const EMPTY_TODOS: TodoItem[] = [];
interface StatusRowProps {
// Working state
isWorking?: boolean;
statusText?: string | null;
isGenericStatus?: boolean;
isWaitingForPermission?: boolean;
wasAborted?: boolean;
abortActive?: boolean;
retryInfo?: { attempt?: number; next?: number } | null;
// Abort state (for mobile/vscode)
showAbort?: boolean;
onAbort?: () => void;
// Abort status display
showAbortStatus?: boolean;
showAssistantStatus?: boolean;
showTodos?: boolean;
agentName?: string;
modelName?: string | null;
providerId?: string | null;
leftAccessory?: React.ReactNode;
}
export const StatusRow: React.FC<StatusRowProps> = ({
@@ -143,186 +27,36 @@ export const StatusRow: React.FC<StatusRowProps> = ({
statusText = null,
isGenericStatus,
isWaitingForPermission,
wasAborted,
abortActive,
retryInfo,
showAbort,
onAbort,
showAbortStatus,
showAssistantStatus = true,
showTodos = true,
agentName,
modelName,
providerId,
leftAccessory,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const liveTodos = useDirectorySync(
React.useCallback(
(state) => {
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
return state.todo[currentSessionId] ?? EMPTY_TODOS;
},
[currentSessionId, showTodos],
),
);
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (showTodos && currentSessionId && currentSessionDirectory
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
: undefined),
[currentSessionDirectory, currentSessionId, showTodos],
),
);
const todos: TodoItem[] = React.useMemo(() => {
if (!currentSessionId) return EMPTY_TODOS;
if (liveTodos.length > 0) return liveTodos;
return persistedSessionTodos ?? EMPTY_TODOS;
}, [liveTodos, persistedSessionTodos, currentSessionId]);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
// Filter out cancelled todos for display and keep original order.
// This prevents items from jumping around when status changes.
const visibleTodos = React.useMemo(() => {
return todos.filter((todo) => todo.status !== "cancelled");
}, [todos]);
const shouldRenderPlaceholder = !abortActive;
const hasContent = isWorking;
// Find the current active todo (first in_progress, or first pending)
const activeTodo = React.useMemo(() => {
return (
visibleTodos.find((t) => t.status === "in_progress") ||
visibleTodos.find((t) => t.status === "pending") ||
null
);
}, [visibleTodos]);
// Calculate progress
const progress = React.useMemo(() => {
const total = todos.filter((t) => t.status !== "cancelled").length;
const completed = todos.filter((t) => t.status === "completed").length;
return { completed, total };
}, [todos]);
const statusSummary = React.useMemo(() => {
const active = visibleTodos.filter((t) => t.status === "in_progress").length;
const left = visibleTodos.filter((t) => t.status === "in_progress" || t.status === "pending").length;
return { active, left };
}, [visibleTodos]);
const hasTodoContent = showTodos && statusSummary.left > 0;
const hasAssistantContent = showAssistantStatus && (
isWorking ||
Boolean(wasAborted) ||
Boolean(showAbortStatus)
);
const hasLeftAccessory = Boolean(leftAccessory);
// Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
// Close popover when clicking outside
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
active: statusSummary.active,
left: statusSummary.left,
});
// Abort button for mobile/vscode
const abortButton = showAbort && onAbort ? (
<button
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label={t('chat.statusRow.actions.stopGeneratingAria')}
>
<Icon name="close-circle" aria-hidden="true"/>
</button>
) : null;
// Todo trigger button
const todoTrigger = hasTodoContent ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
aria-label={todoSummaryLabel}
title={todoSummaryLabel}
>
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
{!isCompact && activeTodo ? (
<span className="status-row__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
<span className="flex items-center gap-0.5">
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
{statusSummary.active}
</span>
<span>·</span>
<span className="flex items-center gap-0.5">
<Icon name="time" className="h-3.5 w-3.5" />
{statusSummary.left}
</span>
</span>
{isExpanded ? (
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
) : (
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
)}
</button>
) : null;
// Don't render if nothing to show
if (!hasContent) {
return null;
}
return (
<div
// Mobile: breathing room between the last message and the agent status
// line — without it the "<model> is running…" row sits flush against
// the message above.
className={cn("mb-1", isMobile && "mt-2", !hasLeftAccessory && "chat-column")}
// The row renders inside the composer-anchored overlay, which owns the
// distance to the input and the horizontal column (the same ones the
// scroll-to-bottom pill uses).
style={STATUS_ROW_CONTAINER_STYLE}
>
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}>
{/* Left: Abort status | Working placeholder | leftAccessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{showAssistantStatus && showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<Icon name="close-circle" aria-hidden="true"/>
{t('chat.statusRow.aborted')}
</span>
</div>
) : showAssistantStatus && shouldRenderPlaceholder ? (
{/* h-8 matches the turn footer's real row height: its h-8 action
buttons define the footer line, with the meta text centered in it. */}
{/* The glass chip lives here, not on the container: the root above is
an inline-size query container, whose width ignores its children
a shrink-to-fit wrapper around it always collapsed to zero. */}
<div className="oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3">
<div className="flex items-center min-w-0 gap-2 overflow-x-hidden">
{shouldRenderPlaceholder ? (
<WorkingPlaceholder
key={currentSessionId ?? "no-session"}
isWorking={isWorking}
@@ -334,50 +68,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
modelName={modelName}
providerId={providerId}
/>
) : leftAccessory ? (
leftAccessory
) : null}
</div>
{/* Right: Abort (mobile only) + Todo */}
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory ? "pr-1.5" : "-mr-3")} ref={popoverRef}>
{abortButton}
{todoTrigger}
{/* Popover dropdown */}
{isExpanded && hasTodoContent && (
<div
style={{
maxWidth: "min(28rem, calc(100cqw - 4ch))",
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
}}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px] rounded-xl p-1",
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150"
)}
>
{/* Header */}
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
{/* Todo list */}
<div className="px-1 max-h-[200px] overflow-y-auto">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
@@ -2,7 +2,6 @@ import React from 'react';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
import { StatusRow } from './StatusRow';
@@ -12,15 +11,6 @@ import { StatusRow } from './StatusRow';
* labels while still limiting subscriptions to the active assistant message.
*/
export const StatusRowContainer: React.FC = React.memo(() => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const abortRecord = useSessionUIStore(
React.useCallback((state) => {
if (!currentSessionId) {
return null;
}
return state.sessionAbortFlags?.get(currentSessionId) ?? null;
}, [currentSessionId]),
);
const { activeModel, working } = useAssistantStatus();
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const providers = useConfigStore((state) => state.providers);
@@ -35,19 +25,14 @@ export const StatusRowContainer: React.FC = React.memo(() => {
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
}, [activeModel, providers]);
const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged);
return (
<StatusRow
isWorking={working.isWorking}
statusText={working.statusText}
isGenericStatus={working.isGenericStatus}
isWaitingForPermission={working.isWaitingForPermission}
wasAborted={wasAborted || working.wasAborted}
abortActive={wasAborted || working.abortActive}
abortActive={working.abortActive}
retryInfo={working.retryInfo}
showAssistantStatus
showTodos={false}
agentName={currentAgentName}
modelName={modelDisplayName}
providerId={activeModel?.providerId ?? null}
@@ -223,20 +223,48 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
if (!currentSessionId) return null;
const turnActions = (
<>
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
void onScrollByTurnOffset?.(-1);
onOpenChange(false);
}}
>
{t('chat.timeline.actions.previousTurn')}
</button>
<span className="text-muted-foreground/50">/</span>
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
onResumeToLatest?.();
onOpenChange(false);
}}
>
{t('chat.timeline.actions.latest')}
</button>
</>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader>
<DialogContent className="max-w-2xl max-h-[70vh] max-md:max-h-[85dvh] flex flex-col overflow-y-auto">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<Icon name="time" className="h-5 w-5" />
{t('chat.timeline.title')}
</DialogTitle>
<DialogDescription>
{t('chat.timeline.description')}
</DialogDescription>
{!isMobile && (
<DialogDescription>
{t('chat.timeline.description')}
</DialogDescription>
)}
</DialogHeader>
<div className="relative mt-2">
<div className="relative mt-2 shrink-0">
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
autoFocus
@@ -249,7 +277,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
</div>
{canLoadEarlier && onLoadEarlier && (
<div className="flex justify-center py-1">
<div className="flex shrink-0 justify-center py-1">
<Button
type="button"
variant="link"
@@ -266,7 +294,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
</div>
)}
<div ref={listRef} className="flex-1 overflow-y-auto">
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto">
{filteredMessages.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
@@ -312,7 +340,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onMouseEnter={() => setSelectedIndex(index)}
>
<span className={cn(
"typography-meta w-16 flex-shrink-0 text-right tabular-nums",
"typography-meta min-w-16 flex-shrink-0 text-right tabular-nums whitespace-nowrap",
isSelected ? "text-interactive-selection-foreground/70" : "text-muted-foreground"
)}>
{messageTime}
@@ -373,45 +401,31 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
)}
</div>
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
<div className="mb-2 flex items-center gap-2">
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
void onScrollByTurnOffset?.(-1);
onOpenChange(false);
}}
>
{t('chat.timeline.actions.previousTurn')}
</button>
<span className="text-muted-foreground/50">/</span>
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
onResumeToLatest?.();
onOpenChange(false);
}}
>
{t('chat.timeline.actions.latest')}
</button>
{isMobile ? (
<div className="mt-2 flex shrink-0 items-center justify-center gap-2 border-t border-border/60 pt-2">
{turnActions}
</div>
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t('chat.timeline.help.clickMessage')}</span>
) : (
<div className="mt-4 p-3 bg-muted/30 rounded-lg shrink-0">
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
<div className="mb-2 flex items-center gap-2">
{turnActions}
</div>
<div className="flex items-center gap-2">
<Icon name="arrow-go-back" className="h-4 w-4 flex-shrink-0" />
<span>{t('chat.timeline.help.undoToPoint')}</span>
</div>
<div className="flex items-center gap-2">
<Icon name="git-branch" className="h-4 w-4 flex-shrink-0" />
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t('chat.timeline.help.clickMessage')}</span>
</div>
<div className="flex items-center gap-2">
<Icon name="arrow-go-back" className="h-4 w-4 flex-shrink-0" />
<span>{t('chat.timeline.help.undoToPoint')}</span>
</div>
<div className="flex items-center gap-2">
<Icon name="git-branch" className="h-4 w-4 flex-shrink-0" />
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
</div>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
@@ -0,0 +1,252 @@
/**
* Regression coverage for https://github.com/openchamber/openchamber/issues/2903
*
* Busy embedded session-chat panels were rendering only the working-status row
* ("…is running command") because ChatContainer gated message reads on the
* same visibility flag used to keep the composer from stealing focus. When the
* iframe booted inactive (or a visibility postMessage was lost),
* useSessionMessageRecords returned [] while session status stayed busy so
* the empty-state branch was skipped and the transcript showed status only.
*
* Idle sessions hit the empty state instead (#2892). Same root cause.
*
* Fix: embedded session-chat keeps `messagesEnabled={true}` so history stays
* subscribed while `active={embeddedBackgroundWorkEnabled}` still gates
* composer focus and background work.
*/
import { describe, expect, mock, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
mock.module('sonner', () => ({
toast: { dismiss: () => undefined, error: () => undefined, info: () => undefined, success: () => undefined },
}));
mock.module('@/components/ui', () => ({
toast: { info: () => undefined, error: () => undefined, success: () => undefined },
}));
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
getDirectory: () => '/repo',
setDirectory: () => undefined,
getSdkClient: () => ({}),
getScopedSdkClient: () => ({}),
},
}));
mock.module('@/stores/permissionStore', () => ({
usePermissionStore: { getState: () => ({ isSessionAutoAccepting: () => false, hydrate: async () => undefined }) },
}));
mock.module('@/stores/useConfigStore', () => ({
useConfigStore: {
getState: () => ({ isConnected: true, hasEverConnected: true, settingsMessageStreamTransport: 'auto' }),
setState: () => undefined,
},
}));
mock.module('@/stores/useTodosPersistStore', () => ({
useTodosPersistStore: { getState: () => ({ setSessionTodos: () => undefined }) },
}));
const { useSessionMessageRecords } = await import('@/sync/sync-context');
const { ChildStoreManager } = await import('@/sync/child-store');
const { getSessionMaterializationStatus } = await import('@/sync/materialization');
import type { State } from '@/sync/types';
const __dirname = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(join(__dirname, '..', '..', '..', 'App.tsx'), 'utf-8');
const chatContainerSource = readFileSync(join(__dirname, '..', 'ChatContainer.tsx'), 'utf-8');
const chatViewSource = readFileSync(join(__dirname, '..', '..', 'views', 'ChatView.tsx'), 'utf-8');
const syncContextSource = readFileSync(join(__dirname, '..', '..', '..', 'sync', 'sync-context.tsx'), 'utf-8');
const SESSION_ID = 'ses_subagent_2903';
const DIRECTORY = '/repo';
const installMinimalDom = () => {
const descriptors = new Map<string, PropertyDescriptor | undefined>();
const setGlobal = (name: string, value: unknown) => {
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
class ElementStub {}
const documentStub: Record<string, unknown> = {
nodeType: 9,
defaultView: globalThis,
activeElement: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
const container = {
nodeType: 1,
tagName: 'DIV',
nodeName: 'DIV',
namespaceURI: 'http://www.w3.org/1999/xhtml',
ownerDocument: documentStub,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
documentStub.documentElement = container;
documentStub.body = container;
setGlobal('document', documentStub);
setGlobal('window', globalThis);
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
setGlobal('Element', ElementStub);
setGlobal('HTMLElement', ElementStub);
setGlobal('HTMLIFrameElement', ElementStub);
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0));
setGlobal('cancelAnimationFrame', (id: ReturnType<typeof setTimeout>) => clearTimeout(id));
return {
container: container as unknown as Element,
restore: () => {
for (const [name, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
};
const createMessage = (id: string, role: 'user' | 'assistant', created: number): Message => ({
id,
sessionID: SESSION_ID,
role,
...(role === 'assistant' ? { parentID: `u_${created}` } : {}),
time: { created },
} as Message);
const createPart = (id: string, messageID: string, text: string): Part => ({
id,
messageID,
sessionID: SESSION_ID,
type: 'text',
text,
} as Part);
/** 14-message subagent transcript, matching the issue reproduction fixture. */
const buildMaterializedSubagentSession = () => {
const messages: Message[] = [];
const part: Record<string, Part[]> = {};
for (let index = 0; index < 14; index += 1) {
const created = index + 1;
const role: 'user' | 'assistant' = created % 2 === 1 ? 'user' : 'assistant';
const id = role === 'user' ? `u_${created}` : `a_${created}`;
messages.push(createMessage(id, role, created));
part[id] = [createPart(`prt_${id}`, id, role === 'user' ? `prompt ${created}` : `output ${created}`)];
}
return { messages, part };
};
const syncContext = (globalThis as unknown as {
__openchamber_sync_context__?: React.Context<unknown>;
}).__openchamber_sync_context__;
if (!syncContext) {
throw new Error('sync context was not published on globalThis by @/sync/sync-context');
}
describe('issue #2903 busy embedded subagent status-line-only', () => {
test('cold disabled reads hide a fully materialized 14-message subagent; enabled reads return all 14', async () => {
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const childStores = new ChildStoreManager();
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
const { messages, part } = buildMaterializedSubagentSession();
store.setState({
status: 'complete',
session: [{
id: SESSION_ID,
title: 'Audit Searchbar implementation',
time: { created: 1, updated: 1 },
version: '1',
directory: DIRECTORY,
} as State['session'][number]],
message: { [SESSION_ID]: messages },
part,
} as Partial<State>);
expect(getSessionMaterializationStatus(store.getState(), SESSION_ID)).toEqual({
hasMessages: true,
renderable: true,
missingPartMessageIDs: [],
});
const system = { childStores, messageLoader: {}, sdk: {}, runtimeKey: 'test', directory: DIRECTORY };
const Provider = syncContext.Provider as React.Provider<unknown>;
let inactiveCount = -1;
let activeCount = -1;
let enabled = false;
const Harness = () => {
const records = useSessionMessageRecords(SESSION_ID, DIRECTORY, { enabled });
if (enabled) {
activeCount = records.length;
} else {
inactiveCount = records.length;
}
return null;
};
try {
await act(async () => {
root.render(React.createElement(Provider, { value: system }, React.createElement(Harness)));
});
expect(inactiveCount).toBe(0);
enabled = true;
await act(async () => {
root.render(React.createElement(Provider, { value: system }, React.createElement(Harness)));
});
expect(activeCount).toBe(14);
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
test('sync gate still returns empty on cold disabled reads', () => {
const hookStart = syncContextSource.indexOf('export function useSessionMessageRecords(');
const hookBody = syncContextSource.slice(hookStart, hookStart + 1800);
expect(hookBody).toContain('if (options?.enabled === false)');
expect(hookBody).toContain('EMPTY_SESSION_MESSAGE_RECORDS');
expect(hookBody).toContain('snapshotRef.current.sessionID === sessionID ? snapshotRef.current.list');
});
test('embedded session-chat keeps message history enabled while visibility gates active', () => {
expect(appSource).toContain('messagesEnabled={true}');
expect(appSource).toContain('active={embeddedBackgroundWorkEnabled}');
expect(appSource).toContain('const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(false);');
expect(chatViewSource).toContain('messagesEnabled?: boolean');
expect(chatContainerSource).toContain('messagesEnabled: messagesEnabledProp');
expect(chatContainerSource).toContain('const messagesEnabled = messagesEnabledProp ?? active;');
expect(chatContainerSource).toContain('enabled: messagesEnabled');
expect(chatContainerSource.includes('enabled: active')).toBe(false);
expect(chatContainerSource).toContain('if (!messagesEnabled || !currentSessionId) return;');
expect(chatContainerSource).toContain('void ensureSessionRenderable(currentSessionId);');
});
test('the empty and idle branch leaves the status row to the busy path', () => {
// A busy session with no messages yet must fall through to the viewport so
// StatusRowContainer is the only thing on screen. The idle branch returns
// before it and must not render one of its own. The empty state itself no
// longer lives here: the draft surface owns it since the draft transition
// animation landed.
expect(chatContainerSource).toContain('if (sessionMessages.length === 0 && !sessionIsWorking)');
expect(chatContainerSource).toContain('<StatusRowContainer />');
const emptyIdleGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
const emptyIdleReturn = chatContainerSource.indexOf(emptyIdleGuard);
expect(emptyIdleReturn).toBeGreaterThan(-1);
const emptyIdleBlock = chatContainerSource.slice(
emptyIdleReturn,
emptyIdleReturn + 1600,
);
expect(emptyIdleBlock).not.toContain('<StatusRowContainer />');
});
test('visibility handshake remains as defense-in-depth for background work', () => {
expect(appSource).toContain('requestEmbeddedSessionVisibility();');
expect(appSource).toContain('EMBEDDED_VISIBILITY_UPDATE');
});
});
@@ -0,0 +1,33 @@
/**
* Regression coverage for https://github.com/openchamber/openchamber/issues/3036.
*
* Restoring persisted agent/model pairs used to switch agents before checking
* whether each model still existed. Several stale pairs could therefore keep
* changing the active agent on every effect pass until React hit its nested
* update limit. The API error belongs in the assistant message; an invalid
* persisted pair must not mutate the current selection while it is rendered.
*/
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const modelControlsSource = readFileSync(join(__dirname, '..', 'ModelControls.tsx'), 'utf-8');
describe('issue #3036 stale persisted models', () => {
test('changes the agent only after its persisted model is accepted', () => {
const candidateLoop = modelControlsSource.slice(
modelControlsSource.indexOf('for (const agent of agents)'),
modelControlsSource.indexOf("return 'continue';"),
);
const applyIndex = candidateLoop.indexOf('const result = tryApplyModelSelection');
const acceptedIndex = candidateLoop.indexOf("if (result === 'applied')");
const setAgentIndex = candidateLoop.indexOf('setAgent(agent.name)');
expect(applyIndex).toBeGreaterThanOrEqual(0);
expect(acceptedIndex).toBeGreaterThan(applyIndex);
expect(setAgentIndex).toBeGreaterThan(acceptedIndex);
});
});
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
import {
getAppLinkConfirmationSnapshot,
openAppLinkWithConfirmation,
settleAppLinkConfirmation,
} from './appLinkConfirmation';
describe('app link confirmation', () => {
beforeEach(() => {
useAppLinkTrustStore.setState({ trustedSchemes: [] });
const pending = getAppLinkConfirmationSnapshot();
if (pending) {
settleAppLinkConfirmation('cancel');
}
});
test('opens trusted schemes without asking', async () => {
useAppLinkTrustStore.getState().trustScheme('obsidian');
await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes');
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true);
});
test('asks once and trusts the scheme when the user chooses trust', async () => {
const pending = openAppLinkWithConfirmation('linear://issue/ABC-1');
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1');
settleAppLinkConfirmation('trust');
await pending;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true);
});
test('cancel opens nothing and keeps the scheme untrusted', async () => {
const pending = openAppLinkWithConfirmation('notion://note/xyz');
settleAppLinkConfirmation('cancel');
await pending;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false);
});
test('a newer request cancels the pending one', async () => {
const first = openAppLinkWithConfirmation('obsidian://open?vault=a');
const firstChoice = first.then(
() => 'settled',
() => 'settled',
);
const second = openAppLinkWithConfirmation('linear://open/1');
expect(await firstChoice).toBe('settled');
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1');
settleAppLinkConfirmation('open');
await second;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
});
});
@@ -0,0 +1,71 @@
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url';
export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel';
type PendingAppLinkRequest = {
url: string;
resolve: (choice: AppLinkConfirmationChoice) => void;
};
let pendingRequest: PendingAppLinkRequest | null = null;
const listeners = new Set<() => void>();
const emitChange = (): void => {
for (const listener of listeners) {
listener();
}
};
const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest;
const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
/**
* Ask the user (via the app-level confirmation dialog) whether an application
* deep link may be opened. Resolves immediately when the scheme was trusted
* earlier. Only one request is active at a time; a new request cancels the
* pending one.
*/
export const openAppLinkWithConfirmation = (url: string): Promise<void> => {
const scheme = getUrlScheme(url);
if (!scheme) {
return Promise.resolve();
}
const trustStore = useAppLinkTrustStore.getState();
if (trustStore.isSchemeTrusted(scheme)) {
return openConfirmedAppLinkUrl(url).then(() => undefined);
}
if (pendingRequest) {
pendingRequest.resolve('cancel');
}
return new Promise<AppLinkConfirmationChoice>((resolve) => {
pendingRequest = { url, resolve };
emitChange();
}).then((choice) => {
if (choice === 'trust') {
useAppLinkTrustStore.getState().trustScheme(scheme);
}
if (choice === 'open' || choice === 'trust') {
return openConfirmedAppLinkUrl(url).then(() => undefined);
}
});
};
export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => {
const request = pendingRequest;
pendingRequest = null;
emitChange();
request?.resolve(choice);
};
export const subscribeAppLinkConfirmation = subscribe;
export const getAppLinkConfirmationSnapshot = getSnapshot;
@@ -0,0 +1,93 @@
import { describe, expect, test } from 'bun:test';
import { attachAppLinkInteractions } from './appLinkInteractions';
const TestElement = class Element {};
const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {};
Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement });
class TestAnchor extends HTMLAnchorElement {
constructor(private readonly rawHref: string) {
super();
}
getAttribute(name: string): string | null {
return name === 'href' ? this.rawHref : null;
}
closest(): TestAnchor {
return this;
}
}
class TestContainer {
listeners = new Map<string, EventListener>();
addEventListener(name: string, listener: (event: MouseEvent) => void): void {
// SAFETY: dispatch constructs every mouse field read by the production listener.
this.listeners.set(name, (event) => listener(event as MouseEvent));
}
removeEventListener(name: string, listener: (event: MouseEvent) => void): void {
void listener;
this.listeners.delete(name);
}
dispatch(name: string, href: string, init: Partial<MouseEvent> = {}): Event {
const event = new Event(name, { cancelable: true });
Object.defineProperties(event, {
target: { value: new TestAnchor(href) },
button: { value: init.button ?? 0 },
metaKey: { value: init.metaKey ?? false },
ctrlKey: { value: init.ctrlKey ?? false },
altKey: { value: init.altKey ?? false },
shiftKey: { value: init.shiftKey ?? false },
});
this.listeners.get(name)?.(event);
return event;
}
}
const setup = (allowExternalHttp = true) => {
const container = new TestContainer();
const appLinks: string[] = [];
const httpLinks: string[] = [];
const cleanup = attachAppLinkInteractions(container, {
allowExternalHttp,
openAppLink: (url) => appLinks.push(url),
openExternalHttp: (url) => httpLinks.push(url),
});
return { container, appLinks, httpLinks, cleanup };
};
describe('app link interactions', () => {
test('confirms plain, modifier, and middle-click activations', () => {
const { container, appLinks } = setup();
const href = 'obsidian://open?vault=Notes';
expect(container.dispatch('click', href).defaultPrevented).toBe(true);
expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true);
expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true);
expect(appLinks).toEqual([href, href, href]);
});
test('blocks drag activation without opening immediately', () => {
const { container, appLinks } = setup();
const href = 'obsidian://open?vault=Notes';
expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true);
expect(appLinks).toEqual([]);
});
test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => {
const enabled = setup();
const disabled = setup(false);
const href = 'https://example.com';
expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false);
expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true);
expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false);
expect(enabled.httpLinks).toEqual([href]);
expect(disabled.httpLinks).toEqual([]);
});
});
@@ -0,0 +1,75 @@
import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url';
type AppLinkInteractionOptions = {
allowExternalHttp: boolean;
openAppLink: (url: string) => void;
openExternalHttp: (url: string) => void;
};
type LinkInteractionContainer = {
addEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
};
const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => {
const target = event.target;
if (!(target instanceof Element)) return null;
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return null;
if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null;
return anchor;
};
const interceptAppLink = (
event: MouseEvent | DragEvent,
openAppLink?: (url: string) => void,
): boolean => {
if (event.defaultPrevented) return false;
const anchor = findLink(event);
const href = anchor?.getAttribute('href') ?? '';
if (!isAppLinkUrl(href)) return false;
event.preventDefault();
event.stopPropagation();
openAppLink?.(href);
return true;
};
const isPlainPrimaryClick = (event: MouseEvent): boolean => (
event.button === 0
&& !event.metaKey
&& !event.ctrlKey
&& !event.altKey
&& !event.shiftKey
);
export const attachAppLinkInteractions = (
container: LinkInteractionContainer,
options: AppLinkInteractionOptions,
): (() => void) => {
const handleClick = (event: MouseEvent) => {
if (interceptAppLink(event, options.openAppLink)) return;
if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return;
const href = findLink(event)?.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) return;
event.preventDefault();
event.stopPropagation();
options.openExternalHttp(href);
};
const handleAuxClick = (event: MouseEvent) => {
if (event.button === 1) interceptAppLink(event, options.openAppLink);
};
const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => {
interceptAppLink(event);
};
container.addEventListener('click', handleClick);
container.addEventListener('auxclick', handleAuxClick);
container.addEventListener('dragstart', blockAlternateAppLinkActivation);
return () => {
container.removeEventListener('click', handleClick);
container.removeEventListener('auxclick', handleAuxClick);
container.removeEventListener('dragstart', blockAlternateAppLinkActivation);
};
};
@@ -0,0 +1,477 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { useBtwStore } from '@/stores/useBtwStore';
import { useSync } from '@/sync/use-sync';
import {
useSessionMessageRecords,
useSessionRenderable,
useSessionStatus,
useScopedBlockingPermissions,
useScopedBlockingQuestions,
} from '@/sync/sync-context';
import { useStreamingStore } from '@/sync/streaming';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { destroyBtwSession, filterBtwTailMessages, promoteBtwSession, type BtwSessionRef } from '@/lib/btw';
import type { BtwPanelState } from './useBtwPanelState';
import { ChatSurfaceProvider } from '../ChatSurfaceContext';
import { useMobileAutocompleteMaxHeight } from '../useMobileAutocompleteMaxHeight';
import ChatMessage from '../ChatMessage';
import { PermissionCard } from '../PermissionCard';
import { QuestionCard } from '../QuestionCard';
const IDLE_SESSION_STATUS = { type: 'idle' as const };
/**
* The `/btw` peek panel.
*
* Rendered from inside the composer form, so the sheet docks exactly above
* the main composer (`absolute bottom-full` on the composer column) on both
* desktop and mobile the main composer IS the btw input, so nothing may
* cover it. Identity is derived from the parent session's metadata (see
* `useBtwPanelState`), so the panel belongs to one parent session only.
*
* Three exits: collapse (panel minimizes to the composer chip, the composer
* returns to the main session), promote (the fork becomes a normal session
* and the app navigates to it), destroy (the fork is deleted; the main
* conversation is never touched).
*/
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState }> = ({
parentSessionId,
panel,
}) => {
const { t } = useI18n();
if (panel.btwSessionId && panel.btwDirectory) {
return (
<BtwSheet
sessionRef={{
parentSessionId,
btwSessionId: panel.btwSessionId,
directory: panel.btwDirectory,
}}
title={panel.btwSession?.title?.trim() || t('chat.btw.titleFallback')}
boundaryMessageID={panel.boundaryMessageID}
collapsed={panel.collapsed}
/>
);
}
if (panel.creating) {
return (
<BtwFrame title={t('chat.btw.titleFallback')}>
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
</div>
</BtwFrame>
);
}
return null;
};
const useBtwDestroy = (sessionRef: BtwSessionRef | null): (() => void) => {
const { t } = useI18n();
return React.useCallback(() => {
if (!sessionRef) return;
void destroyBtwSession(sessionRef).then((ok) => {
if (!ok) toast.error(t('chat.btw.toast.destroyFailed'));
});
}, [sessionRef, t]);
};
type BtwSessionData = {
messageRecords: Array<{ info: Message; parts: Part[] }>;
sessionIsWorking: boolean;
streamingMessageId: string | null;
activeStreamingPhase: 'streaming' | 'cooldown' | 'completed' | null;
sessionPermissions: ReturnType<typeof useScopedBlockingPermissions>;
sessionQuestions: ReturnType<typeof useScopedBlockingQuestions>;
isEmpty: boolean;
};
/**
* Live session data for the fork, all keyed by the fork's own ids. Only the
* fork's tail (messages after the inherited-history boundary) is shown.
*/
const useBtwSessionData = (
sessionId: string,
directory: string,
boundaryMessageID: string | null,
): BtwSessionData => {
const sync = useSync();
const renderable = useSessionRenderable(sessionId, directory);
React.useEffect(() => {
if (!renderable) {
void sync.ensureSessionRenderable(sessionId, false, directory);
}
}, [directory, renderable, sessionId, sync]);
const messageRecords = useSessionMessageRecords(sessionId, directory);
const status = useSessionStatus(sessionId, directory) ?? IDLE_SESSION_STATUS;
const streamingMessageId = useStreamingStore(
React.useCallback((s) => s.streamingMessageIds.get(sessionId) ?? null, [sessionId]),
);
const activeStreamingPhase = useStreamingStore(
React.useCallback(
(s) => (streamingMessageId ? s.messageStreamStates.get(streamingMessageId)?.phase ?? null : null),
[streamingMessageId],
),
);
const sessionPermissions = useScopedBlockingPermissions(sessionId, directory);
const sessionQuestions = useScopedBlockingQuestions(sessionId, directory);
const tailRecords = React.useMemo(
() => filterBtwTailMessages(messageRecords, boundaryMessageID),
[boundaryMessageID, messageRecords],
);
const sessionIsWorking = React.useMemo(() => {
if (sessionPermissions.length > 0 || sessionQuestions.length > 0) {
return false;
}
const statusType = status.type ?? 'idle';
if (statusType === 'busy' || statusType === 'retry') {
return true;
}
// SAFETY: reads only the optional `time.completed` field, which the
// SDK Message union does not expose uniformly; a missing value means
// the assistant turn has not completed.
const lastMessage = tailRecords[tailRecords.length - 1]?.info as (Message & { time?: { completed?: number } }) | undefined;
return Boolean(
lastMessage
&& lastMessage.role === 'assistant'
&& typeof lastMessage.time?.completed !== 'number',
);
}, [sessionPermissions.length, sessionQuestions.length, status.type, tailRecords]);
return {
messageRecords: tailRecords,
sessionIsWorking,
streamingMessageId,
activeStreamingPhase,
sessionPermissions,
sessionQuestions,
isEmpty: tailRecords.length === 0,
};
};
/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
const useEscapeToCollapse = (onCollapse: () => void): void => {
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
// SAFETY: keydown targets are DOM elements (or null on window).
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
onCollapse();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCollapse]);
};
/**
* Stick-to-bottom auto-scroll. Streaming grows content inside one message
* without changing the record count, so following the tail needs a
* ResizeObserver on the content wrapper data-driven effects alone would
* stop following mid-stream.
*/
const useAutoScroll = (
bodyRef: React.RefObject<HTMLDivElement | null>,
contentRef: React.RefObject<HTMLDivElement | null>,
contentReady: boolean,
): ((event: React.UIEvent<HTMLDivElement>) => void) => {
const stickToBottomRef = React.useRef(true);
// `contentReady` is a dependency because the refs are only attached once
// the empty state gives way to the message list; an effect keyed on the
// refs alone would run against `null` and never re-attach the observer.
React.useEffect(() => {
if (!contentReady) return;
const content = contentRef.current;
const element = bodyRef.current;
if (element && stickToBottomRef.current) {
element.scrollTop = element.scrollHeight;
}
if (!content || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
const body = bodyRef.current;
if (body && stickToBottomRef.current) {
body.scrollTop = body.scrollHeight;
}
});
observer.observe(content);
return () => observer.disconnect();
}, [bodyRef, contentReady, contentRef]);
return React.useCallback((event: React.UIEvent<HTMLDivElement>) => {
const element = event.currentTarget;
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
}, []);
};
const BtwFrame: React.FC<{
title: string;
actions?: React.ReactNode;
onTitleClick?: () => void;
titleClickLabel?: string;
collapsed?: boolean;
headerSpinner?: boolean;
children?: React.ReactNode;
}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
<div
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
role="dialog"
aria-label="btw"
>
<div className="oc-glass-popover w-full overflow-hidden rounded-xl border border-[var(--interactive-border)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]">
<div className="flex items-center gap-2 px-3 py-1.5">
{onTitleClick ? (
<button
type="button"
onClick={onTitleClick}
aria-label={titleClickLabel}
title={titleClickLabel}
className="flex min-w-0 items-center gap-2 text-left text-muted-foreground transition-colors hover:text-foreground"
>
{headerSpinner ? (
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin" />
) : (
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
)}
<span className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</span>
<Icon name={collapsed ? 'arrow-up-s' : 'arrow-down-s'} className="size-4 shrink-0" />
</button>
) : (
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
<h2 className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</h2>
</span>
)}
<div className="min-w-0 flex-1" />
{actions}
</div>
{children ? (
<>
{children}
<div className="h-2" />
</>
) : null}
</div>
</div>
);
const BtwSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
collapsed: boolean;
}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => {
const { t } = useI18n();
const handleDestroy = useBtwDestroy(sessionRef);
const setCollapsed = React.useCallback((next: boolean) => {
useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next });
}, [sessionRef.parentSessionId]);
const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]);
const handlePromote = React.useCallback(() => {
void promoteBtwSession(sessionRef).catch(() => {
toast.error(t('chat.btw.toast.promoteFailed'));
});
}, [sessionRef, t]);
useEscapeToCollapse(handleCollapse);
const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria');
const headerButtonClass = 'size-7 rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:!bg-transparent active:!bg-transparent';
const actions = (
<div className="flex shrink-0 items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className={headerButtonClass}
onClick={handlePromote}
aria-label={t('chat.btw.promoteAria')}
title={t('chat.btw.promoteAria')}
>
<Icon name="external-link" className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={headerButtonClass}
onClick={handleDestroy}
aria-label={t('chat.btw.destroyAria')}
title={t('chat.btw.destroyAria')}
>
<Icon name="close" className="size-4" />
</Button>
</div>
);
if (collapsed) {
return (
<BtwCollapsedStrip
sessionRef={sessionRef}
title={title}
actions={actions}
onExpand={handleToggleCollapsed}
expandLabel={toggleLabel}
/>
);
}
return (
<BtwExpandedSheet
sessionRef={sessionRef}
title={title}
boundaryMessageID={boundaryMessageID}
actions={actions}
onTitleClick={handleToggleCollapsed}
titleClickLabel={toggleLabel}
/>
);
};
/**
* Collapsed mode: only the header strip stays docked above the composer. The
* fork keeps running in the background; a spinner replaces the header icon
* while it is busy so activity stays visible without the message list.
*/
const BtwCollapsedStrip: React.FC<{
sessionRef: BtwSessionRef;
title: string;
actions: React.ReactNode;
onExpand: () => void;
expandLabel: string;
}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => {
const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS;
const isBusy = status.type === 'busy' || status.type === 'retry';
return (
<BtwFrame
title={title}
actions={actions}
onTitleClick={onExpand}
titleClickLabel={expandLabel}
collapsed
headerSpinner={isBusy}
/>
);
};
const BtwExpandedSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
actions: React.ReactNode;
onTitleClick: () => void;
titleClickLabel: string;
}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID);
const bodyRef = React.useRef<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const handleBodyScroll = useAutoScroll(bodyRef, contentRef, !data.isEmpty);
// With the on-screen keyboard open the composer (this panel's anchor)
// rises, and a vh-based cap would push the panel under the app header.
// Same protection as the composer autocomplete popups: clamp the scroll
// body to the space actually available above the anchor. The hook measures
// room for the scroll body itself, but the panel header and bottom spacer
// sit inside the same frame above/below it — reserve their height too.
const BTW_FRAME_CHROME_PX = 48;
const availableMaxHeight = useMobileAutocompleteMaxHeight(bodyRef, true, 520 + BTW_FRAME_CHROME_PX);
const mobileMaxHeight = availableMaxHeight !== undefined
? Math.max(120, availableMaxHeight - BTW_FRAME_CHROME_PX)
: undefined;
return (
<BtwFrame title={title} actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
<ChatSurfaceProvider mode="peek">
<BtwMessages
data={data}
bodyRef={bodyRef}
contentRef={contentRef}
onBodyScroll={handleBodyScroll}
maxHeight={mobileMaxHeight}
/>
</ChatSurfaceProvider>
</BtwFrame>
);
};
const BtwMessages: React.FC<{
data: BtwSessionData;
bodyRef: React.RefObject<HTMLDivElement | null>;
contentRef: React.RefObject<HTMLDivElement | null>;
onBodyScroll: (event: React.UIEvent<HTMLDivElement>) => void;
maxHeight?: number;
}> = ({ data, bodyRef, contentRef, onBodyScroll, maxHeight }) => {
const { t } = useI18n();
if (data.isEmpty) {
return (
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
</div>
);
}
return (
<ScrollShadow
ref={bodyRef}
onScroll={onBodyScroll}
size={32}
data-scroll-shadow="true"
className="max-h-[min(55vh,520px)] min-h-0 overflow-y-auto px-3 py-1"
style={maxHeight !== undefined ? { maxHeight } : undefined}
>
<div ref={contentRef}>
{data.messageRecords.map((record, index) => (
<ChatMessage
key={record.info.id}
message={record}
previousMessage={data.messageRecords[index - 1]}
nextMessage={data.messageRecords[index + 1]}
isInActiveTurn={index === data.messageRecords.length - 1}
activeStreamingPhase={
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
}
/>
))}
{data.sessionQuestions.length > 0 || data.sessionPermissions.length > 0 ? (
<div>
{data.sessionQuestions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{data.sessionPermissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
) : null}
{/* Always reserve this row so the content does not shift down
by a line when the indicator disappears. */}
<div
className={cn(
'flex items-center gap-2 px-1 py-2 text-xs text-muted-foreground',
!data.sessionIsWorking && 'invisible',
)}
aria-hidden={!data.sessionIsWorking}
>
<Icon name="loader-4" className="size-3.5 animate-spin" />
<span>{t('chat.btw.working')}</span>
</div>
</div>
</ScrollShadow>
);
};
@@ -0,0 +1,57 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useSession } from '@/sync/sync-context';
import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetadata';
import { useBtwStore } from '@/stores/useBtwStore';
export type BtwPanelState = {
/** The session the composer is in — the one `/btw` would fork. */
parentSession: Session | null;
/** The active fork for this parent, or null when no panel should exist. */
btwSessionId: string | null;
btwSession: Session | null;
/** The fork's directory identity (may be canonicalized by the server). */
btwDirectory: string | null;
/** Last message id inherited from the parent; the panel shows what's after it. */
boundaryMessageID: string | null;
collapsed: boolean;
creating: boolean;
};
/**
* Derive the `/btw` panel identity for one parent session from authoritative
* session metadata (`openchamber.btwSessionID`), plus the transient UI state
* kept in `useBtwStore`. The panel exists only while the parent's link AND the
* fork itself are present in the live stores, so a fork deleted anywhere
* (sidebar, another client) makes the panel disappear without extra tracking.
*/
export function useBtwPanelState(
parentSessionId: string | null | undefined,
directory: string | undefined,
): BtwPanelState {
const parentSession = useSession(parentSessionId, directory);
const linkedBtwSessionId = getBtwSessionID(parentSession);
const btwSession = useSession(linkedBtwSessionId, directory) ?? null;
const uiState = useBtwStore(
React.useCallback(
(s) => (parentSessionId ? s.byParent[parentSessionId] : undefined),
[parentSessionId],
),
);
const destroying = Boolean(uiState?.destroying);
const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null;
return {
parentSession: parentSession ?? null,
btwSessionId,
btwSession: btwSessionId ? btwSession : null,
// SAFETY: the SDK Session type omits the server's `directory` field; this
// widening only reads it, with the parent's directory as the fallback.
btwDirectory: btwSessionId
? ((btwSession as (Session & { directory?: string | null }) | null)?.directory ?? directory ?? null)
: null,
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
collapsed: Boolean(uiState?.collapsed),
creating: Boolean(uiState?.creating),
};
}
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
import { withReviewSessionMarker } from '@/lib/sessionReviewMetadata';
const session = (parentID?: string): Session => ({
id: 'session',
@@ -27,4 +28,14 @@ describe('resolveChatPromptReadOnly', () => {
expect(resolveChatPromptReadOnly(session(), true, true)).toBe(true);
expect(resolveChatPromptReadOnly(session(), true, false)).toBe(false);
});
test('treats a marked code review as an independent session even with a stale parent ID', () => {
const reviewSession = {
...session('original'),
metadata: withReviewSessionMarker({}, 'original'),
} as Session;
expect(resolveChatPromptReadOnly(reviewSession, false, false)).toBe(false);
expect(resolveChatPromptReadOnly(reviewSession, true, true)).toBe(true);
});
});
@@ -1,10 +1,18 @@
import type { Session } from '@opencode-ai/sdk/v2';
import { isReviewSession } from '@/lib/sessionReviewMetadata';
export const resolveChatPromptReadOnly = (
session: Session | null | undefined,
allowPromptingSubagentSessions: boolean,
readOnly: boolean,
): boolean => {
// Review sessions are independent conversations even if an older server or
// cached record still carries parentID. Their explicit metadata is the
// authority; only the surface itself may make them read-only.
if (isReviewSession(session)) {
return readOnly;
}
if (session?.parentID) {
return !allowPromptingSubagentSessions;
}
@@ -1,5 +1,11 @@
import React from 'react';
export type ChatSurfaceMode = 'default' | 'mini-chat';
/**
* 'mini-chat' is the browser-panel side chat (compact, no fork/plan actions).
* 'peek' is a read-only glance surface (the /btw panel): messages render with
* no per-message controls at all no user action row, no assistant action
* buttons, no turn footer.
*/
export type ChatSurfaceMode = 'default' | 'mini-chat' | 'peek';
export const ChatSurfaceContext = React.createContext<ChatSurfaceMode>('default');
@@ -1,33 +1,85 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useConfigStore } from '@/stores/useConfigStore';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
/**
* Compact one-line mirror of the status row for the pill: same label, none of
* the status row's animation machinery (which does not survive being squeezed
* into a 32px chip).
*/
const PillWorkingStatus: React.FC = () => {
const { t } = useI18n();
const { activeModel, working } = useAssistantStatus();
const providers = useConfigStore((state) => state.providers);
const modelName = React.useMemo(() => {
if (!activeModel) return null;
const provider = providers.find((candidate) => candidate.id === activeModel.providerId);
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
}, [activeModel, providers]);
if (!working.isWorking || !working.statusText) return null;
const status = working.statusText;
const label = modelName && modelName.trim().length > 0
? t('chat.statusRow.modelStatus', { model: modelName.trim(), status })
: status.charAt(0).toUpperCase() + status.slice(1);
return (
<span className="min-w-0 truncate pr-3 text-sm text-muted-foreground">
{label}
<span className="animate-pulse"> </span>
</span>
);
};
interface ScrollToBottomButtonProps {
visible: boolean;
/** The session is still streaming: the pill carries the status label
while the floating status row is hidden away from the live edge. */
working?: boolean;
onClick: () => void;
}
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => {
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, working = false, onClick }) => {
const { t } = useI18n();
return (
<div
className={cn(
'absolute bottom-full left-1/2 -translate-x-1/2 mb-2 transition-all duration-150',
visible ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' : 'opacity-0 translate-y-2 scale-95 pointer-events-none',
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
visible ? 'opacity-100' : 'opacity-0',
)}
>
<Button
variant="outline"
size="sm"
onClick={onClick}
className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
aria-label={t('chat.scrollToBottom.aria')}
>
<Icon name="arrow-down" className="h-4 w-4" />
</Button>
{/* The same column that centres the composer, so the pill's left
edge lines up exactly with the input frame. */}
<div className="chat-input-column">
{/* The soft shadow lives on this wrapper, away from the glass
button's backdrop-filter: sharing one element made the
shadow intermittently drop after hide/show cycles. */}
<div className="inline-flex max-w-full rounded-full shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.10)] dark:shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.35)]">
<button
type="button"
onClick={onClick}
aria-label={t('chat.scrollToBottom.aria')}
className={cn(
// Glass material with a hairline real border — much
// lighter than the oc-glass-floating stack.
'oc-glass-popover inline-flex h-8 max-w-full items-center rounded-full [corner-shape:round] text-left',
'border border-black/[0.06] dark:border-white/[0.08]',
visible ? 'pointer-events-auto' : 'pointer-events-none',
)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground">
<Icon name="arrow-down" className="h-4 w-4" />
</span>
{working && visible ? <PillWorkingStatus /> : null}
</button>
</div>
</div>
</div>
);
};
@@ -4,7 +4,6 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup';
import type { TurnActivityRecord } from '../lib/turns/types';
import type { ToolPopupContent } from '../message/types';
import type { StreamPhase } from '../message/types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
interface DiffStats {
additions: number;
@@ -21,7 +20,6 @@ interface TurnActivityProps {
expandedTools: Set<string>;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
showHeader: boolean;
animateRows?: boolean;
@@ -18,13 +18,13 @@ const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, rend
data-scroll-spy-id={turn.turnId}
>
{stickyUserHeader ? (
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] [overflow-anchor:none]">
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] pb-4 sm:pb-8 [overflow-anchor:none]">
<div className="relative z-10">
{renderMessage(turn.userMessage)}
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-full z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
/>
</div>
) : (
@@ -7,6 +7,16 @@ everything between typing and sending.
own state and wires these modules together; it should not grow logic that
belongs to one of them.
`ChatContainer.tsx` keeps one `ChatInput` mounted while a new-session draft
becomes its first session. Draft-only UI first fades for 120ms while the editor
stays in place. The parent then moves the editor to its final session position
with a 180ms transform-only FLIP animation. Reduced-motion mode skips these
transitions. `session-ui-store.ts` marks sessions materialized from a submitted
draft, so selecting an existing session while a draft is open switches without
animation. Do not restore separate draft and session composer branches:
remounting the editor loses focus and interrupts the transition. Keep the
existing mobile fixed-position rules unchanged.
## Layers
| Directory | Owns |
@@ -50,6 +60,15 @@ copy.
exactly what gets sent, so nothing downstream serializes a rich document model
back into a prompt.
The document is not, however, the string it was given: CodeMirror normalizes
line endings, so a `\r\n` pair becomes one break and the document ends up
shorter than the inserted string. **Never derive a caret position from the
length of text you are inserting** — a caret past the end makes `dispatch`
throw, the transaction never applies, and the un-normalized text stays in React
state to crash again on the next restore. Every edit that moves the caret goes
through `replaceWithCaret` (`editor/documentEdits.ts`), which measures the
change instead of the string.
The composer previously painted a transparent `<textarea>` over a mirror
`<div>`. That restricted highlighting to styles which do not change glyph
advance width — colour, background, underline — because anything else made the
@@ -61,20 +80,54 @@ question of design, not of feasibility.
Selection rendering: every device runs CodeMirror's `drawSelection()` — it
keeps typing on the drawn-selection code path, and removing it makes
CodeMirror enforce cursor association on the native selection, which iOS
answers with severe input lag. Every device also layers
`composerNativeSelectionExtension` (`editor/theme.ts`) on top: it re-shows
answers with severe input lag. **That much is not platform-specific and must
not be undone.** What differs is who paints the selection, and
`composerSelectionExtension` (`editor/theme.ts`) picks that per platform.
When CodeMirror 6.43.9's iOS predicate does not match,
`composerNativeSelectionExtension` layers over `drawSelection()`: it re-shows
the native selection, and — only while a range is selected — the native caret,
hiding the painted layers those replace. The native selection is the one that
shows for two reasons: the painted layer sits behind the content, so tokens
with their own background (inline code, fences) cover it completely; and
iOS's selection drag handles attach to the visible native selection and take
their colour from the caret, so a transparent caret means invisible handles.
The range-only caret scoping is load-bearing — a native caret visible while
typing makes WebKit re-render its caret UI after every keystroke, felt as
severe input lag. The selection tint comes from `--primary`, not the selection
token:
themes define `--interactive-selection` with its own alpha, so a translucent
mix of it is nearly invisible.
with their own background (inline code, fences) cover it completely; and the
platform's selection drag handles attach to the visible native selection and
take their colour from the caret, so a transparent caret means invisible
handles. The range-only caret scoping is load-bearing — a native caret visible
while typing makes the browser re-render its caret UI after every keystroke,
felt as severe input lag.
When CodeMirror 6.43.9's exact iOS predicate matches,
`composerIOSSelectionExtension` leaves selection-handle geometry and appearance
to CodeMirror. CodeMirror puts the handles in `.cm-selectionLayer`, normally at
`z-index: -1`; the extension raises that layer above the content so opaque
token backgrounds cannot cover them, and leaves it transparent to touch.
The handle dots extend 8px past their range; matching scroller padding and
negative margin expand the clip area without moving the text or changing the
composer height. iOS still paints its taller system selection overlay even
when CSS makes `::selection` transparent. The extension therefore suppresses
CodeMirror's synthetic selection rectangles on iOS while leaving its handles,
cursor path and `nativeSelectionHidden` facet active. Otherwise the grey system
highlight and themed rectangle overlap with visibly different heights.
Do not add a second custom layer or custom handles here: overlapping translucent
rectangles make selection darker at their seams and imitated handles drift from
the geometry WebKit actually manipulates. What iOS avoids is installing the
native-selection workaround above: explicitly restoring native paint and caret
makes WebKit re-measure them after every decoration redraw, and the composer
rebuilds every decoration on every keystroke. That cost is felt worst during
IME composition.
The non-iOS native selection tint comes from `--primary`, not the selection
token: themes define `--interactive-selection` with its own alpha, so mixing it
with transparent again is nearly invisible. The iOS system overlay owns its
visible selection fill.
The content element keeps the existing correction policy: on in the mobile UI,
off elsewhere. CodeMirror also reads the attribute and reverts Apple and
Android's insert-period-on-double-space only when its value is exactly `off`.
`editor/autocorrect.ts` uses the HTML standard's
[ASCII case-insensitive `autocorrect` keywords](https://html.spec.whatwg.org/multipage/interaction.html#attr-autocorrect)
to keep desktop word correction off while avoiding that CodeMirror-only
revert. Its platform checks deliberately match CodeMirror's own browser flags.
`composerLanguage.ts` retokenizes the whole document on every change. The
composer holds a prompt, not a source file: it is short enough that a full pass
@@ -88,10 +141,14 @@ and the send path reading the same grammar.
drawn caret through a class it only writes while applying an update, so the
selection has to be the update that follows the focus.
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
inline comments and context into OpenCode's one-primary-plus-parts shape. The
oldest queued message becomes primary; **inline comments attach to the last
body the user authored** rather than becoming their own part; PR instructions
precede the PR diff.
context drafts and linked references into OpenCode's one-primary-plus-parts
shape. The oldest queued message becomes primary. **Every attached context
item (inline comments, terminal selections, browser annotations, PR context,
linked issue/PR) becomes its own synthetic text part carrying structured
metadata** built by `lib/messages/contextParts.ts`; the timeline reads that
metadata back to render context blocks. PR instructions precede the PR diff.
Queueing a message leaves context drafts in their store on purpose — the send
that later delivers the queue consumes them.
- `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory,
session) identity. Writes are debounced while typing but forced at every edge
where the page may stop running, because a pending timer is not a saved
@@ -101,6 +158,9 @@ and the send path reading the same grammar.
- `state/useDraftTarget.ts` — the draft can target a directory that does not
exist yet (a worktree being created). It must survive not appearing in the
branch list, or the selector snaps back to the project root mid-creation.
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
state and registers its application shortcuts locally. The selectors only
consume their shared prefix while the draft target UI is mounted.
## Mobile
@@ -4,6 +4,7 @@ import {
appendInlineText,
appendWithLineBreaks,
buildImagePasteInsertion,
getMarkdownAutoPairEdit,
shouldWrapSelectionAsLink,
withInlineInsertionBoundaries,
} from '../text';
@@ -119,3 +120,39 @@ describe('shouldWrapSelectionAsLink', () => {
expect(shouldWrapSelectionAsLink('https://x.dev', '[docs](https://y.dev)')).toBe(false);
});
});
describe('getMarkdownAutoPairEdit', () => {
test('completes a fenced block with the caret on the middle line', () => {
expect(getMarkdownAutoPairEdit('``', '`', 2, 2)).toEqual({
from: 2,
to: 2,
insert: '`\n\n```',
selectionStart: 4,
selectionEnd: 4,
});
});
test('completes a fence at the start of any line', () => {
expect(getMarkdownAutoPairEdit('intro\n``tail', '`', 8, 8)).toEqual({
from: 8,
to: 8,
insert: '`\n\n```',
selectionStart: 10,
selectionEnd: 10,
});
});
test('does not complete two backticks in the middle of a line', () => {
expect(getMarkdownAutoPairEdit('text ``', '`', 7, 7)).toBeNull();
});
test('wraps selected text and keeps the text selected', () => {
expect(getMarkdownAutoPairEdit('hello', '*', 1, 4)).toEqual({
from: 1,
to: 4,
insert: '*ell*',
selectionStart: 2,
selectionEnd: 5,
});
});
});
@@ -34,9 +34,11 @@ import {
import { cn } from '@/lib/utils';
import type { ComposerLanguageContext } from '../language/tokenize';
import type { ComposerAutoCorrect } from './autocorrect';
import { composerLanguage, setLanguageContext } from './composerLanguage';
import { replaceWithCaret } from './documentEdits';
import type { ComposerEditorViewStore } from './viewStore';
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
import { composerEditorTheme, composerSelectionExtension } from './theme';
import { handleComposerHostMouseDown } from './hostMouseDown';
export interface ComposerSelection {
@@ -63,8 +65,8 @@ export interface ComposerEditorHandle {
selectAll(): void;
/** Replace the current selection, leaving the caret after the insertion. */
insertText(text: string): void;
/** Replace an explicit range; the caret lands at `caret` or after the text. */
replaceRange(from: number, to: number, text: string, caret?: number): void;
/** Replace a range; selection defaults to a caret after the inserted text. */
replaceRange(from: number, to: number, text: string, selectionStart?: number, selectionEnd?: number): void;
/** Viewport coordinates of the caret, for positioning popups. */
caretCoords(position?: number): { top: number; bottom: number; left: number } | null;
/** The scrollable element, for measuring and scroll compensation. */
@@ -89,8 +91,11 @@ export interface ComposerEditorProps {
placeholder?: string;
editable?: boolean;
spellCheck?: boolean;
/** Mobile keyboards; ignored on desktop. */
autoCorrect?: boolean;
/**
* The content element's autocorrect keyword. See `autocorrect.ts` for the
* case-sensitive CodeMirror workaround.
*/
autoCorrect?: ComposerAutoCorrect;
autoCapitalize?: 'none' | 'sentences';
/** Fill the available height instead of growing with the content. */
fillContainer?: boolean;
@@ -130,6 +135,16 @@ function insertedTextOf(transaction: { changes: { iterChanges: (fn: (fromA: numb
return inserted;
}
/**
* True for keydown events CodeMirror re-dispatches after deferring the real
* one (iOS Enter/Backspace/Delete, Chrome Android Enter): `dispatchKey`
* stamps the replacement event with a `synthetic` expando. These events are
* built from the key name alone, so they carry no modifier keys.
*/
function isDeferredSyntheticEvent(event: KeyboardEvent): boolean {
return Boolean((event as unknown as { synthetic?: boolean }).synthetic);
}
/**
* Compartments are configuration keys, not per-view state, so one set can serve
* every editor. They live at module scope because a kept-alive view outlives
@@ -147,7 +162,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
placeholder,
editable = true,
spellCheck = false,
autoCorrect = false,
autoCorrect = 'off',
autoCapitalize = 'none',
fillContainer = false,
maxLines = 8,
@@ -160,6 +175,14 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
const hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(null);
// The real keydown's shift state for the LAST Enter that reached the
// editor. CodeMirror defers Enter on iOS (and Chrome Android) and
// re-dispatches it as a synthetic keydown built from the key name
// alone, dropping every modifier (see `trackRealEnterShift` and the
// `interceptKeys` handler below); this ref is what lets the deferred
// event still tell Shift+Enter from Enter.
const lastRealEnterShiftRef = React.useRef(false);
// Callbacks reach the CodeMirror extensions through a ref: the view is
// built once and must not be torn down when a handler identity changes,
// which would drop focus mid-typing. When a view store is supplied the
@@ -200,7 +223,15 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
}
const interceptKeys: KeyBinding[] = [{
any: (_view, event) => handlersRef.current.onKeyDown?.(event) ?? false,
any: (_view, event) => {
// A deferred Enter lost its modifiers in the re-dispatch;
// give the caller's policy (Enter vs Shift+Enter) back the
// shift state it saw on the real keydown.
if (event.key === 'Enter' && isDeferredSyntheticEvent(event) && lastRealEnterShiftRef.current) {
Object.defineProperty(event, 'shiftKey', { value: true });
}
return handlersRef.current.onKeyDown?.(event) ?? false;
},
}];
const view = new EditorView({
@@ -208,14 +239,13 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
doc: handlersRef.current.value,
extensions: [
history(),
// `drawSelection()` must stay even though the native
// selection is what actually shows (see the theme's
// comment on `composerNativeSelectionExtension`):
// removing it makes CodeMirror enforce cursor
// association on the native selection, which iOS
// answers with severe input lag.
// `drawSelection()` must stay on every platform.
// `composerSelectionExtension()` changes only who
// paints the selection; removing `drawSelection()`
// makes CodeMirror enforce cursor association on the
// native selection, which iOS answers with severe lag.
drawSelection(),
composerNativeSelectionExtension,
composerSelectionExtension(),
EditorView.lineWrapping,
// Highest precedence: the composer's own keys must win
// over CodeMirror's defaults (Enter sends, ArrowUp
@@ -262,7 +292,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
}),
EditorView.contentAttributes.of({
spellcheck: String(handlersRef.current.spellCheck ?? false),
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off',
autocorrect: handlersRef.current.autoCorrect ?? 'off',
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
...(handlersRef.current['aria-label']
? { 'aria-label': handlersRef.current['aria-label'] }
@@ -276,6 +306,25 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
viewRef.current = view;
if (store) store.view = view;
// CodeMirror defers Enter on iOS (and Chrome Android): the real
// keydown is captured without running the keymaps, the browser's
// native newline goes through, and the keymaps then run against a
// synthetic keydown `dispatchKey` builds from the key name alone —
// which has NO modifiers. Recording the real shift state here (a
// plain listener, registered after CodeMirror's own, so it runs
// after the deferral decision but before the deferred dispatch)
// lets the deferred Enter be re-presented with Shift+Enter intact
// instead of arriving as a plain Enter that "sends" where Enter
// sends. Without it, Shift+Enter on iOS/Android submits the
// message instead of inserting a newline. The listener lives on
// the kept-alive view's contentDOM, so it stays across mounts and
// keeps feeding the same ref the `interceptKeys` closure reads.
const trackRealEnterShift = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || isDeferredSyntheticEvent(event)) return;
lastRealEnterShiftRef.current = event.shiftKey;
};
view.contentDOM.addEventListener('keydown', trackRealEnterShift);
return () => {
viewRef.current = null;
// A stored view is detached, not destroyed: the store owns its
@@ -299,17 +348,18 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view) return;
const current = view.state.doc.toString();
if (current === value) return;
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
// An external rewrite (draft restore, history navigation,
// "add to chat", dictation insert) lands the caret at the END,
// matching what a plain textarea did when its value was
// replaced. Every rewrite that reaches here appends or
// replaces wholesale; keeping the old caret instead left it
// stranded before the inserted text, and the next insertion
// or keystroke landed inside the previous one.
selection: { anchor: value.length },
});
// Skip every controlled writeback while the browser is composing.
// A stale value echo can differ from CodeMirror's newer document,
// and replacing it would interrupt the IME session and move the caret.
if (view.compositionStarted) return;
// An external rewrite (draft restore, history navigation,
// "add to chat", dictation insert) lands the caret at the END,
// matching what a plain textarea did when its value was replaced.
// Every rewrite that reaches here appends or replaces wholesale;
// keeping the old caret instead left it stranded before the
// inserted text, and the next insertion or keystroke landed inside
// the previous one.
view.dispatch(replaceWithCaret(view.state, 0, current.length, value));
// A large insert can push the caret below the fold, and a
// transaction-time `scrollIntoView` cannot reach it: wrapped-line
// heights are still estimates during the update, and the
@@ -406,7 +456,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view) return;
const content = view.contentDOM;
content.setAttribute('spellcheck', String(spellCheck));
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off');
content.setAttribute('autocorrect', autoCorrect);
content.setAttribute('autocapitalize', autoCapitalize);
}, [autoCapitalize, autoCorrect, spellCheck]);
@@ -463,17 +513,18 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view || !text) return;
const { from, to } = view.state.selection.main;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: from + text.length },
...replaceWithCaret(view.state, from, to, text),
userEvent: 'input.type',
});
},
replaceRange(from, to, text, caret) {
replaceRange(from, to, text, selectionStart, selectionEnd = selectionStart) {
const view = viewRef.current;
if (!view) return;
const caret = selectionStart === undefined
? undefined
: { anchor: selectionStart, head: selectionEnd ?? selectionStart };
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: caret ?? from + text.length },
...replaceWithCaret(view.state, from, to, text, caret),
userEvent: 'input.type',
});
},
@@ -0,0 +1,92 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { composerAutoCorrect, type ComposerAutoCorrect } from '../autocorrect';
const platform = (overrides: Partial<Navigator>): Navigator => ({
maxTouchPoints: 0,
platform: '',
userAgent: '',
vendor: '',
...overrides,
} as Navigator);
const codeMirrorKeepsDoubleSpacePeriod = (
autoCorrect: ComposerAutoCorrect,
): boolean => autoCorrect !== 'off';
const affectedPlatforms: Array<[string, Navigator]> = [
['macOS', platform({ platform: 'MacIntel' })],
['iPhone', platform({
platform: 'iPhone',
userAgent: 'Mozilla/5.0 Mobile/15E148 Safari/604.1',
vendor: 'Apple Computer, Inc.',
})],
['iPadOS touch detection', platform({
maxTouchPoints: 5,
userAgent: 'Mozilla/5.0 Version/17.4 Safari/605.1.15',
vendor: 'Apple Computer, Inc.',
})],
['Android', platform({
platform: 'Linux armv8l',
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8)',
})],
];
const unaffectedPlatforms: Array<[string, Navigator]> = [
['Windows', platform({ platform: 'Win32' })],
['Linux', platform({ platform: 'Linux x86_64' })],
];
describe('composerAutoCorrect', () => {
test('matches the pinned CodeMirror period-revert guard', () => {
const source = readFileSync(
fileURLToPath(import.meta.resolve('@codemirror/view')),
'utf8',
);
const semantics = source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\s+/g, '');
expect(/getAttribute\(["']autocorrect["']\)==["']off["']/.test(semantics)).toBe(true);
expect(semantics).toContain(
'constios=safari&&(/Mobile\\/\\w+/.test(nav.userAgent)||nav.maxTouchPoints>2)',
);
expect(semantics).toContain('mac:ios||/Mac/.test(nav.platform)');
expect(semantics).toContain('android:/Android\\b/.test(nav.userAgent)');
});
for (const [name, navigator] of affectedPlatforms) {
test(`preserves the ${name} platform period without enabling autocorrect`, () => {
const autoCorrect = composerAutoCorrect({ isMobile: false, navigator });
expect(autoCorrect.toLowerCase()).toBe('off');
// @codemirror/view 6.39.13 reverts the native period only for exact "off".
expect(codeMirrorKeepsDoubleSpacePeriod(autoCorrect)).toBe(true);
});
}
for (const [name, navigator] of unaffectedPlatforms) {
test(`leaves desktop correction off on ${name}`, () => {
expect(composerAutoCorrect({ isMobile: false, navigator })).toBe('off');
});
}
test('uses CodeMirror platform detection rather than a macOS user agent', () => {
expect(composerAutoCorrect({
isMobile: false,
navigator: platform({
platform: 'Linux x86_64',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
}),
})).toBe('off');
});
test('preserves the existing mobile autocorrect policy', () => {
expect(composerAutoCorrect({
isMobile: true,
navigator: platform({ platform: 'Win32' }),
})).toBe('on');
});
});
@@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test';
import { EditorState } from '@codemirror/state';
import { replaceWithCaret } from '../documentEdits';
const apply = (doc: string, from: number, to: number, insert: string, caret?: { anchor: number; head: number }) => {
const state = EditorState.create({ doc });
const next = state.update(replaceWithCaret(state, from, to, insert, caret)).state;
return { text: next.doc.toString(), selection: next.selection.main };
};
describe('replaceWithCaret', () => {
test('puts the caret at the end of a wholesale replacement', () => {
const { text, selection } = apply('old', 0, 3, 'a new draft');
expect(text).toBe('a new draft');
expect(selection.anchor).toBe(11);
expect(selection.head).toBe(11);
});
// Issue #3013: CodeMirror collapses `\r\n` into one line break, so a caret
// taken from the JS string length falls outside the document and dispatch
// throws `RangeError: Selection points outside of document`.
test('keeps the caret inside the document when CRLF is normalized away', () => {
const { text, selection } = apply('a', 0, 1, 'x\r\ny');
expect(text).toBe('x\ny');
expect(selection.anchor).toBe(3);
});
test('survives a draft made only of CRLF breaks', () => {
const { text, selection } = apply('a', 0, 1, '\r\n\r\n\r\n');
expect(text).toBe('\n\n\n');
expect(selection.anchor).toBe(3);
});
test('places the caret after text inserted at the selection', () => {
const { text, selection } = apply('hello world', 5, 5, ',\r\n there');
expect(text).toBe('hello,\n there world');
expect(selection.anchor).toBe(13);
});
test('honours an explicit caret', () => {
const { selection } = apply('hello', 0, 5, 'goodbye', { anchor: 2, head: 4 });
expect(selection.anchor).toBe(2);
expect(selection.head).toBe(4);
});
test('clamps an explicit caret that the normalized document cannot hold', () => {
const { text, selection } = apply('a', 0, 1, 'x\r\ny', { anchor: 4, head: 4 });
expect(text).toBe('x\ny');
expect(selection.anchor).toBe(3);
});
});
@@ -1,16 +1,29 @@
import { describe, expect, test } from 'bun:test';
import { EditorState } from '@codemirror/state';
import { EditorState, type Extension } from '@codemirror/state';
import {
COMPOSER_EDITOR_THEME_SPEC,
IOS_SELECTION_THEME_SPEC,
NATIVE_SELECTION_THEME_SPEC,
composerEditorTheme,
composerIOSSelectionExtension,
composerNativeSelectionExtension,
composerSelectionExtension,
isCodeMirrorIOSNavigator,
} from '../theme';
const selectors = Object.keys(COMPOSER_EDITOR_THEME_SPEC);
const declarations = JSON.stringify(COMPOSER_EDITOR_THEME_SPEC);
function installationError(extension: Extension): string | null {
try {
EditorState.create({ extensions: [extension] });
return null;
} catch (error) {
return String(error);
}
}
describe('composerEditorTheme', () => {
/**
* EditorView.theme compiles its selectors when this module is imported and
@@ -20,13 +33,7 @@ describe('composerEditorTheme', () => {
* surfaces only in the running app, where it takes the composer down.
*/
test('its selectors compile and the theme can be installed', () => {
let failure: unknown = null;
try {
EditorState.create({ extensions: [composerEditorTheme] });
} catch (error) {
failure = error;
}
expect(failure).toBeNull();
expect(installationError(composerEditorTheme)).toBeNull();
});
/**
@@ -46,6 +53,14 @@ describe('composerEditorTheme', () => {
expect(rule.borderLeftColor.startsWith('var(--')).toBe(true);
});
test('the drawn caret is wide enough to remain prominent', () => {
const cursorRule = selectors.find((selector) => selector.includes('.cm-cursor'));
const rule = (COMPOSER_EDITOR_THEME_SPEC as Record<string, Record<string, string>>)[cursorRule!];
expect(rule.borderLeftWidth).toBe('2px');
expect(rule.transform).toBe('scaleY(1.15)');
expect(rule.transformOrigin).toBe('center');
});
/**
* CodeMirror's own `.cm-cursor` rule and its `&dark` override are one and
* two classes deep respectively; a bare `.cm-cursor` selector loses to the
@@ -93,6 +108,10 @@ describe('composerEditorTheme', () => {
expect(rule.background.includes('transparent')).toBe(true);
}
});
test('the common theme does not re-show the native selection', () => {
expect(selectors.some((selector) => selector.includes('::selection'))).toBe(false);
});
});
describe('composerNativeSelectionTheme', () => {
@@ -100,21 +119,16 @@ describe('composerNativeSelectionTheme', () => {
const nativeDeclarations = JSON.stringify(NATIVE_SELECTION_THEME_SPEC);
/**
* Every device layers this over `drawSelection()`: the native selection
* paints over token backgrounds (the painted layer is hidden behind them)
* and iOS attaches its selection handles to it. `drawSelection()` must
* NOT be removed for that: without it CodeMirror starts enforcing cursor
* association on the native selection while typing in wrapped text, and
* iOS answers those programmatic selection moves with severe input lag.
* Every device except iOS layers this over `drawSelection()`: the native
* selection paints over token backgrounds (the painted layer is hidden
* behind them) and the platform attaches its selection handles to it.
* `drawSelection()` must NOT be removed for that: without it CodeMirror
* starts enforcing cursor association on the native selection while typing
* in wrapped text, and iOS answers those programmatic selection moves with
* severe input lag.
*/
test('it compiles and can be installed', () => {
let failure: unknown = null;
try {
EditorState.create({ extensions: [composerNativeSelectionExtension] });
} catch (error) {
failure = error;
}
expect(failure).toBeNull();
expect(installationError(composerNativeSelectionExtension)).toBeNull();
});
/**
@@ -142,9 +156,9 @@ describe('composerNativeSelectionTheme', () => {
});
/**
* iOS colours its selection drag handles from the caret colour. With
* `drawSelection()`'s `caret-color: transparent !important` in effect the
* handles are drawn invisibly. The native caret must come back with
* A platform showing native handles colours them from the caret colour.
* With `drawSelection()`'s `caret-color: transparent !important` in effect
* the handles are drawn invisibly. The native caret must come back with
* enough weight to win, and the drawn cursor layer must go so there are
* not two carets.
*
@@ -187,3 +201,106 @@ describe('composerNativeSelectionTheme', () => {
expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]);
});
});
describe('composerIOSSelectionExtension', () => {
const layerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller > .cm-selectionLayer'];
const scrollerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller'];
const selectionBackgroundRule = IOS_SELECTION_THEME_SPEC['& .cm-selectionBackground'];
test('it compiles and can be installed', () => {
expect(installationError(composerIOSSelectionExtension)).toBeNull();
});
/**
* CodeMirror renders its selection layer at `z-index: -1`, behind the
* text. Inline code and code fences have opaque backgrounds and otherwise
* cover both the selection and the iOS handles. The base value is inline,
* so raising it without `!important` silently does nothing.
*/
test('CodeMirror selection and handles are raised above token backgrounds', () => {
expect(layerRule.zIndex).toBe('100 !important');
});
/**
* The layer now sits over the content and would intercept taps and drags
* by default. It only paints; CodeMirror/WebKit still own the gestures.
*/
test('the layer does not intercept touch', () => {
expect(layerRule.pointerEvents).toBe('none');
});
/**
* A higher z-index cannot escape overflow clipping. CodeMirror's dots
* extend 8px past the range, so the scroller needs that much internal room;
* the matching negative margin keeps the text and composer height fixed.
*/
test('the scroller reserves unclipped room for both handles', () => {
expect(scrollerRule.paddingBlock).toBe('8px');
expect(scrollerRule.marginBlock).toBe('-8px');
});
test('the CodeMirror fill does not stack over the iOS system highlight', () => {
expect(selectionBackgroundRule.background).toBe('transparent !important');
});
/**
* A second custom layer was visually indistinguishable from duplicate
* native selection UI. iOS must only reposition the one layer that
* CodeMirror already uses for both selection rectangles and handles.
*/
test('it does not add a second selection implementation', () => {
expect(Object.keys(IOS_SELECTION_THEME_SPEC)).toEqual([
'& .cm-scroller',
'& .cm-scroller > .cm-selectionLayer',
'& .cm-selectionBackground',
]);
});
});
describe('composerSelectionExtension', () => {
/**
* The split is the point: iOS is the only platform that pays for a visible
* native selection during composition, and CodeMirror 6.43.9 draws its
* handles. Collapsing the two branches into one would
* either restore the latency on iOS or leave every other platform without
* discoverable range selection.
*/
test('the CodeMirror iOS path uses its handles; other platforms keep native selection', () => {
expect(composerSelectionExtension(true)).toBe(composerIOSSelectionExtension);
expect(composerSelectionExtension(false)).toBe(composerNativeSelectionExtension);
});
/**
* The composer may remove the native fallback only when CodeMirror's own
* browser predicate enables its replacement handles. This deliberately
* includes CodeMirror's vendor and touch thresholds rather than using a
* broader application-level iOS heuristic.
*/
test('the platform predicate matches CodeMirror 6.43.9', () => {
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6) Mobile/15E148 Safari/604.1',
'Apple Computer, Inc.',
5,
)).toBe(true);
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
'Apple Computer, Inc.',
5,
)).toBe(true);
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
'Google Inc.',
5,
)).toBe(false);
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
'Apple Computer, Inc.',
0,
)).toBe(false);
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0)',
'Apple Computer, Inc.',
5,
)).toBe(false);
});
});
@@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
const composerEditorSource = readFileSync(
new URL('../ComposerEditor.tsx', import.meta.url),
'utf-8',
);
const writebackEffect = (): string => {
const start = composerEditorSource.indexOf('// Controlled value:');
expect(start).toBeGreaterThan(-1);
const end = composerEditorSource.indexOf('}, [value]);', start);
expect(end).toBeGreaterThan(start);
return composerEditorSource.slice(start, end);
};
describe('composer value writeback composition guard (issue #2527)', () => {
test('checks equality, then composition, before dispatching', () => {
const effect = writebackEffect();
const equalityCheck = effect.indexOf('if (current === value) return;');
const compositionGuard = effect.indexOf('if (view.compositionStarted) return;');
const dispatch = effect.indexOf('view.dispatch(');
expect(equalityCheck).toBeGreaterThan(-1);
expect(compositionGuard).toBeGreaterThan(equalityCheck);
expect(dispatch).toBeGreaterThan(compositionGuard);
});
});
@@ -0,0 +1,24 @@
export type ComposerAutoCorrect = 'on' | 'off' | 'Off';
type PlatformNavigator = Pick<Navigator,
'maxTouchPoints' | 'platform' | 'userAgent' | 'vendor'
>;
/** Keep desktop autocorrect off without triggering CodeMirror's period revert. */
export function composerAutoCorrect(options: {
isMobile: boolean;
navigator?: PlatformNavigator;
}): ComposerAutoCorrect {
if (options.isMobile) return 'on';
const nav = options.navigator
?? (typeof navigator === 'undefined'
? { maxTouchPoints: 0, platform: '', userAgent: '', vendor: '' }
: navigator);
// These must match CodeMirror's flags because its revert checks exact "off".
const ios = /Apple Computer/.test(nav.vendor)
&& (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2);
return ios || /Mac/.test(nav.platform) || /Android\b/.test(nav.userAgent)
? 'Off'
: 'off';
}
@@ -41,7 +41,7 @@ const languageContextField = StateField.define<ComposerLanguageContext>({
},
});
export const EMPTY_CONTEXT: ComposerLanguageContext = {
const EMPTY_CONTEXT: ComposerLanguageContext = {
inputMode: 'normal',
knownAgentNames: new Set(),
confirmedMentions: new Set(),
@@ -90,8 +90,3 @@ export function composerLanguage(initial: ComposerLanguageContext = EMPTY_CONTEX
decorationField,
];
}
/** The context currently in effect, for callers that need to read it back. */
export function readLanguageContext(view: EditorView): ComposerLanguageContext {
return view.state.field(languageContextField);
}
@@ -0,0 +1,33 @@
import type { EditorState, TransactionSpec } from '@codemirror/state';
/**
* Replace a document range and leave the caret inside the resulting document.
*
* CodeMirror normalizes line endings on the way in: a `\r\n` pair becomes one
* line break, so the inserted string is longer than the text it produces. A
* caret derived from the JavaScript string therefore lands past the end of the
* document and `dispatch` throws `RangeError: Selection points outside of
* document`. The transaction never applies, so the un-normalized text stays in
* React state, gets persisted as a draft, and crashes the chat again on every
* restore (issue #3013).
*
* Deriving the caret from the change set instead keeps it correct for whatever
* CodeMirror actually inserted, without this module having to know the
* normalization rules.
*/
export const replaceWithCaret = (
state: EditorState,
from: number,
to: number,
insert: string,
caret?: { anchor: number; head: number },
): TransactionSpec => {
const changes = state.changes({ from, to, insert });
const clamp = (position: number): number => Math.min(Math.max(position, 0), changes.newLength);
// What CodeMirror inserted, measured on the document rather than on the
// string: the new length minus everything the change left untouched.
const insertedLength = changes.newLength - (state.doc.length - (to - from));
const anchor = caret ? clamp(caret.anchor) : from + insertedLength;
const head = caret ? clamp(caret.head) : anchor;
return { changes, selection: { anchor, head } };
};
@@ -5,6 +5,7 @@
* language layer emits, so the composer and the message list stay in step.
*/
import type { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
/**
@@ -19,6 +20,8 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
'&.cm-focused': { outline: 'none' },
'.cm-content': {
padding: '0',
// Keep the drawn empty-document cursor inside the scroller's horizontal clip.
paddingInlineStart: '1px',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
@@ -30,7 +33,10 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
// `caret-color: transparent !important` at the highest precedence and
// draws its own `.cm-cursor` element, whose base style is a hard-coded
// `border-left: 1.2px solid black`. Styling `caret-color` here therefore
// does nothing at all — the border is what has to be coloured.
// does nothing at all — the border is what has to be coloured. A 2px
// stroke makes the insertion point remain visible against every composer
// surface without relying on a fixed colour. A slight vertical scale makes
// it extend beyond the glyphs without changing CodeMirror's line geometry.
//
// CodeMirror recolours it for dark editors through `&dark .cm-cursor`,
// which needs the theme to declare itself dark. OpenChamber themes are not
@@ -43,6 +49,9 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
// moment this module is imported.
'&.cm-editor .cm-cursor, &.cm-editor .cm-dropCursor': {
borderLeftColor: 'var(--surface-foreground)',
borderLeftWidth: '2px',
transform: 'scaleY(1.15)',
transformOrigin: 'center',
},
'.cm-line': { padding: '0' },
'.cm-scroller': {
@@ -72,23 +81,16 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
'&.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
background: 'color-mix(in srgb, var(--interactive-selection) 55%, transparent)',
},
// The native selection still shows through in places CodeMirror does not
// draw over, such as the placeholder. Same colour as the native-selection
// theme below, for the same reason: the selection token carries its own
// alpha and reads as nearly invisible when mixed down again.
'& ::selection': {
background: 'color-mix(in srgb, var(--primary) 25%, transparent)',
},
};
export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
/**
* Every device keeps `drawSelection()` but shows the NATIVE selection through
* it, for two independent reasons:
* Outside CodeMirror's iOS branch, devices keep `drawSelection()` but show the
* NATIVE selection through it, for two independent reasons:
*
* - iOS attaches its selection handles (the draggable pins after a
* double-tap) to the *visible* native selection, and `drawSelection()`
* - Their selection drag handles (the draggable pins after a double-tap)
* attach to the *visible* native selection, and `drawSelection()`
* hides it with `.cm-line ::selection { background: transparent
* !important }`, so the handles never appear and range selection is
* undiscoverable.
@@ -97,12 +99,15 @@ export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
* the selection is invisible inside those spans. The native selection
* paints over element backgrounds.
*
* Dropping `drawSelection()` entirely is NOT an option: without it CodeMirror
* clears the `nativeSelectionHidden` facet and starts enforcing cursor
* association on the native selection while typing in wrapped text
* programmatic selection moves that iOS answers with severe input lag (each
* one also resets the keyboard's autocorrect context). Typing must stay on
* the drawn-selection code path; only the paint changes.
* Dropping `drawSelection()` entirely is NOT an option, on any platform:
* without it CodeMirror clears the `nativeSelectionHidden` facet and starts
* enforcing cursor association on the native selection while typing in
* wrapped text programmatic selection moves that iOS answers with severe
* input lag (each one also resets the keyboard's autocorrect context). Typing
* must stay on the drawn-selection code path; only the paint changes.
*
* CodeMirror's iOS branch does NOT use this arrangement
* `composerIOSSelectionExtension` below explains why.
*
* Both rules below fight `drawSelection()`'s own `Prec.highest` theme, so
* they carry `!important` and one class more specificity
@@ -146,17 +151,106 @@ export const NATIVE_SELECTION_THEME_SPEC = {
},
};
export const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
/**
* The native-selection arrangement, installed on every device: the theme
* above plus the `.oc-native-range` marker class that scopes its caret rules
* to the moments a range is actually selected. `editorAttributes`
* The native-selection arrangement, installed outside CodeMirror's iOS branch:
* the theme above plus the `.oc-native-range` marker class that scopes its
* caret rules to the moments a range is actually selected. `editorAttributes`
* re-evaluates on every update, so the class follows the selection with no
* listener of its own.
*/
export const composerNativeSelectionExtension = [
export const composerNativeSelectionExtension: Extension = [
composerNativeSelectionTheme,
EditorView.editorAttributes.of((view) =>
view.state.selection.main.empty ? null : { class: 'oc-native-range' }),
];
/**
* When its iOS predicate matches, CodeMirror 6.43.9 draws the range handles
* into the same layer as the selection, so CodeMirror owns both their geometry
* and appearance.
*
* That layer normally renders at `z-index: -1`, behind the content. Inline
* code and code fences have opaque backgrounds and would cover both the tint
* and handles. Raising the one existing layer fixes that without introducing
* a second set of rectangles or trying to imitate WebKit's controls. The
* layer remains transparent to touch so WebKit receives selection gestures.
*
* What iOS avoids is the native-selection workaround above: explicitly
* restoring the native highlight and caret makes WebKit re-measure and repaint
* that UI after every decoration redraw. `composerLanguage.ts` rebuilds the
* whole decoration set on every keystroke, so the cost is felt worst during
* IME composition where each intermediate replacement pays for it. WebKit's
* unavoidable system selection overlay remains the only visible fill.
*/
export const IOS_SELECTION_THEME_SPEC = {
// The handles extend 8px above/below their range. The scroller clips them
// at its own edge even when the layer has a high z-index, so reserve that
// room inside the clipping box and pull the box outward by the same amount.
// Text and composer height stay where they were; only the clip area grows.
'& .cm-scroller': {
marginBlock: '-8px',
paddingBlock: '8px',
},
'& .cm-scroller > .cm-selectionLayer': {
// CodeMirror writes `z-index: -1` inline. `!important` is intentional:
// without it token backgrounds cover the selection and its handles.
zIndex: '100 !important',
pointerEvents: 'none',
},
// iOS keeps showing its taller system selection overlay even when
// ::selection is transparent. Painting CodeMirror's themed rectangles as
// well produces two visibly misaligned fills, so only the synthetic
// background is suppressed. The handles in this layer remain visible.
'& .cm-selectionBackground': {
background: 'transparent !important',
},
};
export const composerIOSSelectionExtension: Extension =
EditorView.theme(IOS_SELECTION_THEME_SPEC);
/**
* Which selection paint the composer installs. The split is the platform's,
* not a preference: iOS is the one place where restoring native selection
* paint and caret costs measurable input latency, and the only place
* CodeMirror supplies replacement drag handles.
*
* The caller can pass the policy, so the choice stays testable and is made
* once per editor rather than once per module load.
*/
export function composerSelectionExtension(
useCodeMirrorIOSHandles: boolean = usesCodeMirrorIOSSelectionHandles(),
): Extension {
return useCodeMirrorIOSHandles
? composerIOSSelectionExtension
: composerNativeSelectionExtension;
}
/**
* Mirrors @codemirror/view 6.43.9's iOS predicate. This branch may only rely
* on the drawn handles when CodeMirror itself will create them; a broader iOS
* heuristic could remove the native fallback without installing a replacement.
*/
export function isCodeMirrorIOSNavigator(
userAgent: string,
vendor: string,
maxTouchPoints: number,
): boolean {
const isIE = /Edge\/(\d+)/.test(userAgent)
|| /MSIE \d/.test(userAgent)
|| /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.test(userAgent);
if (isIE || !/Apple Computer/.test(vendor)) return false;
return /Mobile\/\w+/.test(userAgent) || maxTouchPoints > 2;
}
function usesCodeMirrorIOSSelectionHandles(): boolean {
const nav = globalThis.navigator;
if (!nav) return false;
return isCodeMirrorIOSNavigator(
nav.userAgent || '',
nav.vendor || '',
nav.maxTouchPoints ?? 0,
);
}
@@ -114,5 +114,3 @@ function matchMention(
});
return query === null ? null : { kind: 'mention', query };
}
export type { FileMentionAutocompleteInputSource };
@@ -23,6 +23,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { normalizePath } from '../attachments/filePaths';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
import { useI18n } from '@/lib/i18n';
/** How long a cached branch list is served before it is refreshed. */
const BRANCHES_SWR_TTL_MS = 30_000;
@@ -35,6 +37,7 @@ export interface DraftTargetProject {
color?: string | null;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
iconBackground?: string | null;
kind?: 'chat' | 'project';
}
/** A project's display name, falling back to its directory name. */
@@ -43,7 +46,15 @@ export function getProjectDisplayLabel(project: { label?: string; path: string }
}
export function useDraftTarget(enabled: boolean) {
const projects = useProjectsStore((state) => state.projects) as DraftTargetProject[];
const configuredProjects: readonly DraftTargetProject[] = useProjectsStore((state) => state.projects);
const { t } = useI18n();
const chatProject = React.useMemo<DraftTargetProject>(() => ({
id: CHAT_DRAFT_PROJECT_ID,
path: '',
label: t('layout.mainTab.chat'),
kind: 'chat',
}), [t]);
const projects = React.useMemo(() => [chatProject, ...configuredProjects], [chatProject, configuredProjects]);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -53,6 +64,7 @@ export function useDraftTarget(enabled: boolean) {
const { git: runtimeGit } = useRuntimeAPIs();
const selectedDraftProject = React.useMemo(() => {
if (newSessionDraft?.target === 'chat') return chatProject;
const explicit = newSessionDraft?.selectedProjectId
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
: null;
@@ -67,14 +79,16 @@ export function useDraftTarget(enabled: boolean) {
return active;
}
return projects[0] ?? null;
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
return configuredProjects[0] ?? chatProject;
}, [activeProjectId, chatProject, configuredProjects, newSessionDraft?.selectedProjectId, newSessionDraft?.target, projects]);
const selectedDraftProjectPath = React.useMemo(
() => normalizePath(selectedDraftProject?.path ?? null),
[selectedDraftProject?.path],
() => selectedDraftProject?.kind === 'chat' ? null : normalizePath(selectedDraftProject?.path ?? null),
[selectedDraftProject?.kind, selectedDraftProject?.path],
);
const draftProjectLabel = selectedDraftProject ? getProjectDisplayLabel(selectedDraftProject) : null;
const draftProjectLabel = selectedDraftProject && selectedDraftProject.kind !== 'chat'
? getProjectDisplayLabel(selectedDraftProject)
: null;
const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath);
const selectedDraftProjectBranchesFetchedAt = useGitStore(
@@ -258,6 +272,10 @@ export function useDraftTarget(enabled: boolean) {
if (!project) {
return;
}
if (project.kind === 'chat') {
setNewSessionDraftTarget({ projectId: CHAT_DRAFT_PROJECT_ID, directoryOverride: null }, { force: true });
return;
}
if (activeProjectId !== projectId) {
setActiveProjectIdOnly(projectId);
}
@@ -17,6 +17,15 @@ import React from 'react';
import { isCapacitorApp } from '@/lib/platform';
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
// Android mobile browsers are the pan-mode holdouts this pin exists for on
// the CHAT screen too: interactive-widget=resizes-content is ignored by a
// fair share of Android WebView/Chrome builds, and unlike iOS Safari they do
// not reliably reveal the focused field either — the composer just stays
// behind the keyboard. iOS keeps its browser-native reveal on the chat
// screen, so this stays Android-only there.
// Callers are browser-only React effects, so navigator always exists here.
const isAndroidBrowser = (): boolean => /Android/i.test(navigator.userAgent);
export interface MobileViewportPinOptions {
isMobile: boolean;
/** Composer expanded to fullscreen on mobile. */
@@ -96,12 +105,14 @@ export function useMobileViewportPin(options: MobileViewportPinOptions): void {
};
}, [editorRef, formRef, isFullscreen, isMobile]);
// Draft screen with the keyboard up: anchor the normal-height composer to
// the visible bottom. The chat screen does not need this — its own
// focused-field reveal works there.
// Keyboard up: anchor the normal-height composer to the visible bottom.
// Draft screen on every mobile browser; chat screen only on Android,
// where neither viewport resizing nor the focused-field reveal can be
// relied on (iOS chat keeps the browser's own reveal).
React.useLayoutEffect(() => {
if (!isMobile || isCapacitorApp()) return;
if (!isDraftScreen || isFullscreen || !isFocused) return;
if (isFullscreen || !isFocused) return;
if (!isDraftScreen && !isAndroidBrowser()) return;
const vv = window.visualViewport;
const form = formRef.current;
if (!vv || !form) return;
@@ -1,6 +1,8 @@
import { describe, expect, test } from 'bun:test';
import type { AttachedFile } from '@/stores/types/sessionTypes';
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { CONTEXT_METADATA_KEY, contextPayloadFromDraft } from '@/lib/messages/contextParts';
import {
buildOutgoingMessage,
type OutgoingMessageDeps,
@@ -26,7 +28,6 @@ const deps = (overrides: Partial<OutgoingMessageDeps> = {}): OutgoingMessageDeps
},
sanitizeAttachments: (files) => [...(files ?? [])],
collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]),
appendComments: (text, comments) => `${text}\n[${comments.length} comments]`,
buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null),
...overrides,
});
@@ -37,7 +38,7 @@ const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageIn
composerAttachments: [],
inlineComments: [],
syntheticTexts: [],
linkedIssueContext: null,
linkedIssue: null,
linkedPr: null,
...overrides,
});
@@ -130,36 +131,51 @@ describe('agent mentions', () => {
});
});
describe('inline comments', () => {
test('attach to the composer text when nothing was queued', () => {
const commentDraft = (overrides: Partial<InlineCommentDraft> = {}): InlineCommentDraft => ({
id: 'icd-1',
sessionKey: 's1',
source: 'diff',
fileLabel: 'src/app.ts',
startLine: 3,
endLine: 5,
side: 'modified',
code: 'const x = 1;',
language: 'ts',
text: 'fix this',
createdAt: 1,
...overrides,
});
describe('context drafts', () => {
test('each becomes a synthetic part carrying structured metadata', () => {
const result = buildOutgoingMessage(input({
composerText: 'body',
inlineComments: [{}, {}],
inlineComments: [commentDraft(), commentDraft({ id: 'icd-2', source: 'file', side: undefined })],
}), deps());
expect(result.primaryText).toBe('body\n[2 comments]');
expect(result.primaryText).toBe('body');
expect(result.additionalParts).toHaveLength(2);
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
.toEqual(contextPayloadFromDraft(commentDraft()));
expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY])
.toEqual(contextPayloadFromDraft(commentDraft({ id: 'icd-2', source: 'file', side: undefined })));
expect(result.additionalParts[0].text).toContain('Comment on `src/app.ts` lines 3-5 (modified):');
expect(result.additionalParts[0].text).toContain('fix this');
});
test('attach to the last authored part when messages were queued', () => {
test('context parts precede other synthetic context', () => {
const result = buildOutgoingMessage(input({
queued: [{ content: 'queued' }],
composerText: 'typed',
inlineComments: [{}],
composerText: 'body',
inlineComments: [commentDraft()],
syntheticTexts: ['conflict note'],
}), deps());
expect(result.primaryText).toBe('queued');
expect(result.additionalParts[0].text).toBe('typed\n[1 comments]');
expect(result.additionalParts.map((p) => p.text.startsWith('Comment on') ? 'comment' : p.text))
.toEqual(['comment', 'conflict note']);
});
test('fall back to primary when the queue produced no additional parts', () => {
const result = buildOutgoingMessage(input({
queued: [{ content: 'only queued' }],
inlineComments: [{}],
}), deps());
expect(result.primaryText).toBe('only queued\n[1 comments]');
});
test('no comments changes nothing', () => {
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).primaryText)
.toBe('body');
test('no drafts changes nothing', () => {
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).additionalParts)
.toEqual([]);
});
});
@@ -167,26 +183,31 @@ describe('synthetic context', () => {
test('a linked PR sends its instructions before its diff', () => {
const result = buildOutgoingMessage(input({
composerText: 'review this',
linkedPr: { instructions: 'how to read it', context: 'the diff' },
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'how to read it', context: 'the diff' },
}), deps());
expect(result.additionalParts.map((p) => p.text))
.toEqual(['how to read it', 'the diff']);
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY])
.toEqual({ kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' });
});
test('a linked issue is sent as context', () => {
const result = buildOutgoingMessage(input({
composerText: 'fix it',
linkedIssueContext: 'issue body',
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
}), deps());
expect(result.additionalParts).toEqual([{ text: 'issue body', synthetic: true }]);
expect(result.additionalParts).toHaveLength(1);
expect(result.additionalParts[0].text).toBe('issue body');
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
.toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' });
});
test('synthetic texts precede the linked references', () => {
const result = buildOutgoingMessage(input({
composerText: 'x',
syntheticTexts: ['conflict note'],
linkedIssueContext: 'issue body',
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
}), deps());
expect(result.additionalParts.map((p) => p.text))
.toEqual(['conflict note', 'issue body']);
@@ -211,7 +232,9 @@ describe('synthetic context', () => {
});
test('context alone is still worth sending', () => {
const result = buildOutgoingMessage(input({ linkedIssueContext: 'issue body' }), deps());
const result = buildOutgoingMessage(input({
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
}), deps());
expect(result.isEmpty).toBe(false);
});
@@ -230,8 +253,8 @@ describe('full assembly order', () => {
queued: [{ content: 'q1' }, { content: 'q2' }],
composerText: 'typed /deploy',
syntheticTexts: ['synthetic'],
linkedIssueContext: 'issue',
linkedPr: { instructions: 'pr-how', context: 'pr-diff' },
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
}), deps());
expect(result.primaryText).toBe('q1');
@@ -14,12 +14,16 @@
*/
import type { AttachedFile } from '@/stores/types/sessionTypes';
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { contextPayloadFromDraft, createContextPart, type ContextPartMetadata } from '@/lib/messages/contextParts';
export interface OutgoingPart {
text: string;
attachments?: AttachedFile[];
/** Synthetic parts are context for the model, not shown as user content. */
synthetic?: boolean;
/** Structured context (see contextParts.ts), persisted with the part. */
metadata?: ContextPartMetadata;
}
export interface OutgoingMessage {
@@ -43,12 +47,12 @@ export interface OutgoingMessageInput {
/** The composer's own text, or null when this send skips it. */
composerText: string | null;
composerAttachments: readonly AttachedFile[];
/** Inline review comments, appended to the user's last authored text. */
inlineComments: readonly unknown[];
/** Context drafts (code comments, terminal selections, annotations, PR context). */
inlineComments: readonly InlineCommentDraft[];
/** Synthetic context produced elsewhere (conflict resolution, and such). */
syntheticTexts: readonly string[];
linkedIssueContext: string | null;
linkedPr: { instructions: string; context: string } | null;
linkedIssue: { number: number; title: string; url: string; contextText: string } | null;
linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null;
}
/**
@@ -64,8 +68,6 @@ export interface OutgoingMessageDeps {
sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[];
/** Skills named inline with `/name`. */
collectSkillNames: (text: string) => string[];
/** Append inline review comments to a message body. */
appendComments: (text: string, comments: readonly unknown[]) => string;
/** Instruction telling the model which skills the user named. */
buildSkillInstruction: (names: string[]) => string | null;
}
@@ -134,33 +136,29 @@ export function buildOutgoingMessage(
}
}
// Inline comments attach to the last thing the user authored, so they read
// as a continuation of it rather than as a separate turn.
if (input.inlineComments.length > 0) {
const lastAuthored = input.queued.length > 0 && additionalParts.length > 0
? additionalParts[additionalParts.length - 1]
: null;
if (lastAuthored) {
lastAuthored.text = deps.appendComments(lastAuthored.text, input.inlineComments);
} else {
primaryText = deps.appendComments(primaryText, input.inlineComments);
}
// Everything below is context for the model, never plain user text. Each
// attached context item becomes its own synthetic part carrying structured
// metadata, so the timeline can render it as a context block after the
// server echoes the message back.
for (const draft of input.inlineComments) {
additionalParts.push(createContextPart(contextPayloadFromDraft(draft)));
}
// Everything below is context for the model, never user-visible content.
for (const text of input.syntheticTexts) {
additionalParts.push({ text, synthetic: true });
}
if (input.linkedIssueContext) {
additionalParts.push({ text: input.linkedIssueContext, synthetic: true });
if (input.linkedIssue) {
const { number, title, url, contextText } = input.linkedIssue;
additionalParts.push(createContextPart({ kind: 'github-issue', number, title, url }, contextText));
}
if (input.linkedPr) {
// Instructions before context: the model is told how to read the diff
// before it is given the diff.
additionalParts.push({ text: input.linkedPr.instructions, synthetic: true });
additionalParts.push({ text: input.linkedPr.context, synthetic: true });
const { number, title, url, instructions, context } = input.linkedPr;
additionalParts.push({ text: instructions, synthetic: true });
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
}
const skillInstruction = deps.buildSkillInstruction(skillNames);
@@ -91,7 +91,7 @@ export function buildImagePasteInsertion(pastedText: string, citationText: strin
* A single-line URL pasted over a selection becomes a markdown link rather
* than replacing the selected text.
*/
export const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
/**
* Whether a pasted URL should wrap the selection as `[selected](url)`. A URL
@@ -104,3 +104,61 @@ export function shouldWrapSelectionAsLink(url: string, selected: string): boolea
&& selected.trim().length > 0
&& !selected.includes('](');
}
const MARKDOWN_WRAP_PAIRS: Record<string, [string, string]> = {
'`': ['`', '`'],
'*': ['*', '*'],
'_': ['_', '_'],
'~': ['~', '~'],
'(': ['(', ')'],
'[': ['[', ']'],
'{': ['{', '}'],
'"': ['"', '"'],
"'": ["'", "'"],
};
/**
* Markdown source-mode conveniences handled before CodeMirror inserts a key.
* The returned text change and selection belong to one editor transaction so
* the caret cannot be applied against the previous document.
*/
export function getMarkdownAutoPairEdit(
value: string,
key: string,
selectionStart: number,
selectionEnd: number,
): {
from: number;
to: number;
insert: string;
selectionStart: number;
selectionEnd: number;
} | null {
const pair = MARKDOWN_WRAP_PAIRS[key];
if (selectionEnd > selectionStart && pair) {
const selected = value.slice(selectionStart, selectionEnd);
const [open, close] = pair;
return {
from: selectionStart,
to: selectionEnd,
insert: `${open}${selected}${close}`,
selectionStart: selectionStart + open.length,
selectionEnd: selectionEnd + open.length,
};
}
if (key === '`' && selectionStart === selectionEnd) {
const before = value.slice(0, selectionStart);
if (/(^|\n)``$/.test(before)) {
return {
from: selectionStart,
to: selectionEnd,
insert: '`\n\n```',
selectionStart: selectionStart + 2,
selectionEnd: selectionStart + 2,
};
}
}
return null;
}
@@ -0,0 +1,40 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface AutocompleteRowTooltipProps {
description?: string;
active: boolean;
children: React.ReactElement;
}
export function AutocompleteRowTooltip({ description, active, children }: AutocompleteRowTooltipProps) {
const [delayedActive, setDelayedActive] = React.useState(false);
React.useEffect(() => {
if (!active || !description) {
setDelayedActive(false);
return;
}
const timeout = window.setTimeout(() => setDelayedActive(true), 200);
return () => window.clearTimeout(timeout);
}, [active, description]);
if (!description) return children;
return (
<Tooltip delayDuration={0} open={active && delayedActive} onOpenChange={() => {}}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
{active && delayedActive ? (
<TooltipContent
side="right"
sideOffset={8}
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
>
<p className="typography-meta whitespace-pre-wrap">{description}</p>
</TooltipContent>
) : null}
</Tooltip>
);
}
@@ -89,7 +89,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
<Icon name="add-circle" className={cn(iconSizeClass, 'text-current')} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(handlePickLocalFiles);
@@ -2,164 +2,381 @@
* Context chips above the composer.
*
* Each chip stands for context that will be attached to the next message but
* is not part of its text: review comments left in a diff, captured dev-server
* logs, preview annotations, terminal selections. They are shown so the user
* knows what is riding along and can drop any of it before sending.
* is not part of its text: review comments left in a diff, preview
* annotations, terminal selections, PR context, chat quotes. Hovering (or
* tapping) a chip opens a stacked preview of its items above the composer,
* where a comment the user wrote can be edited in place and any item removed
* before sending.
*/
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import type { IconName } from '@/components/icon/icons';
import { useI18n } from '@/lib/i18n';
import type { InlineCommentDraft, InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
import {
EMPTY_INLINE_COMMENT_DRAFTS,
getInlineCommentDraftKey,
useInlineCommentDraftStore,
type InlineCommentDraft,
type InlineCommentDraftTarget,
type InlineCommentSource,
} from '@/stores/useInlineCommentDraftStore';
import type { Theme } from '@/types/theme';
export interface ComposerContextChipsProps {
/** Terminal selections, which show their own label and line range. */
terminalDrafts: readonly InlineCommentDraft[];
reviewCount: number;
prCommentCount: number;
prCheckCount: number;
previewConsoleCount: number;
previewAnnotationCount: number;
draftTarget: InlineCommentDraftTarget | null;
onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
onRemoveReviewDrafts: () => void;
onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => void;
colors: Theme['colors'];
}
/** A chip showing how many items of one kind are attached, with a clear action. */
function CountChip(props: {
/** Chip groups: every terminal selection is its own chip; the rest group by kind. */
type ChipGroup = {
key: string;
icon: IconName;
iconClassName?: string;
label: string;
count: number;
removeLabel: string;
drafts: InlineCommentDraft[];
};
const REVIEW_SOURCES: readonly InlineCommentSource[] = ['diff', 'file', 'plan', 'file-quote'];
/** Sources whose drafts carry a user-written comment that can be edited. */
const editableSource = (source: InlineCommentSource): boolean => source !== 'terminal';
/** Captured code/output kinds read better monospaced; quoted prose does not. */
const monoSource = (source: InlineCommentSource): boolean =>
source !== 'chat-quote' && source !== 'preview-annotation' && source !== 'file-quote';
const basename = (path: string): string => {
const segments = path.split('/').filter(Boolean);
return segments[segments.length - 1] ?? path;
};
const ENTRY_ACTION_CLASS = 'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]';
const ENTRY_LABEL_CLASS = 'text-[10px] font-medium uppercase tracking-wide text-[var(--surface-mutedForeground)] opacity-60';
const DraftPreviewEntry: React.FC<{
draft: InlineCommentDraft;
index: number;
title: string;
editing: boolean;
onStartEdit: () => void;
onEndEdit: () => void;
onRemove: () => void;
colors: Theme['colors'];
icon?: React.ReactNode;
}) {
return (
<div
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
style={{
backgroundColor: props.colors?.surface?.elevated,
borderColor: props.colors?.interactive?.border,
}}
>
{props.icon}
<span className="text-xs font-medium text-muted-foreground">{props.label}</span>
<span className="text-xs font-semibold" style={{ color: props.colors?.status?.info }}>
{props.count}
</span>
<button
type="button"
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
style={{ minHeight: 0, minWidth: 0 }}
onClick={props.onRemove}
aria-label={props.removeLabel}
title={props.removeLabel}
>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
);
}
export function ComposerContextChips(props: ComposerContextChipsProps) {
onSaveComment: ((text: string) => void) | null;
}> = ({ draft, index, title, editing, onStartEdit, onEndEdit, onRemove, onSaveComment }) => {
const { t } = useI18n();
const {
terminalDrafts,
reviewCount,
prCommentCount,
prCheckCount,
previewConsoleCount,
previewAnnotationCount,
draftTarget,
onRemoveDraft,
onRemoveReviewDrafts,
onRemovePreviewDrafts,
colors,
} = props;
const [editText, setEditText] = React.useState(draft.text);
const editRef = React.useRef<HTMLTextAreaElement>(null);
React.useEffect(() => {
if (!editing) return;
setEditText(draft.text);
queueMicrotask(() => {
const element = editRef.current;
if (element) {
element.focus();
element.setSelectionRange(element.value.length, element.value.length);
}
});
// The draft text at edit start is the baseline; later store updates are
// our own saves.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing]);
const commitEdit = () => {
if (onSaveComment && editText !== draft.text) {
onSaveComment(editText);
}
onEndEdit();
};
const cancelEdit = () => {
setEditText(draft.text);
onEndEdit();
};
// Keep focus in the textarea while a header button is pressed: without
// this the textarea's blur commits first, the header re-renders under the
// pointer, and the click lands on the button that replaced the pressed one
// (save punches through to edit, cancel to remove).
const keepEditorFocus = (event: React.PointerEvent) => {
if (editing) event.preventDefault();
};
return (
<div className="flex flex-wrap items-center gap-2 pb-2">
{terminalDrafts.map((draft) => (
<div
key={draft.id}
className="inline-flex max-w-full items-center gap-1.5 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1"
title={draft.code}
>
<Icon name="terminal" className="h-3.5 w-3.5" />
<span className="truncate text-xs font-medium text-[var(--surface-mutedForeground)]">
{t('chat.chatInput.terminalContext', {
terminal: draft.fileLabel,
start: draft.startLine,
end: draft.endLine,
})}
</span>
<div>
<div className="flex items-center gap-1.5 px-3 py-1.5"
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-mutedForeground) 8%, transparent)' }}>
<span className="text-xs font-medium text-[var(--surface-mutedForeground)]">{index + 1}.</span>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--surface-foreground)]" title={title}>
{title}
</span>
{onSaveComment ? (
<button
type="button"
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
onClick={() => draftTarget && onRemoveDraft(draftTarget, draft.id)}
aria-label={t('chat.chatInput.terminalContextRemove')}
title={t('chat.chatInput.terminalContextRemove')}
className={ENTRY_ACTION_CLASS}
style={{ minHeight: 0, minWidth: 0 }}
onPointerDown={keepEditorFocus}
onClick={editing ? commitEdit : onStartEdit}
aria-label={t('chat.chatInput.contextPreview.edit')}
title={t('chat.chatInput.contextPreview.edit')}
>
<Icon name="close" className="h-3 w-3" />
<Icon name={editing ? 'check' : 'pencil'} className="h-3 w-3" />
</button>
) : null}
<button
type="button"
className={ENTRY_ACTION_CLASS}
style={{ minHeight: 0, minWidth: 0 }}
onPointerDown={keepEditorFocus}
onClick={editing ? cancelEdit : onRemove}
aria-label={t('chat.chatInput.contextPreview.remove')}
title={t('chat.chatInput.contextPreview.remove')}
>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
<div className="space-y-2 px-3 py-2">
{draft.code.trim() ? (
<div>
<div className={ENTRY_LABEL_CLASS}>{t('chat.chatInput.contextPreview.selectedLabel')}</div>
<div
className={
monoSource(draft.source)
? 'mt-0.5 whitespace-pre-wrap break-words font-mono text-xs text-[var(--surface-foreground)]'
: 'mt-0.5 whitespace-pre-wrap break-words text-sm text-[var(--surface-foreground)]'
}
>
{draft.code}
</div>
</div>
) : null}
{onSaveComment && (editing || draft.text.trim()) ? (
<div>
<div className={ENTRY_LABEL_CLASS}>{t('chat.chatInput.contextPreview.commentLabel')}</div>
{editing ? (
<textarea
ref={editRef}
rows={2}
value={editText}
onChange={(event) => setEditText(event.target.value)}
onBlur={commitEdit}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
commitEdit();
} else if (event.key === 'Escape') {
event.preventDefault();
setEditText(draft.text);
onEndEdit();
}
}}
placeholder={t('chat.textSelection.comment.placeholder')}
className="mt-0.5 w-full resize-none rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-2 py-1 text-sm text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)]"
style={{ minHeight: 0 }}
/>
) : (
<div className="mt-0.5 whitespace-pre-wrap break-words text-sm text-[var(--surface-foreground)]">{draft.text}</div>
)}
</div>
) : null}
</div>
</div>
);
};
export function ComposerContextChips({ draftTarget, colors }: ComposerContextChipsProps) {
const { t } = useI18n();
const draftKey = draftTarget
? getInlineCommentDraftKey(getRuntimeKey(), draftTarget.directory, draftTarget.sessionKey)
: null;
const drafts = useInlineCommentDraftStore(
React.useCallback(
(state) => (draftKey ? state.drafts[draftKey] ?? EMPTY_INLINE_COMMENT_DRAFTS : EMPTY_INLINE_COMMENT_DRAFTS),
[draftKey],
),
);
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
const [openGroupKey, setOpenGroupKey] = React.useState<string | null>(null);
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
const editingRef = React.useRef<string | null>(null);
editingRef.current = editingDraftId;
const containerRef = React.useRef<HTMLDivElement>(null);
const closeTimerRef = React.useRef<number | null>(null);
const cancelClose = React.useCallback(() => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
// Hover-away close. Suspended while a comment is being edited: entering or
// leaving edit mode reflows the panel under the pointer, and a synthetic
// mouseleave from that reflow must not tear the editor down.
const scheduleClose = React.useCallback(() => {
if (editingRef.current) return;
cancelClose();
closeTimerRef.current = window.setTimeout(() => {
closeTimerRef.current = null;
setOpenGroupKey(null);
}, 150);
}, [cancelClose]);
React.useEffect(() => cancelClose, [cancelClose]);
// Clicking outside the chips + panel closes the preview even when a reflow
// swallowed the mouseleave (e.g. right after finishing an edit).
React.useEffect(() => {
if (!openGroupKey) return;
const handlePointerDown = (event: PointerEvent) => {
// SAFETY: a pointer event target inside the document is always a
// Node; `contains` only needs that.
if (containerRef.current?.contains(event.target as Node)) return;
setOpenGroupKey(null);
setEditingDraftId(null);
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [openGroupKey]);
const titleFor = React.useCallback((draft: InlineCommentDraft): string => {
switch (draft.source) {
case 'terminal':
return t('chat.chatInput.terminalContext', {
terminal: draft.fileLabel,
start: draft.startLine,
end: draft.endLine,
});
case 'preview-annotation':
return t('chat.message.context.browserAnnotation', { page: draft.fileLabel });
case 'pr-comment':
return t('chat.message.context.prComment', { label: draft.fileLabel });
case 'pr-check':
return t('chat.message.context.prCheck', { label: draft.fileLabel });
case 'chat-quote':
return t('chat.message.context.chatQuote');
case 'file-quote':
return draft.startLine > 0 && draft.endLine > 0
? (draft.startLine === draft.endLine
? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine })
: t('chat.message.context.codeComment', { file: basename(draft.fileLabel), start: draft.startLine, end: draft.endLine }))
: t('chat.message.context.fileQuote', { file: basename(draft.fileLabel) });
default:
return draft.startLine === draft.endLine
? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine })
: t('chat.message.context.codeComment', { file: basename(draft.fileLabel), start: draft.startLine, end: draft.endLine });
}
}, [t]);
const groups = React.useMemo<ChipGroup[]>(() => {
const result: ChipGroup[] = [];
const byKind = (
key: string,
icon: IconName,
label: string,
match: (draft: InlineCommentDraft) => boolean,
iconClassName?: string,
) => {
const matched = drafts.filter(match);
if (matched.length > 0) {
result.push({ key, icon, iconClassName, label, count: matched.length, drafts: matched });
}
};
for (const draft of drafts) {
if (draft.source !== 'terminal') continue;
result.push({
key: `terminal-${draft.id}`,
icon: 'terminal',
label: t('chat.chatInput.terminalContext', {
terminal: draft.fileLabel,
start: draft.startLine,
end: draft.endLine,
}),
count: 0,
drafts: [draft],
});
}
byKind('review', 'chat-1', t('chat.chatInput.reviewComments'), (draft) => REVIEW_SOURCES.includes(draft.source));
byKind('pr-comment', 'git-pull-request', t('chat.chatInput.prCommentContext'), (draft) => draft.source === 'pr-comment');
byKind('pr-check', 'close-circle', t('chat.chatInput.prCheckContext'), (draft) => draft.source === 'pr-check', 'text-[var(--status-error)]');
byKind('chat-quote', 'chat-1', t('chat.chatInput.chatQuoteContext'), (draft) => draft.source === 'chat-quote');
byKind('annotation', 'global', t('chat.chatInput.previewAnnotations'), (draft) => draft.source === 'preview-annotation');
return result;
}, [drafts, t]);
React.useEffect(() => {
if (openGroupKey && !groups.some((group) => group.key === openGroupKey)) {
setOpenGroupKey(null);
setEditingDraftId(null);
}
}, [groups, openGroupKey]);
if (!draftTarget || drafts.length === 0) return null;
const openGroup = openGroupKey ? groups.find((group) => group.key === openGroupKey) ?? null : null;
return (
<div className="relative" ref={containerRef}>
{openGroup ? (
<div
className="oc-glass-popover absolute bottom-full left-0 z-30 mb-1.5 w-full max-w-[480px] overflow-hidden rounded-xl border border-[var(--interactive-border)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
>
<div className="max-h-[min(50vh,420px)] divide-y divide-[var(--interactive-border)] overflow-y-auto">
{openGroup.drafts.map((draft, index) => (
<DraftPreviewEntry
key={draft.id}
draft={draft}
index={index}
title={titleFor(draft)}
editing={editingDraftId === draft.id}
onStartEdit={() => setEditingDraftId(draft.id)}
onEndEdit={() => setEditingDraftId((current) => (current === draft.id ? null : current))}
onRemove={() => removeDraft(draftTarget, draft.id)}
onSaveComment={editableSource(draft.source)
? (text) => updateDraft(draftTarget, draft.id, { text })
: null}
/>
))}
</div>
</div>
))}
{reviewCount > 0 ? (
<CountChip
label={t('chat.chatInput.reviewComments')}
count={reviewCount}
removeLabel={t('chat.chatInput.reviewCommentsRemove')}
onRemove={onRemoveReviewDrafts}
colors={colors}
/>
) : null}
{prCommentCount > 0 ? (
<CountChip
label={t('chat.chatInput.prCommentContext')}
count={prCommentCount}
removeLabel={t('chat.chatInput.prCommentContextRemove')}
onRemove={() => onRemovePreviewDrafts('pr-comment')}
colors={colors}
icon={<Icon name="git-pull-request" className="h-3.5 w-3.5 text-muted-foreground" />}
/>
) : null}
{prCheckCount > 0 ? (
<CountChip
label={t('chat.chatInput.prCheckContext')}
count={prCheckCount}
removeLabel={t('chat.chatInput.prCheckContextRemove')}
onRemove={() => onRemovePreviewDrafts('pr-check')}
colors={colors}
icon={<Icon name="close-circle" className="h-3.5 w-3.5 text-[var(--status-error)]" />}
/>
) : null}
{previewConsoleCount > 0 ? (
<CountChip
label={t('chat.chatInput.devServerLogs')}
count={previewConsoleCount}
removeLabel={t('chat.chatInput.devServerLogsRemove')}
onRemove={() => onRemovePreviewDrafts('preview-console')}
colors={colors}
/>
) : null}
{previewAnnotationCount > 0 ? (
<CountChip
label={t('chat.chatInput.previewAnnotations')}
count={previewAnnotationCount}
removeLabel={t('chat.chatInput.previewContextRemove')}
onRemove={() => onRemovePreviewDrafts('preview-annotation')}
colors={colors}
/>
) : null}
<div className="flex flex-wrap items-center gap-2 pb-2">
{groups.map((group) => (
<button
key={group.key}
type="button"
className="inline-flex max-w-full items-center gap-1.5 rounded-xl border px-2.5 py-1 text-left"
style={{
backgroundColor: colors?.surface?.elevated,
borderColor: colors?.interactive?.border,
}}
onMouseEnter={() => {
cancelClose();
setOpenGroupKey(group.key);
}}
onMouseLeave={scheduleClose}
onClick={() => {
if (editingRef.current) return;
setOpenGroupKey((current) => (current === group.key ? null : group.key));
}}
aria-expanded={openGroupKey === group.key}
>
<Icon name={group.icon} className={`h-3.5 w-3.5 shrink-0 text-muted-foreground ${group.iconClassName ?? ''}`} />
<span className="truncate text-xs font-medium text-muted-foreground">{group.label}</span>
{group.count > 0 ? (
<span className="text-xs font-semibold" style={{ color: colors?.status?.info }}>
{group.count}
</span>
) : null}
</button>
))}
</div>
</div>
);
}
@@ -12,6 +12,7 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
import {
Select,
SelectContent,
@@ -23,8 +24,10 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import { useKeybind } from '@/hooks/useKeybind';
import type { Theme } from '@/types/theme';
import { normalizePath } from '../attachments/filePaths';
import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget';
@@ -54,10 +57,12 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
projectColor ? PROJECT_COLOR_MAP[projectColor] ?? undefined : undefined;
/** A project's icon (custom image, configured icon, or a folder) plus its name. */
export function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = getProjectIconColor(project.color);
const fallbackIcon = projectIconName ? (
const fallbackIcon = project.kind === 'chat' ? (
<Icon name="chat-4" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
) : projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
@@ -103,25 +108,61 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
onDirectoryChange,
theme,
} = props;
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
const handlePickerKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (openPicker === null || !shouldDismissDropdown(event)) return;
event.preventDefault();
event.stopPropagation();
setOpenPicker(null);
};
useKeybind('open_draft_project_picker', () => {
projectTriggerRef.current?.focus();
setOpenPicker('project');
});
useKeybind('open_draft_worktree_picker', () => {
if (!showBranchSelector) return false;
worktreeTriggerRef.current?.focus();
setOpenPicker('worktree');
});
const handleProjectChange = (projectId: string) => {
onProjectChange(projectId);
setOpenPicker(null);
};
const handleDirectoryChange = (directory: string) => {
onDirectoryChange(directory);
setOpenPicker(null);
};
return (
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
<Select
value={selectedProject.id}
onValueChange={onProjectChange}
open={openPicker === 'project'}
onOpenChange={(open) => setOpenPicker(open ? 'project' : null)}
onValueChange={handleProjectChange}
disableGlobalShortcuts
>
<SelectTrigger
ref={projectTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
<SelectValue>
{<ProjectLabel project={selectedProject} theme={theme} />}
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue>
</SelectTrigger>
<SelectContent fitContent>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent onKeyDown={handlePickerKeyDown}>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
{<ProjectLabel project={project} theme={theme} />}
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
<ProjectLabel project={project} theme={theme} />
</SelectItem>
))}
</SelectContent>
@@ -130,9 +171,14 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{showBranchSelector ? (
<Select
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
onValueChange={onDirectoryChange}
open={openPicker === 'worktree'}
onOpenChange={(open) => setOpenPicker(open ? 'worktree' : null)}
onValueChange={handleDirectoryChange}
disableGlobalShortcuts
>
<SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
@@ -140,11 +186,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
<SelectContent className="w-max min-w-48">
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
{projectRootBranchOption ? (
<SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
{projectRootBranchOption.label}
</SelectItem>
</SelectGroup>
@@ -163,13 +209,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
</button>
</div>
{worktreeBranchOptions.map((option) => (
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
<SelectItem key={option.value} value={option.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
{option.pending ? '⏳ ' : ''}{option.label}
</SelectItem>
))}
</SelectGroup>
{selectedDirectory && !selectedBranchIsKnown ? (
<SelectItem value={selectedDirectory} className="max-w-[24rem] truncate">
<SelectItem value={selectedDirectory} showSelectedBackground={false} className="max-w-[24rem] truncate">
{selectedBranchLabel}
</SelectItem>
) : null}
@@ -195,7 +241,9 @@ export function MobileDraftTargetTriggers(
className="inline-flex h-7 min-w-0 max-w-[42vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('project')}
>
{<ProjectLabel project={selectedProject} theme={theme} />}
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
{showBranchSelector ? (
@@ -258,13 +306,7 @@ export function MobileDraftTargetSheets(
className="h-9"
/>
<div className="flex flex-col">
{projects
.filter((project) => {
const needle = query.trim().toLowerCase();
if (!needle) return true;
return getProjectDisplayLabel(project).toLowerCase().includes(needle)
|| project.path.toLowerCase().includes(needle);
})
{rankByQuery(projects, query, (project) => [getProjectDisplayLabel(project), project.path])
.map((project) => (
<button
key={project.id}
@@ -275,7 +317,7 @@ export function MobileDraftTargetSheets(
onOpenPickerChange(null);
}}
>
<span className="min-w-0 flex-1">{<ProjectLabel project={project} theme={theme} />}</span>
<span className="min-w-0 flex-1"><ProjectLabel project={project} theme={theme} /></span>
{project.id === selectedProject.id ? (
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
) : null}
@@ -298,8 +340,7 @@ export function MobileDraftTargetSheets(
/>
<div className="flex flex-col">
{(() => {
const needle = query.trim().toLowerCase();
const matches = (label: string) => !needle || label.toLowerCase().includes(needle);
const matches = (label: string) => matchesRankQuery([label], query);
const selectedValue = selectedDirectory
?? branchItems[0]?.value
?? normalizePath(selectedProject.path)
@@ -343,8 +384,7 @@ export function MobileDraftTargetSheets(
{t('chat.chatInput.worktreeNew')}
</button>
</div>
{worktreeBranchOptions
.filter((option) => matches(option.label))
{rankByQuery(worktreeBranchOptions, query, (option) => [option.label])
.map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))}
{selectedDirectory && !selectedBranchIsKnown && matches(selectedBranchLabel ?? '')
? renderRow(selectedDirectory, selectedBranchLabel, 'unknown-current')
@@ -5,7 +5,12 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { cn, isMacOS } from '@/lib/utils';
import {
formatShortcutForDisplay,
getEffectiveShortcutCombo,
} from '@/lib/shortcuts';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
type FocusModeButtonProps = {
footerIconButtonClass: string;
@@ -17,6 +22,12 @@ type FocusModeButtonProps = {
export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
const { t } = useI18n();
const expandInputShortcutOverride = useUIStore((state) => state.shortcutOverrides.expand_input);
const expandInputCombo = getEffectiveShortcutCombo(
'expand_input',
expandInputShortcutOverride === undefined ? undefined : { expand_input: expandInputShortcutOverride },
);
const shortcut = expandInputCombo ? formatShortcutForDisplay(expandInputCombo) : null;
return (
<Tooltip>
@@ -43,9 +54,7 @@ export const FocusModeButton = React.memo(function FocusModeButton(props: FocusM
<TooltipContent side="top" sideOffset={8}>
<div className="flex flex-col gap-0.5 text-center">
<span>{t('chat.chatInput.focusMode.label')}</span>
<span className="font-mono opacity-60">
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
</span>
{shortcut ? <span className="font-mono opacity-60">{shortcut}</span> : null}
</div>
</TooltipContent>
</Tooltip>
@@ -84,7 +84,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
/>
<div className="flex items-center gap-2">
<div
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
data-mobile-composer-pill="true"
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
>
<ComposerAttachmentControls
@@ -93,7 +93,8 @@ export const RevertedMessageDock: React.FC<RevertedMessageDockProps> = React.mem
if (!sessionId || restoringId) return;
setRestoringId(messageId);
try {
const nextMessage = userMessages.find((message) => message.id > messageId);
const messageIndex = userMessages.findIndex((message) => message.id === messageId);
const nextMessage = messageIndex >= 0 ? userMessages[messageIndex + 1] : undefined;
if (nextMessage) {
await revertToMessage(sessionId, nextMessage.id, { skipRedoPush: true });
} else {
@@ -103,7 +103,7 @@ const STYLE_CLASS: Record<AnyStyle, string> = {
mentionAgent: 'text-[var(--status-success)]',
mentionCommand: 'text-[var(--primary)]',
mentionSnippet: 'text-[var(--status-warning)]',
code: 'rounded-[3px] bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)]',
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
// A `~path` is written for the reader's benefit, not to attach anything —
// it takes the same colour as a file mention, since it names the same kind
@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test';
import { mentionServerQuery, rankFileMentionResults, tokenizeMentionQuery } from './fileMentionResults';
const hit = (relativePath: string) => {
const name = relativePath.split('/').filter(Boolean).pop() ?? relativePath;
return {
name,
path: `/root/${relativePath}`,
relativePath,
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
};
};
describe('tokenizeMentionQuery', () => {
test('normalizes leading ./ and slashes and splits on whitespace', () => {
expect(tokenizeMentionQuery('./Solo Team')).toEqual(['solo', 'team']);
expect(tokenizeMentionQuery(' ')).toEqual([]);
});
});
describe('mentionServerQuery', () => {
test('uses the longest token for the server search', () => {
expect(mentionServerQuery('team solo-is-a')).toBe('solo-is-a');
expect(mentionServerQuery('')).toBe('');
});
});
describe('rankFileMentionResults', () => {
test('ranks files and directories together by match quality, not by category', () => {
const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')];
const directories = [hit('machine-learning/tensorflow/'), hit('solo-is-a-team-size/')];
const ranked = rankFileMentionResults(files, directories, 'solo');
const paths = ranked.map((entry) => entry.relativePath);
expect(paths.slice(0, 2)).toEqual(['solo-is-a-team-size/', 'solo-is-a-team-size/index.md']);
expect(paths).not.toContain('machine-learning/tensorflow/');
});
test('multi-token queries match tokens in any order across the path', () => {
const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')];
const ranked = rankFileMentionResults(files, [], 'team solo');
expect(ranked.map((entry) => entry.relativePath)).toEqual(['solo-is-a-team-size/index.md']);
});
test('tags each result with its kind', () => {
const ranked = rankFileMentionResults([hit('a/readme.md')], [hit('a/')], 'a');
expect(ranked.find((entry) => entry.relativePath === 'a/')?.kind).toBe('directory');
expect(ranked.find((entry) => entry.relativePath === 'a/readme.md')?.kind).toBe('file');
});
});

Some files were not shown because too many files have changed in this diff Show More