Merge remote-tracking branch 'origin/main' into port-2738
# Conflicts: # packages/ui/src/components/session/sidebar/DOCUMENTATION.md # packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx # packages/ui/src/components/session/sidebar/sessions/sessionNodeItemUtils.test.ts
This commit is contained in:
+20
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.18.1",
|
||||
"version": "1.21.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/main.tsx",
|
||||
@@ -8,7 +8,8 @@
|
||||
"dev": "tsc --noEmit --watch",
|
||||
"build": "tsc --noEmit",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
|
||||
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js",
|
||||
"test": "node ../../scripts/run-isolated-tests.mjs src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||
@@ -18,38 +19,38 @@
|
||||
"@capacitor/keyboard": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.1.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.11.0",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "6.39.13",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@codemirror/lint": "^6.9.7",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "6.43.9",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@legendapp/list": "3.3.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "1.18.12",
|
||||
"@opencode-ai/sdk": "1.18.25",
|
||||
"@pierre/diffs": "1.3.0-beta.6",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vim": "^6.4.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@tanstack/react-virtual": "3.14.5",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"@zumer/snapdom": "^2.12.0",
|
||||
"beautiful-mermaid": "^1.1.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -66,6 +67,7 @@
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^17.0.3",
|
||||
"marked-linkify-it": "^4.0.2",
|
||||
"morphdom": "^2.7.7",
|
||||
"motion": "^12.23.24",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -103,6 +105,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"globals": "^16.3.0",
|
||||
"happy-dom": "^18.0.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
|
||||
+59
-36
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 +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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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; };
|
||||
|
||||
@@ -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; }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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';
|
||||
@@ -131,8 +131,6 @@ interface ChatMessageProps {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
};
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
animationHandlers?: AnimationHandlers;
|
||||
scrollToBottom?: () => void;
|
||||
turnGroupingContext?: TurnGroupingContext;
|
||||
assistantHeaderMessageId?: string;
|
||||
@@ -147,8 +145,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
message,
|
||||
previousMessage,
|
||||
nextMessage,
|
||||
onContentChange,
|
||||
animationHandlers,
|
||||
turnGroupingContext,
|
||||
assistantHeaderMessageId,
|
||||
isInActiveTurn = false,
|
||||
@@ -202,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;
|
||||
|
||||
@@ -460,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 [];
|
||||
@@ -548,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) {
|
||||
@@ -703,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) {
|
||||
@@ -853,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) => {
|
||||
@@ -904,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;
|
||||
@@ -1044,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)',
|
||||
@@ -1070,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}
|
||||
@@ -1084,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}
|
||||
/>
|
||||
@@ -1107,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}
|
||||
@@ -1121,7 +928,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
contextPinPending={pinPending}
|
||||
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
userActionsMode="external-actions"
|
||||
stickyUserHeaderEnabled={stickyUserHeader}
|
||||
/>
|
||||
@@ -1154,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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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[]>([]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+252
@@ -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;
|
||||
@@ -157,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,
|
||||
@@ -234,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
|
||||
@@ -288,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'] }
|
||||
@@ -344,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
|
||||
@@ -451,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]);
|
||||
|
||||
@@ -508,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);
|
||||
});
|
||||
});
|
||||
|
||||
+28
@@ -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;
|
||||
|
||||
+53
-30
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user