Merge main
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.19.0",
|
||||
"version": "1.21.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/main.tsx",
|
||||
@@ -43,8 +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.18",
|
||||
"@opencode-ai/sdk": "1.18.23",
|
||||
"@pierre/diffs": "1.3.0-beta.6",
|
||||
"@replit/codemirror-vim": "^6.4.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
@@ -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",
|
||||
|
||||
+24
-29
@@ -1,16 +1,19 @@
|
||||
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';
|
||||
@@ -18,7 +21,6 @@ import { useAgentMemorySync } from '@/hooks/useAgentMemorySync';
|
||||
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { hasModifier } from '@/lib/utils';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop';
|
||||
import {
|
||||
getInjectedBootOutcome,
|
||||
@@ -33,7 +35,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';
|
||||
@@ -279,6 +280,13 @@ function App({ apis }: AppProps) {
|
||||
};
|
||||
}, [showMemoryDebug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setRequestsInFlightTrackingEnabled(showMemoryDebug);
|
||||
return () => {
|
||||
setRequestsInFlightTrackingEnabled(false);
|
||||
};
|
||||
}, [showMemoryDebug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
applyMobileKeyboardMode(mobileKeyboardMode);
|
||||
}, [mobileKeyboardMode]);
|
||||
@@ -625,7 +633,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);
|
||||
};
|
||||
|
||||
@@ -639,12 +646,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);
|
||||
@@ -676,11 +680,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),
|
||||
});
|
||||
};
|
||||
@@ -721,28 +726,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(() => {
|
||||
@@ -908,6 +901,7 @@ function App({ apis }: AppProps) {
|
||||
isVSCodeRuntime={isVSCodeRuntime}
|
||||
embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
|
||||
/>
|
||||
<AppLinkConfirmDialog />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</RuntimeAPIProvider>
|
||||
@@ -951,6 +945,7 @@ function App({ apis }: AppProps) {
|
||||
<OpenCodeUpdateToast />
|
||||
<MainLayout />
|
||||
<Toaster />
|
||||
<AppLinkConfirmDialog />
|
||||
{!isBootShell && (
|
||||
<>
|
||||
<ConfigUpdateOverlay />
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { opencodeClient } from '@/lib/opencode/client';
|
||||
@@ -25,6 +26,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 +155,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 +168,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 +282,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 +296,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;
|
||||
};
|
||||
@@ -321,6 +326,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';
|
||||
@@ -109,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<{ id: 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);
|
||||
@@ -540,7 +543,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<PlanView
|
||||
projectPlanId={openPlan.id}
|
||||
savedProjectPlan={{ projectRef: openPlan.projectRef, planId: openPlan.id }}
|
||||
onNavigatedToChat={() => {
|
||||
closeSurface();
|
||||
closeWorkspace();
|
||||
@@ -771,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);
|
||||
@@ -1258,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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getProjectLabel, normalizePath } from './mobilePaths';
|
||||
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 +189,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'>;
|
||||
@@ -1355,7 +1353,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 +1383,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) => {
|
||||
|
||||
@@ -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: { id: 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,8 +8,10 @@ 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 { opencodeClient } from '@/lib/opencode/client';
|
||||
@@ -56,6 +58,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
usePushVisibilityBeacon({ enabled: true });
|
||||
useWindowTitle();
|
||||
useRouter();
|
||||
useGlobalSessionsPolling(panelType !== 'agentManager');
|
||||
|
||||
React.useEffect(() => {
|
||||
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
|
||||
@@ -108,6 +111,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 +131,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 />
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -16,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';
|
||||
@@ -37,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);
|
||||
}
|
||||
@@ -59,7 +57,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
// 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.
|
||||
@@ -71,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,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>
|
||||
);
|
||||
};
|
||||
@@ -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,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>
|
||||
);
|
||||
};
|
||||
@@ -18,8 +18,9 @@ import { StatusRowContainer } from './StatusRowContainer';
|
||||
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
import { useScrollShadow } from '@/components/ui/useScrollShadow';
|
||||
import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
|
||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||
import { TimelineDialog } from './TimelineDialog';
|
||||
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
||||
@@ -60,11 +61,14 @@ import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shel
|
||||
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { createFirstVisibleSessionPerformanceTracker } from '@/sync/session-load-performance';
|
||||
import { isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
|
||||
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
|
||||
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
|
||||
const DRAFT_EXIT_DURATION_MS = 120;
|
||||
const COMPOSER_MOVE_DURATION_MS = 180;
|
||||
const CHAT_SCROLL_STYLE = {
|
||||
overflowAnchor: 'none',
|
||||
overscrollBehavior: 'contain',
|
||||
@@ -148,11 +152,15 @@ type ChatViewportProps = {
|
||||
currentSessionKey: string;
|
||||
isDesktopExpandedInput: boolean;
|
||||
isMobile: boolean;
|
||||
stickyUserHeader: boolean;
|
||||
directory?: string;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
messageListRef: React.RefObject<MessageListHandle | null>;
|
||||
pendingRevealWork: boolean;
|
||||
registerList: (list: TimelineListHandle | null) => void;
|
||||
anchorMessageId: string | null;
|
||||
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
||||
onAnchorSizeChanged: (messageId: string) => void;
|
||||
onIsAtEndChange: (isAtEnd: boolean) => void;
|
||||
onTimelineDataChange: () => void;
|
||||
renderedMessages: SessionMessageRecord[];
|
||||
isLoadingOlder: boolean;
|
||||
sessionIsWorking: boolean;
|
||||
@@ -164,10 +172,11 @@ type ChatViewportProps = {
|
||||
confirmedAt?: number;
|
||||
fallbackTimestamp?: number;
|
||||
} | null;
|
||||
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
handleHistoryScroll: () => void;
|
||||
scrollToBottom: () => void;
|
||||
endPinningReleased: boolean;
|
||||
// One-shot fade for content that replaced the hydration skeleton;
|
||||
// cached sessions render instantly without it.
|
||||
revealContent: boolean;
|
||||
sessionQuestions: QuestionRequest[];
|
||||
sessionPermissions: PermissionRequest[];
|
||||
isProgrammaticFollowActive: boolean;
|
||||
@@ -187,21 +196,24 @@ const ChatViewport = React.memo(({
|
||||
currentSessionKey,
|
||||
isDesktopExpandedInput,
|
||||
isMobile,
|
||||
stickyUserHeader,
|
||||
directory,
|
||||
scrollRef,
|
||||
messageListRef,
|
||||
pendingRevealWork,
|
||||
registerList,
|
||||
anchorMessageId,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onTimelineDataChange,
|
||||
renderedMessages,
|
||||
isLoadingOlder,
|
||||
sessionIsWorking,
|
||||
streamingMessageId,
|
||||
activeStreamingPhase,
|
||||
retryOverlay,
|
||||
handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
handleHistoryScroll,
|
||||
scrollToBottom,
|
||||
endPinningReleased,
|
||||
revealContent,
|
||||
sessionQuestions,
|
||||
sessionPermissions,
|
||||
isProgrammaticFollowActive,
|
||||
@@ -312,83 +324,96 @@ const ChatViewport = React.memo(({
|
||||
scrollRef.current?.focus({ preventScroll: true });
|
||||
}, [scrollRef]);
|
||||
|
||||
// Everything that used to sit beside the list inside the scroll container
|
||||
// now renders as the list's header/footer, so it keeps scrolling with the
|
||||
// rows exactly as before.
|
||||
const listHeader = React.useMemo(() => (
|
||||
showLoadOlderButton ? (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLoadOlder}
|
||||
disabled={isLoadingOlder}
|
||||
>
|
||||
{isLoadingOlder && (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('chat.history.loadOlder')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
), [isLoadingOlder, onLoadOlder, showLoadOlderButton, t]);
|
||||
|
||||
const listFooter = React.useMemo(() => (
|
||||
<>
|
||||
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
|
||||
<div>
|
||||
{sessionQuestions.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} />
|
||||
))}
|
||||
{sessionPermissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
||||
|
||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||
</>
|
||||
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
|
||||
|
||||
const scrollContainerProps = React.useMemo(() => ({
|
||||
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
|
||||
style: CHAT_SCROLL_STYLE,
|
||||
tabIndex: 0,
|
||||
onClick: focusScrollContainer,
|
||||
'data-scrollbar': 'chat',
|
||||
'data-scroll-shadow': 'true',
|
||||
'data-orientation': 'vertical',
|
||||
}), [focusScrollContainer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
||||
: 'flex-1'
|
||||
: 'flex-1',
|
||||
revealContent && !isDesktopExpandedInput && 'oc-chat-hydration-reveal',
|
||||
)}
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<ScrollShadow
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||
ref={scrollRef}
|
||||
style={CHAT_SCROLL_STYLE}
|
||||
observeMutations={false}
|
||||
hideTopShadow={isMobile && stickyUserHeader}
|
||||
tabIndex={0}
|
||||
onClick={focusScrollContainer}
|
||||
onScroll={handleHistoryScroll}
|
||||
data-scroll-shadow="true"
|
||||
data-scrollbar="chat"
|
||||
>
|
||||
<div className="relative z-0 min-h-full">
|
||||
{showLoadOlderButton && (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onLoadOlder}
|
||||
disabled={isLoadingOlder}
|
||||
>
|
||||
{isLoadingOlder && (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('chat.history.loadOlder')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MessageList
|
||||
key={currentSessionKey}
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
disableStaging={pendingRevealWork}
|
||||
messages={renderedMessages}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
activeStreamingMessageId={streamingMessageId}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
retryOverlay={retryOverlay}
|
||||
onMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
scrollRef={scrollRef}
|
||||
directory={directory}
|
||||
/>
|
||||
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
|
||||
<div>
|
||||
{sessionQuestions.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} />
|
||||
))}
|
||||
{sessionPermissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
||||
|
||||
<div className="mb-3">
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||
<MessageList
|
||||
key={currentSessionKey}
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
messages={renderedMessages}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
activeStreamingMessageId={streamingMessageId}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
retryOverlay={retryOverlay}
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
endPinningReleased={endPinningReleased}
|
||||
directory={directory}
|
||||
registerList={registerList}
|
||||
anchorMessageId={anchorMessageId}
|
||||
onAnchorReady={onAnchorReady}
|
||||
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||
// Zero end inset: the footer spacer already reserves the
|
||||
// zone the floating status row covers; adding its height
|
||||
// again produced a double-tall blank band at rest.
|
||||
composerOverlayHeight={0}
|
||||
onIsAtEndChange={onIsAtEndChange}
|
||||
onTimelineDataChange={onTimelineDataChange}
|
||||
listHeader={listHeader}
|
||||
listFooter={listFooter}
|
||||
scrollContainerProps={scrollContainerProps}
|
||||
/>
|
||||
<OverlayScrollbar containerRef={scrollRef} disableHorizontal suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
||||
<PromptNavigatorRail
|
||||
turnIds={promptTurnIds}
|
||||
@@ -408,21 +433,18 @@ const ChatViewport = React.memo(({
|
||||
&& prev.currentSessionKey === next.currentSessionKey
|
||||
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.stickyUserHeader === next.stickyUserHeader
|
||||
&& prev.directory === next.directory
|
||||
&& prev.scrollRef === next.scrollRef
|
||||
&& prev.messageListRef === next.messageListRef
|
||||
&& prev.pendingRevealWork === next.pendingRevealWork
|
||||
&& prev.renderedMessages === next.renderedMessages
|
||||
&& prev.isLoadingOlder === next.isLoadingOlder
|
||||
&& prev.sessionIsWorking === next.sessionIsWorking
|
||||
&& prev.streamingMessageId === next.streamingMessageId
|
||||
&& prev.activeStreamingPhase === next.activeStreamingPhase
|
||||
&& prev.retryOverlay === next.retryOverlay
|
||||
&& prev.handleMessageContentChange === next.handleMessageContentChange
|
||||
&& prev.getAnimationHandlers === next.getAnimationHandlers
|
||||
&& prev.handleHistoryScroll === next.handleHistoryScroll
|
||||
&& prev.scrollToBottom === next.scrollToBottom
|
||||
&& prev.endPinningReleased === next.endPinningReleased
|
||||
&& prev.revealContent === next.revealContent
|
||||
&& prev.sessionQuestions === next.sessionQuestions
|
||||
&& prev.sessionPermissions === next.sessionPermissions
|
||||
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
|
||||
@@ -475,9 +497,11 @@ const ReadOnlyPromptBanner: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="rounded-2xl border border-border/70 bg-[var(--surface-background)] px-4 py-3 typography-ui-label text-muted-foreground">
|
||||
{t('chat.container.readOnlySubagentPromptBanner')}
|
||||
<div className="w-full py-3">
|
||||
<div className="chat-input-column">
|
||||
<div className="rounded-2xl border border-border/70 bg-[var(--surface-background)] px-4 py-3 text-center typography-ui-label text-muted-foreground">
|
||||
{t('chat.container.readOnlySubagentPromptBanner')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -502,19 +526,24 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea
|
||||
);
|
||||
};
|
||||
|
||||
const DraftWelcome: React.FC = () => {
|
||||
const DraftWelcome: React.FC<{ exiting?: boolean }> = ({ exiting = false }) => {
|
||||
const { t } = useI18n();
|
||||
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
|
||||
const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null);
|
||||
const projectLabel = useProjectsStore(React.useCallback((state) => {
|
||||
if (draftTarget === 'chat') return null;
|
||||
const projectId = selectedProjectId ?? state.activeProjectId;
|
||||
const project = (projectId
|
||||
? state.projects.find((candidate) => candidate.id === projectId)
|
||||
: null) ?? state.projects[0] ?? null;
|
||||
return project ? getProjectDisplayLabel(project) : null;
|
||||
}, [selectedProjectId]));
|
||||
}, [draftTarget, selectedProjectId]));
|
||||
|
||||
return (
|
||||
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<div className={cn(
|
||||
'oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center transition-opacity duration-[120ms] ease-out motion-reduce:transition-none',
|
||||
exiting && 'pointer-events-none opacity-0',
|
||||
)}>
|
||||
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
||||
{renderDraftTitle(
|
||||
projectLabel
|
||||
@@ -558,6 +587,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
// Session UI state
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
|
||||
const materializedDraftSessionId = useSessionUIStore((s) => s.materializedDraftSessionId);
|
||||
const clearMaterializedDraftSession = useSessionUIStore((s) => s.clearMaterializedDraftSession);
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||
@@ -615,6 +646,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
suspendPartUpdatesForMessageId: streamingMessageId,
|
||||
});
|
||||
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
|
||||
const authSessionExpired = useAuthSessionStore((store) => store.state !== 'ok');
|
||||
const wasAuthExpiredRef = React.useRef(false);
|
||||
const sessionMessageLoadState = useSessionMessageLoadState(
|
||||
currentSessionId ?? '',
|
||||
effectiveSessionDirectory,
|
||||
@@ -731,12 +764,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
const draftOpen = Boolean(newSessionDraft?.open);
|
||||
const isManagedChatContext = draftOpen
|
||||
? newSessionDraft?.target === 'chat'
|
||||
: isChatDirectoryPath(effectiveSessionDirectory);
|
||||
// A draft can target another project or a pending worktree before it has a
|
||||
// session. Keep the panel on that same directory so its project, MCP, and
|
||||
// usage readouts describe where the draft will run rather than the project
|
||||
// the user came from.
|
||||
const workStatusDirectory = draftOpen
|
||||
? newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory
|
||||
? (isManagedChatContext ? null : newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory)
|
||||
: effectiveSessionDirectory;
|
||||
const initError = useGlobalSyncStore((s) => s.error);
|
||||
// Despite the historical name, this now covers mobile too: the mobile
|
||||
@@ -748,7 +784,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
// row that holds both columns, so its width never depends on the panel's
|
||||
// own visibility.
|
||||
const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({
|
||||
directory: workStatusDirectory,
|
||||
isMobile,
|
||||
isVSCode,
|
||||
});
|
||||
@@ -785,6 +820,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
return () => setWorkStatusPanelVisible(false);
|
||||
}, [setWorkStatusPanelVisible, showWorkStatusPanel]);
|
||||
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
||||
// Session keys that showed the hydration skeleton this app run; their
|
||||
// content gets a one-shot reveal fade once it replaces the skeleton.
|
||||
const hydrationRevealKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
|
||||
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
|
||||
|
||||
@@ -877,23 +916,67 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
activeTurnChangeRef.current(turnId);
|
||||
}, []);
|
||||
|
||||
// The composer sits below the timeline, but the status/working row floats
|
||||
// OVER the timeline's bottom edge; its measured height keeps the live
|
||||
// streaming line above it and reserves matching end inset in the list.
|
||||
const [statusOverlayHeight, setStatusOverlayHeight] = React.useState(0);
|
||||
const composerOverlayHeight = statusOverlayHeight;
|
||||
const statusOverlayObserverRef = React.useRef<ResizeObserver | null>(null);
|
||||
const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => {
|
||||
statusOverlayObserverRef.current?.disconnect();
|
||||
statusOverlayObserverRef.current = null;
|
||||
if (!node || !globalThis.ResizeObserver) {
|
||||
setStatusOverlayHeight(0);
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
// +8 for the mb-2 gap between the row and the composer, which the
|
||||
// node's own box does not include.
|
||||
const height = node.getBoundingClientRect().height + 8;
|
||||
setStatusOverlayHeight((prev) => (Math.abs(prev - height) < 1 ? prev : height));
|
||||
};
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(node);
|
||||
statusOverlayObserverRef.current = observer;
|
||||
update();
|
||||
}, []);
|
||||
React.useEffect(() => () => {
|
||||
statusOverlayObserverRef.current?.disconnect();
|
||||
statusOverlayObserverRef.current = null;
|
||||
}, []);
|
||||
const lastUserMessageId = React.useMemo(() => {
|
||||
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = sessionMessages[index];
|
||||
if (message.info.role === 'user') {
|
||||
return message.info.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [sessionMessages]);
|
||||
|
||||
const {
|
||||
scrollRef,
|
||||
notifyContentChange: handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
scrollNode,
|
||||
registerList,
|
||||
anchorMessageId,
|
||||
onAnchorReady,
|
||||
onAnchorSizeChanged,
|
||||
onIsAtEndChange,
|
||||
onManualNavigation,
|
||||
onTimelineDataChange,
|
||||
goToBottom,
|
||||
scrollToBottomOnSend,
|
||||
releaseAutoFollow,
|
||||
restoreSnapshot,
|
||||
isPinned,
|
||||
isFollowingProgrammatically,
|
||||
showScrollButton,
|
||||
} = useChatAutoFollow({
|
||||
userOwnsScroll,
|
||||
} = useChatTimelineScroll({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
sessionIsWorking,
|
||||
isMobile,
|
||||
composerOverlayHeight,
|
||||
lastUserMessageId,
|
||||
onActiveTurnChange: handleActiveTurnChange,
|
||||
});
|
||||
|
||||
@@ -908,33 +991,49 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
messageListRef,
|
||||
loadMoreMessages,
|
||||
goToBottom,
|
||||
releaseAutoFollow,
|
||||
releaseAutoFollow: onManualNavigation,
|
||||
isPinned,
|
||||
showScrollButton,
|
||||
});
|
||||
// The list owns the scroll element, so the shadows and the load-older
|
||||
// trigger bind to its node rather than to a wrapper we render.
|
||||
const scrollNodeRef = React.useMemo(() => ({ current: scrollNode }), [scrollNode]);
|
||||
useScrollShadow(scrollNodeRef, {
|
||||
observeMutations: false,
|
||||
hideTopShadow: isMobile && stickyUserHeader,
|
||||
});
|
||||
|
||||
const handleHistoryScroll = timelineController.handleHistoryScroll;
|
||||
React.useEffect(() => {
|
||||
if (!scrollNode) return;
|
||||
const onScroll = () => handleHistoryScroll();
|
||||
scrollNode.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
scrollNode.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [handleHistoryScroll, scrollNode]);
|
||||
|
||||
const resumeToLatestInstant = React.useCallback(() => {
|
||||
goToBottom('instant');
|
||||
}, [goToBottom]);
|
||||
|
||||
// Mobile loads older history via an explicit top button instead of a
|
||||
// scroll-position trigger (see handleHistoryScroll in the controller).
|
||||
const showLoadOlderButton = isMobileSurfaceRuntime()
|
||||
&& timelineController.historySignals.canLoadEarlier;
|
||||
const timelineLoadEarlier = timelineController.loadEarlier;
|
||||
const handleLoadOlderClick = React.useCallback(() => {
|
||||
// Loading older history is an explicit move INTO the past: release
|
||||
// live follow first, or the prepend's content growth would trigger an
|
||||
// end correction and throw the viewport to the bottom.
|
||||
onManualNavigation();
|
||||
void timelineLoadEarlier({ userInitiated: true });
|
||||
}, [timelineLoadEarlier]);
|
||||
}, [onManualNavigation, timelineLoadEarlier]);
|
||||
|
||||
React.useEffect(() => {
|
||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||
}, [timelineController.handleActiveTurnChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (sessionPermissions.length === 0 && sessionQuestions.length === 0) {
|
||||
return;
|
||||
}
|
||||
handleMessageContentChange('permission');
|
||||
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
|
||||
|
||||
const navigation = useChatTurnNavigation({
|
||||
sessionId: currentSessionId,
|
||||
turnIds: timelineController.turnIds,
|
||||
@@ -944,7 +1043,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
resumeToBottom: timelineController.resumeToBottomInstant,
|
||||
});
|
||||
const handlePromptNavigatorSelect = React.useCallback((turnId: string) => {
|
||||
void navigation.scrollToTurnId(turnId, { behavior: 'smooth' });
|
||||
// Instant on purpose: a long smooth scroll through a virtualized
|
||||
// timeline gets cancelled by row remounts and lands mid-way or on the
|
||||
// wrong message; a teleport always arrives.
|
||||
void navigation.scrollToTurnId(turnId, { behavior: 'auto' });
|
||||
}, [navigation]);
|
||||
const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier;
|
||||
const showPromptNavigator = !isMobile
|
||||
@@ -992,8 +1094,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const { activeMainTab } = useUIStore.getState();
|
||||
if (activeMainTab !== 'chat' || hasBlockingChatOverlay()) {
|
||||
if (hasBlockingChatOverlay()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1058,11 +1159,37 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
const isSessionHydrating =
|
||||
Boolean(currentSessionId)
|
||||
&& !hasRenderableSessionSnapshot;
|
||||
React.useEffect(() => {
|
||||
if (isSessionHydrating || hydrationRevealKeyRef.current === null) return;
|
||||
// One-shot: forget the key after the reveal animation has played so a
|
||||
// later (now cached) visit to the same session opens instantly.
|
||||
const timer = setTimeout(() => {
|
||||
hydrationRevealKeyRef.current = null;
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSessionHydrating, currentSessionKey]);
|
||||
const retrySessionLoad = React.useCallback(() => {
|
||||
if (!messagesEnabled || !currentSessionId) return;
|
||||
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
|
||||
}, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]);
|
||||
|
||||
// A load that failed while the session was expired retries itself the
|
||||
// moment the re-login lands — the error screen should never outlive its
|
||||
// cause.
|
||||
React.useEffect(() => {
|
||||
if (authSessionExpired) {
|
||||
wasAuthExpiredRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (wasAuthExpiredRef.current) {
|
||||
wasAuthExpiredRef.current = false;
|
||||
if (sessionMessageLoadState.status === 'error') {
|
||||
retrySessionLoad();
|
||||
}
|
||||
}
|
||||
}, [authSessionExpired, retrySessionLoad, sessionMessageLoadState.status]);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !currentSessionId) return;
|
||||
if (lastScrolledSessionKeyRef.current === currentSessionKey) return;
|
||||
@@ -1071,7 +1198,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
lastScrolledSessionKeyRef.current = currentSessionKey;
|
||||
if (hasHashTarget) {
|
||||
// Hash navigation handler will scroll to target; we just release auto-follow.
|
||||
releaseAutoFollow();
|
||||
onManualNavigation();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1083,7 +1210,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
} else {
|
||||
window.requestAnimationFrame(run);
|
||||
}
|
||||
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]);
|
||||
}, [active, currentSessionId, currentSessionKey, onManualNavigation, restoreSnapshot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!messagesEnabled || !currentSessionId) return;
|
||||
@@ -1091,11 +1218,75 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
void ensureSessionRenderable(currentSessionId);
|
||||
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]);
|
||||
|
||||
const composerSlotRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const previousComposerRectRef = React.useRef<DOMRect | null>(null);
|
||||
const previousDraftOpenRef = React.useRef(draftOpen);
|
||||
const previousDraftLayoutVisibleRef = React.useRef(draftOpen);
|
||||
const [draftExitAnimating, setDraftExitAnimating] = React.useState(false);
|
||||
const shouldAnimateDraftTransition = Boolean(
|
||||
currentSessionId && materializedDraftSessionId === currentSessionId,
|
||||
);
|
||||
const draftPresentationExiting = draftExitAnimating
|
||||
|| (previousDraftOpenRef.current && !draftOpen && shouldAnimateDraftTransition);
|
||||
const draftLayoutVisible = draftOpen || draftPresentationExiting;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (draftOpen) {
|
||||
setDraftExitAnimating(false);
|
||||
return;
|
||||
}
|
||||
if (!previousDraftOpenRef.current || !shouldAnimateDraftTransition) return;
|
||||
|
||||
setDraftExitAnimating(true);
|
||||
const timeoutId = window.setTimeout(() => setDraftExitAnimating(false), DRAFT_EXIT_DURATION_MS);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [draftOpen, shouldAnimateDraftTransition]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
previousDraftOpenRef.current = draftOpen;
|
||||
}, [draftOpen]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const composerSlot = composerSlotRef.current;
|
||||
if (!composerSlot) return;
|
||||
|
||||
const composerEditor = composerSlot.querySelector('[data-testid="chat-input"]');
|
||||
const currentRect = composerEditor?.getBoundingClientRect() ?? composerSlot.getBoundingClientRect();
|
||||
const previousRect = previousComposerRectRef.current;
|
||||
const leftDraftLayout = previousDraftLayoutVisibleRef.current
|
||||
&& !draftLayoutVisible
|
||||
&& Boolean(currentSessionId);
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
|
||||
const shouldMoveComposer = leftDraftLayout && shouldAnimateDraftTransition;
|
||||
if (shouldMoveComposer && previousRect && !reduceMotion && !useCompactDraftLayout && !isDesktopExpandedInput) {
|
||||
const deltaX = previousRect.left - currentRect.left;
|
||||
const deltaY = previousRect.top - currentRect.top;
|
||||
composerSlot.animate(
|
||||
[
|
||||
{ transform: `translate(${deltaX}px, ${deltaY}px)` },
|
||||
{ transform: 'translate(0, 0)' },
|
||||
],
|
||||
{ duration: COMPOSER_MOVE_DURATION_MS, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
|
||||
);
|
||||
}
|
||||
previousComposerRectRef.current = currentRect;
|
||||
previousDraftLayoutVisibleRef.current = draftLayoutVisible;
|
||||
if (leftDraftLayout && currentSessionId) {
|
||||
clearMaterializedDraftSession(currentSessionId);
|
||||
}
|
||||
}, [
|
||||
clearMaterializedDraftSession,
|
||||
currentSessionId,
|
||||
draftLayoutVisible,
|
||||
isDesktopExpandedInput,
|
||||
shouldAnimateDraftTransition,
|
||||
useCompactDraftLayout,
|
||||
]);
|
||||
|
||||
if (!currentSessionId && !draftOpen) {
|
||||
// With auto-open, the draft welcome opens on the next tick (effect below),
|
||||
// so the empty state is only ever transient here — render a neutral
|
||||
// background instead of flashing the logo / "start a new chat" on refresh.
|
||||
// Keep the empty state when there's nothing to auto-open or an init error to show.
|
||||
// The auto-open effect runs on the next tick. Use a neutral background
|
||||
// until then instead of flashing the standard empty state.
|
||||
if (autoOpenDraft && !initError) {
|
||||
return <div className="flex h-full flex-col bg-background" />;
|
||||
}
|
||||
@@ -1106,82 +1297,52 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentSessionId && draftOpen) {
|
||||
return (
|
||||
// No transform on this root: it would become the containing block for
|
||||
// the fullscreen composer's position:fixed visual-viewport pinning in
|
||||
// mobile browsers (see ChatInput's composerFormRef effect).
|
||||
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
|
||||
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col bg-background">
|
||||
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 bg-background'
|
||||
: useCompactDraftLayout
|
||||
? 'bg-background px-0'
|
||||
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
|
||||
)}
|
||||
>
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
{workStatusOverlayMountable ? (
|
||||
<WorkStatusPanel
|
||||
overlay
|
||||
visible={showWorkStatusOverlay}
|
||||
sessionId={null}
|
||||
directory={workStatusDirectory ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{workStatusPanelMountable ? (
|
||||
<WorkStatusPanel
|
||||
visible={showWorkStatusPanel}
|
||||
sessionId={null}
|
||||
directory={workStatusDirectory ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const sessionSurface = (() => {
|
||||
if (draftOpen || draftPresentationExiting) {
|
||||
if (!useCompactDraftLayout || isDesktopExpandedInput) {
|
||||
return null;
|
||||
}
|
||||
return <DraftWelcome exiting={draftPresentationExiting} />;
|
||||
}
|
||||
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking;
|
||||
if (showHydrationSkeleton) {
|
||||
hydrationRevealKeyRef.current = currentSessionKey ?? currentSessionId ?? null;
|
||||
}
|
||||
if (showHydrationSkeleton) {
|
||||
if (sessionMessageLoadState.status === 'error') {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
|
||||
<div className="max-w-sm text-center">
|
||||
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
|
||||
<Icon name="error-warning" className="size-4" />
|
||||
</div>
|
||||
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">
|
||||
{authSessionExpired
|
||||
? t('chat.container.sessionLoadError.authDescription')
|
||||
: t('chat.container.sessionLoadError.description')}
|
||||
</p>
|
||||
{authSessionExpired ? (
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => useAuthSessionStore.getState().markReauthenticating()}>
|
||||
{t('sessionAuth.expired.loginAction')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
|
||||
{t('chat.container.sessionLoadError.retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
if (sessionMessageLoadState.status === 'error') {
|
||||
return (
|
||||
<div data-composer-bound className="relative flex h-full flex-col bg-background">
|
||||
{returnToParentButton}
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
|
||||
<div className="max-w-sm text-center">
|
||||
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
|
||||
<Icon name="error-warning" className="size-4" />
|
||||
</div>
|
||||
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
|
||||
{t('chat.container.sessionLoadError.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 bg-background">
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-composer-bound className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
||||
: 'flex-1'
|
||||
return (
|
||||
<div
|
||||
data-chat-hydration-skeleton=""
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
|
||||
)}
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
@@ -1192,20 +1353,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
<div className="chat-message-column">
|
||||
<div className="space-y-2.5 px-4 py-3">
|
||||
<div className="space-y-1.5">
|
||||
{item.toolRows.map((row) => {
|
||||
return (
|
||||
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
|
||||
<Skeleton className="h-3.5 w-3.5 rounded-full flex-shrink-0" />
|
||||
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
|
||||
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{item.toolRows.map((row) => (
|
||||
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
|
||||
<Skeleton className="h-3.5 w-3.5 shrink-0 rounded-full" />
|
||||
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
|
||||
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<Skeleton className={cn('h-4 rounded-md', item.textWidths[0])} />
|
||||
<Skeleton className={cn('h-4 rounded-md', item.textWidths[1])} />
|
||||
<Skeleton className={cn('h-4 rounded-md', item.textWidths[2])} />
|
||||
{item.textWidths.map((width, index) => (
|
||||
<Skeleton key={`${item.id}-text-${index}`} className={cn('h-4 rounded-md', width)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1214,79 +1373,45 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
return (
|
||||
// No transform here either — same fixed-positioning constraint as the
|
||||
// draft branch above.
|
||||
<div data-composer-bound className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
||||
: 'flex-1'
|
||||
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
|
||||
)}
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
{!isDesktopExpandedInput ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<ChatEmptyState />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
|
||||
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<ChatViewport
|
||||
currentSessionId={currentSessionId}
|
||||
currentSessionKey={currentSessionKey ?? currentSessionId}
|
||||
return (
|
||||
<ChatViewport
|
||||
currentSessionId={currentSessionId ?? ''}
|
||||
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
|
||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||
isMobile={isMobile}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
directory={effectiveSessionDirectory}
|
||||
scrollRef={scrollRef}
|
||||
registerList={registerList}
|
||||
anchorMessageId={anchorMessageId}
|
||||
onAnchorReady={onAnchorReady}
|
||||
onAnchorSizeChanged={onAnchorSizeChanged}
|
||||
onIsAtEndChange={onIsAtEndChange}
|
||||
onTimelineDataChange={onTimelineDataChange}
|
||||
messageListRef={messageListRef}
|
||||
pendingRevealWork={timelineController.pendingRevealWork}
|
||||
renderedMessages={timelineController.renderedMessages}
|
||||
isLoadingOlder={timelineController.isLoadingOlder}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
streamingMessageId={streamingMessageId}
|
||||
activeStreamingPhase={activeStreamingPhase}
|
||||
retryOverlay={retryOverlay}
|
||||
handleMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
handleHistoryScroll={timelineController.handleHistoryScroll}
|
||||
scrollToBottom={resumeToLatestInstant}
|
||||
endPinningReleased={userOwnsScroll}
|
||||
revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)}
|
||||
sessionQuestions={sessionQuestions}
|
||||
sessionPermissions={sessionPermissions}
|
||||
isProgrammaticFollowActive={isFollowingProgrammatically}
|
||||
@@ -1300,22 +1425,69 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
isLoadingOlderPrompts={timelineController.isLoadingOlder}
|
||||
onLoadEarlierPrompts={handleLoadOlderClick}
|
||||
/>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
|
||||
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
{sessionSurface}
|
||||
|
||||
<div
|
||||
ref={composerSlotRef}
|
||||
className={cn(
|
||||
'relative z-10',
|
||||
'relative z-10 flex min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: draftLayoutVisible && !useCompactDraftLayout
|
||||
? 'flex-1 items-center justify-center bg-background pb-[6vh]'
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
|
||||
<ScrollToBottomButton
|
||||
visible={timelineController.showScrollToBottom}
|
||||
onClick={navigation.resumeToLatest}
|
||||
{!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
|
||||
<>
|
||||
<ScrollToBottomButton
|
||||
visible={timelineController.showScrollToBottom}
|
||||
working={sessionIsWorking}
|
||||
onClick={navigation.resumeToLatest}
|
||||
/>
|
||||
{/* Same anchor and column as the pill, so the status
|
||||
row and the pill it hands off to share the exact
|
||||
distance from the input and the same left edge. */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
|
||||
userOwnsScroll && 'opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="chat-input-column">
|
||||
{/* The glass chip itself is rendered inside
|
||||
StatusRow (its root is a size container
|
||||
that cannot shrink-wrap). */}
|
||||
<div
|
||||
ref={onStatusOverlayNode}
|
||||
className={cn(
|
||||
'[&:not(:has(*))]:hidden',
|
||||
userOwnsScroll ? 'pointer-events-none' : 'pointer-events-auto',
|
||||
)}
|
||||
>
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{promptReadOnly ? (
|
||||
<ReadOnlyPromptBanner />
|
||||
) : (
|
||||
<ChatInput
|
||||
active={active}
|
||||
scrollToBottom={scrollToBottomOnSend}
|
||||
scrollToLatest={resumeToLatestInstant}
|
||||
draftPresentationExiting={draftPresentationExiting}
|
||||
/>
|
||||
)}
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
|
||||
{/* Inside the chat column, not beside it: as a row sibling it took
|
||||
@@ -1327,6 +1499,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
visible={showWorkStatusOverlay}
|
||||
sessionId={currentSessionId ?? null}
|
||||
directory={workStatusDirectory ?? null}
|
||||
repositoryEnabled={!isManagedChatContext}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1349,6 +1522,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
||||
visible={showWorkStatusPanel}
|
||||
sessionId={currentSessionId ?? null}
|
||||
directory={workStatusDirectory ?? null}
|
||||
repositoryEnabled={!isManagedChatContext}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { buildLinkedIssue } from '@/lib/linkedIssues';
|
||||
import { useUserMessageHistory } from "@/sync/sync-context";
|
||||
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
@@ -34,6 +33,10 @@ import {
|
||||
type ChatDraftSnapshot,
|
||||
} from '@/lib/chatDraftPersistence';
|
||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||
import { BtwPanel } from './btw/BtwPanel';
|
||||
import { useBtwPanelState } from './btw/useBtwPanelState';
|
||||
import { wasPromotedBtwSession } from '@/lib/sessionBtwMetadata';
|
||||
import { BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
|
||||
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
@@ -46,12 +49,12 @@ import type { SnippetAutocompleteHandle } from './SnippetAutocomplete';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ModelControls } from './ModelControls';
|
||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
import { StatusRow } from './StatusRow';
|
||||
import { ComposerStatusBar } from './ComposerStatusBar';
|
||||
import { PendingChangesBar } from './PendingChangesBar';
|
||||
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
||||
import { MobileAgentButton } from './MobileAgentButton';
|
||||
import { MobileModelButton } from './MobileModelButton';
|
||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { useCurrentSessionActivity, useSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { toast } from '@/components/ui';
|
||||
// useMessageStore removed — messages now come from sync system
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
@@ -75,6 +78,8 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { togglePermissionAutoAccept } from './permissionAutoAccept';
|
||||
import { useKeybind } from '@/hooks/useKeybind';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
import { extractGitChangedFiles } from './changedFiles';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
@@ -100,10 +105,12 @@ import {
|
||||
type ComposerEditorHandle,
|
||||
} from './composer/editor/ComposerEditor';
|
||||
import { createComposerEditorViewStore } from './composer/editor/viewStore';
|
||||
import { composerAutoCorrect } from './composer/editor/autocorrect';
|
||||
import {
|
||||
appendInlineText,
|
||||
appendWithLineBreaks,
|
||||
buildImagePasteInsertion,
|
||||
getMarkdownAutoPairEdit,
|
||||
shouldWrapSelectionAsLink,
|
||||
withInlineInsertionBoundaries,
|
||||
} from './composer/text';
|
||||
@@ -218,12 +225,17 @@ const MemoModelControls = React.memo(ModelControls);
|
||||
const MemoComposerDictation = React.memo(ComposerDictation);
|
||||
const MemoMobileAgentButton = React.memo(MobileAgentButton);
|
||||
const MemoMobileModelButton = React.memo(MobileModelButton);
|
||||
const MemoStatusRow = React.memo(StatusRow);
|
||||
const MemoComposerStatusBar = React.memo(ComposerStatusBar);
|
||||
|
||||
interface ChatInputProps {
|
||||
onOpenSettings?: () => void;
|
||||
scrollToBottom?: () => void;
|
||||
// Queued sends do not create a user row (the queue delivers later), so
|
||||
// the anchor-arming scrollToBottom is wrong for them; this returns the
|
||||
// viewport to the live edge instead.
|
||||
scrollToLatest?: () => void;
|
||||
active?: boolean;
|
||||
draftPresentationExiting?: boolean;
|
||||
}
|
||||
|
||||
const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
|
||||
@@ -237,7 +249,13 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
|
||||
return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
|
||||
};
|
||||
|
||||
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom, active = true }) => {
|
||||
const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onOpenSettings,
|
||||
scrollToBottom,
|
||||
scrollToLatest,
|
||||
active = true,
|
||||
draftPresentationExiting = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Track if we restored a draft on mount (for text selection)
|
||||
const initialDraftRef = React.useRef<string | null>(null);
|
||||
@@ -309,6 +327,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const currentSessionDirectoryForSync = useSessionUIStore(
|
||||
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
|
||||
);
|
||||
// btw mode: the CURRENT session's metadata links an active btw fork and
|
||||
// the panel is expanded, so this composer's sends route to the fork
|
||||
// instead of the main session. Collapsed keeps the fork alive (chip stays
|
||||
// visible) while the composer talks to the main session again.
|
||||
const btwPanel = useBtwPanelState(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory ?? undefined);
|
||||
const btwSessionId = btwPanel.btwSessionId;
|
||||
const btwDirectory = btwPanel.btwDirectory;
|
||||
const btwSessionRef = React.useMemo<BtwSessionRef | null>(
|
||||
() => (currentSessionId && btwSessionId && btwDirectory
|
||||
? { parentSessionId: currentSessionId, btwSessionId, directory: btwDirectory }
|
||||
: null),
|
||||
[btwDirectory, btwSessionId, currentSessionId],
|
||||
);
|
||||
const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed;
|
||||
// A session promoted out of `/btw` keeps the boundary instructions in its
|
||||
// transcript — there is no way to delete a message part — so it has to say
|
||||
// they no longer apply.
|
||||
const isPromotedBtwSession = wasPromotedBtwSession(btwPanel.parentSession);
|
||||
const activeRuntimeKey = getRuntimeKey();
|
||||
const chatDraftIdentity = React.useMemo(
|
||||
() => createChatDraftIdentity(
|
||||
@@ -326,6 +362,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
|
||||
const setDraftPermissionAutoAcceptEnabled = useSessionUIStore((s) => s.setDraftPermissionAutoAcceptEnabled);
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const prepareChatDraftDirectory = useSessionUIStore((s) => s.prepareChatDraftDirectory);
|
||||
const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId);
|
||||
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
|
||||
const attachedFiles = useInputStore((s) => s.attachedFiles);
|
||||
@@ -336,6 +373,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit);
|
||||
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
|
||||
const pendingInputText = useInputStore((s) => s.pendingInputText);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!newSessionDraftOpen || newSessionDraft.target !== 'chat' || message.trim().length === 0) return;
|
||||
void prepareChatDraftDirectory();
|
||||
}, [message, newSessionDraft.target, newSessionDraftOpen, prepareChatDraftDirectory]);
|
||||
const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts);
|
||||
const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort);
|
||||
const abortCurrentOperation = React.useCallback(
|
||||
@@ -384,7 +426,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const ensureGitStatus = useGitStore((state) => state.ensureStatus);
|
||||
const fetchGitStatus = useGitStore((state) => state.fetchStatus);
|
||||
const clearGitDiffCache = useGitStore((state) => state.clearDiffCache);
|
||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
|
||||
const [isNarrowComposer, setIsNarrowComposer] = React.useState(false);
|
||||
const [attachmentPreview, setAttachmentPreview] = React.useState<ToolPopupContent>({
|
||||
@@ -551,7 +592,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const availableSkills = useSkillsStore((s) => s.skills);
|
||||
const knownSlashNames = React.useMemo(() => {
|
||||
const names = new Set<string>([
|
||||
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
|
||||
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'btw', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
|
||||
]);
|
||||
if (!isMobile && !isVSCodeRuntime()) names.add('handoff-review');
|
||||
for (const command of availableCommands) names.add(command.name.toLowerCase());
|
||||
@@ -662,7 +703,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
attachments,
|
||||
};
|
||||
}, [resolveInlineFileMention]);
|
||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
|
||||
// Issue linking state
|
||||
@@ -724,55 +764,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[inlineDraftKey]
|
||||
)
|
||||
);
|
||||
const draftSourceKey = useInlineCommentDraftStore(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : [];
|
||||
let previewConsole = 0;
|
||||
let previewAnnotation = 0;
|
||||
let review = 0;
|
||||
let terminal = 0;
|
||||
let prComment = 0;
|
||||
let prCheck = 0;
|
||||
for (const draft of drafts) {
|
||||
if (draft.source === 'preview-console') previewConsole += 1;
|
||||
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
|
||||
else if (draft.source === 'terminal') terminal += 1;
|
||||
else if (draft.source === 'pr-comment') prComment += 1;
|
||||
else if (draft.source === 'pr-check') prCheck += 1;
|
||||
else review += 1;
|
||||
}
|
||||
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}`;
|
||||
},
|
||||
[inlineDraftKey]
|
||||
)
|
||||
);
|
||||
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const hasDrafts = draftCount > 0;
|
||||
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
|
||||
const terminalContextDrafts = terminalContextCount > 0
|
||||
? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal')
|
||||
: [];
|
||||
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => {
|
||||
if (!inlineDraftTarget) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
||||
for (const draft of drafts) {
|
||||
if (draft.source === source) {
|
||||
removeInlineCommentDraft(inlineDraftTarget, draft.id);
|
||||
}
|
||||
}
|
||||
}, [inlineDraftTarget, removeInlineCommentDraft]);
|
||||
// Review comments are the inline-comment drafts that aren't preview sources.
|
||||
const removeReviewDrafts = React.useCallback(() => {
|
||||
if (!inlineDraftTarget) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
||||
for (const draft of drafts) {
|
||||
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check') {
|
||||
removeInlineCommentDraft(inlineDraftTarget, draft.id);
|
||||
}
|
||||
}
|
||||
}, [inlineDraftTarget, removeInlineCommentDraft]);
|
||||
|
||||
// User message history for up/down arrow navigation.
|
||||
// Keep this on a narrow hook instead of full session message records.
|
||||
@@ -821,8 +814,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
prevNewSessionDraftOpenRef.current = newSessionDraftOpen;
|
||||
}, [newSessionDraftOpen, isMobile]);
|
||||
|
||||
// Session activity for queue availability and controls
|
||||
const { phase: sessionPhase } = useCurrentSessionActivity();
|
||||
// Session activity for queue availability and controls. In btw mode the
|
||||
// composer controls the temporary fork, so the stop button and send-button
|
||||
// state follow the FORK's activity; the queue affordance stays tied to the
|
||||
// main session (queued messages always belong to the main chat).
|
||||
const { phase: currentSessionPhase } = useCurrentSessionActivity();
|
||||
const { phase: btwSessionPhase } = useSessionActivity(btwSessionId, btwDirectory ?? undefined);
|
||||
const sessionPhase = isBtwActive ? btwSessionPhase : currentSessionPhase;
|
||||
const autoReviewRunning = useAutoReviewStore(React.useCallback((state) => {
|
||||
if (!currentSessionId) return false;
|
||||
const run = state.runsByOriginalSessionID[currentSessionId];
|
||||
@@ -896,12 +894,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const inputSnapshot = getCurrentInputSnapshot();
|
||||
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
|
||||
|
||||
const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : [];
|
||||
|
||||
let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||
if (drafts.length > 0) {
|
||||
messageToQueue = appendInlineComments(messageToQueue, drafts);
|
||||
}
|
||||
// Context drafts stay in their store: the send that later delivers the
|
||||
// queue consumes them and attaches them as structured context parts.
|
||||
const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||
const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles);
|
||||
|
||||
addToQueue(messageQueueTarget, {
|
||||
@@ -915,6 +910,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
// Sending while the agent works must still take the reader to the
|
||||
// live edge — a queued message produces no user row yet, so the
|
||||
// anchor path has nothing to claim and would leave the viewport
|
||||
// parked mid-history.
|
||||
scrollToLatest?.();
|
||||
|
||||
// Clear input and attachments
|
||||
// Note: confirmedMentionsRef is NOT cleared here because queued messages
|
||||
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
|
||||
@@ -927,7 +928,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
if (!isMobile) {
|
||||
composerRef.current?.focus();
|
||||
}
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest]);
|
||||
|
||||
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||
setMessage(content);
|
||||
@@ -970,6 +971,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const queuedMessageId = options?.queuedMessageId;
|
||||
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
||||
const capturedTarget = messageQueueTarget;
|
||||
// An expired session cannot deliver anything: keep the prompt in the
|
||||
// composer and point at the login banner instead of burning the send
|
||||
// on a guaranteed 401.
|
||||
if (useAuthSessionStore.getState().state !== 'ok') {
|
||||
toast.error(t('sessionAuth.expired.sendBlocked'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the draft and current-session identity before the first
|
||||
// async gap so a later sidebar selection cannot reroute the send.
|
||||
const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null;
|
||||
@@ -1008,6 +1017,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
if (!providerIdToSend || !modelIdToSend) {
|
||||
console.warn('Cannot send message: provider or model not selected');
|
||||
toast.error(t('chat.chatInput.toast.noModelSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1020,12 +1030,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
// queued-message auto-send hook delivers it as the next turn once the
|
||||
// rejected turn winds down and the session returns to idle. This avoids
|
||||
// aborting the turn (which would surface an "aborted" notice).
|
||||
if (currentSessionId && !queuedOnly && autoReviewRunning) {
|
||||
if (currentSessionId && !queuedOnly && autoReviewRunning && !isBtwActive) {
|
||||
handleQueueMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSessionId && !queuedOnly) {
|
||||
// btw mode: the child fork's blocking prompts are answered inside the
|
||||
// panel; the composer send goes straight to the fork (routeMessage
|
||||
// queues if the fork's own turn is busy).
|
||||
if (currentSessionId && !queuedOnly && !isBtwActive) {
|
||||
// Sending is authoritative for blocking prompts: deny pending
|
||||
// permissions and dismiss open questions for the session subtree,
|
||||
// then queue the message once if either was open. The deny/clear
|
||||
@@ -1045,17 +1058,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessageOptions: {
|
||||
let sendMessageOptions: {
|
||||
target?: NonNullable<typeof capturedTarget>;
|
||||
sessionId?: string;
|
||||
directory?: string;
|
||||
draftSnapshot?: NonNullable<typeof capturedDraftSnapshot>;
|
||||
delivery?: 'steer';
|
||||
} | undefined = (capturedTarget || capturedDraftSnapshot || delivery)
|
||||
? {
|
||||
...(capturedTarget ? { target: capturedTarget } : {}),
|
||||
...(capturedDraftSnapshot ? { draftSnapshot: capturedDraftSnapshot } : {}),
|
||||
...(delivery ? { delivery } : {}),
|
||||
}
|
||||
: undefined;
|
||||
} | undefined;
|
||||
if (isBtwActive && btwSessionId && btwDirectory) {
|
||||
sendMessageOptions = {
|
||||
sessionId: btwSessionId,
|
||||
directory: btwDirectory,
|
||||
};
|
||||
} else if (capturedTarget || capturedDraftSnapshot || delivery) {
|
||||
sendMessageOptions = {};
|
||||
if (capturedTarget) sendMessageOptions.target = capturedTarget;
|
||||
if (capturedDraftSnapshot) sendMessageOptions.draftSnapshot = capturedDraftSnapshot;
|
||||
}
|
||||
if (delivery && sendMessageOptions) sendMessageOptions.delivery = delivery;
|
||||
|
||||
const preparedDocumentMentions = new Map<string, AttachedFile[]>();
|
||||
const reservedFilenames = new Set([
|
||||
@@ -1096,9 +1116,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
|
||||
// Inline review comments and synthetic context are consumed before
|
||||
// assembly so a failed send can restore exactly what it took.
|
||||
// assembly so a failed send can restore exactly what it took. Context
|
||||
// drafts ride with whichever send goes out next, including queued
|
||||
// auto-sends: queueing leaves them in the store on purpose.
|
||||
const syntheticParts = consumePendingSyntheticParts();
|
||||
const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget;
|
||||
const consumedDraftTarget = inlineDraftTarget;
|
||||
const drafts: InlineCommentDraft[] = consumedDraftTarget
|
||||
? consumeDrafts(consumedDraftTarget)
|
||||
: [];
|
||||
@@ -1112,10 +1134,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
composerText: !queuedOnly && inputSnapshot.hasContent ? inputSnapshot.message : null,
|
||||
composerAttachments: attachedFiles,
|
||||
inlineComments: drafts,
|
||||
syntheticTexts: syntheticParts?.map((part) => part.text) ?? [],
|
||||
linkedIssueContext: linkedIssue?.contextText ?? null,
|
||||
// btw mode: the boundary rides with every send, not just the
|
||||
// first one, so the inherited transcript stays reference material
|
||||
// for the whole side conversation.
|
||||
syntheticTexts: [
|
||||
...(isBtwActive ? [BTW_BOUNDARY_INSTRUCTION] : []),
|
||||
...(isPromotedBtwSession ? [BTW_PROMOTION_NOTICE] : []),
|
||||
...(syntheticParts?.map((part) => part.text) ?? []),
|
||||
],
|
||||
linkedIssue: linkedIssue
|
||||
? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText }
|
||||
: null,
|
||||
linkedPr: linkedPr
|
||||
? { instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||
: null,
|
||||
}, {
|
||||
parseAgentMention: (text) => {
|
||||
@@ -1128,8 +1159,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
},
|
||||
sanitizeAttachments: sanitizeAttachmentsForSend,
|
||||
collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames),
|
||||
appendComments: (text, comments) =>
|
||||
appendInlineComments(text, comments as InlineCommentDraft[]),
|
||||
buildSkillInstruction: buildSkillMentionInstruction,
|
||||
});
|
||||
|
||||
@@ -1196,6 +1225,40 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (commandName === 'btw' && currentSessionId) {
|
||||
const question = argument.trim();
|
||||
if (!question) {
|
||||
toast.error(t('chat.btw.toast.emptyArgument'));
|
||||
return;
|
||||
}
|
||||
const targetDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId)
|
||||
|| currentDirectory
|
||||
|| null;
|
||||
if (!targetDirectory) {
|
||||
toast.error(t('chat.btw.toast.createFailed'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// A new btw replaces this session's current one: destroy
|
||||
// the previous fork first so forks never accumulate.
|
||||
if (btwSessionRef) {
|
||||
await destroyBtwSession(btwSessionRef);
|
||||
}
|
||||
await startBtwSession({
|
||||
parentSessionId: currentSessionId,
|
||||
question,
|
||||
directory: targetDirectory,
|
||||
providerID: providerIdToSend,
|
||||
modelID: modelIdToSend,
|
||||
agent: agentNameToSend,
|
||||
variant: variantToSend,
|
||||
});
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
toast.error(getSubmitErrorMessage(error, t('chat.btw.toast.createFailed')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// The rest render a visible prompt plus synthetic instructions and
|
||||
// send them as one message.
|
||||
@@ -1231,7 +1294,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
|
||||
const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory;
|
||||
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false);
|
||||
// btw mode: the fork already carries the question plus full history,
|
||||
// so the response-style instruction never applies there.
|
||||
const shouldAddResponseStyle = !isBtwActive && (newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false));
|
||||
if (shouldAddResponseStyle) {
|
||||
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
|
||||
if (responseStyleInstruction) {
|
||||
@@ -1258,6 +1323,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
...additionalParts.flatMap(p => p.attachments ?? []),
|
||||
];
|
||||
|
||||
// Arm the timeline anchor BEFORE the optimistic user row can commit;
|
||||
// arming after (or a frame later) races the commit and the anchor
|
||||
// never claims the new message.
|
||||
scrollToBottom?.();
|
||||
|
||||
const sendPromise = sendMessage(
|
||||
primaryText,
|
||||
providerIdToSend,
|
||||
@@ -1276,14 +1346,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
scrollToBottom?.();
|
||||
} else {
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToBottom?.();
|
||||
});
|
||||
}
|
||||
|
||||
void sendPromise.then(() => {
|
||||
// Record what this session was pointed at, so the work-status panel
|
||||
// can show it as a context source long after the message scrolled
|
||||
@@ -1343,10 +1405,25 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
console.error('Message send failed:', rawMessage || error);
|
||||
restoreConsumedDrafts();
|
||||
|
||||
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
|
||||
if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) {
|
||||
setMessage(inputSnapshot.message);
|
||||
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
|
||||
// A failed send returns the typed prompt no matter WHY it failed —
|
||||
// auth, network, server, anything. Losing a long prompt to a toast
|
||||
// is the one outcome this handler must never produce.
|
||||
if (inputSnapshot.message) {
|
||||
if (currentChatDraftIdentityRef.current !== chatDraftIdentity) {
|
||||
// The user switched sessions mid-send: restore into that
|
||||
// session's persisted draft, not the visible composer.
|
||||
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
|
||||
} else {
|
||||
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
|
||||
if (!currentInput || currentInput === inputSnapshot.message) {
|
||||
setMessage(inputSnapshot.message);
|
||||
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
|
||||
} else {
|
||||
// New typing already lives in the composer; the failed
|
||||
// prompt joins it instead of clobbering either text.
|
||||
useInputStore.getState().setPendingInputText(inputSnapshot.message, 'append');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isSoftNetworkError =
|
||||
@@ -1401,7 +1478,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
// Primary action for send/queue button — respects selected follow-up behavior
|
||||
const handlePrimaryAction = React.useCallback(() => {
|
||||
const inputSnapshot = getCurrentInputSnapshot();
|
||||
const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
|
||||
const canQueue = !isBtwActive && inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
|
||||
if (followUpBehavior === 'queue' && canQueue) {
|
||||
handleQueueMessage();
|
||||
} else if (followUpBehavior === 'steer' && canQueue) {
|
||||
@@ -1409,7 +1486,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
} else {
|
||||
void handleSubmitRef.current();
|
||||
}
|
||||
}, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage]);
|
||||
}, [inputMode, getCurrentInputSnapshot, currentSessionId, currentSessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage, isBtwActive]);
|
||||
|
||||
// Draft welcome presets: submit immediately.
|
||||
const submitPresetPrompt = React.useCallback((text: string, type: 'command' | 'skill') => {
|
||||
@@ -1558,39 +1635,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const selEnd = ta?.getSelection().end ?? -1;
|
||||
|
||||
if (ta && selStart >= 0) {
|
||||
const applyEdit = (next: string, caretStart: number, caretEnd: number) => {
|
||||
const edit = getMarkdownAutoPairEdit(message, e.key, selStart, selEnd);
|
||||
if (edit) {
|
||||
e.preventDefault();
|
||||
setMessage(next);
|
||||
composerRef.current?.setSelection(caretStart, caretEnd);
|
||||
updateAutocompleteState(next, caretEnd);
|
||||
};
|
||||
|
||||
// Wrap the current selection: select text, press ` * _ ~ ( [ { " '
|
||||
const WRAP_PAIRS: Record<string, [string, string]> = {
|
||||
'`': ['`', '`'], '*': ['*', '*'], '_': ['_', '_'], '~': ['~', '~'],
|
||||
'(': ['(', ')'], '[': ['[', ']'], '{': ['{', '}'],
|
||||
'"': ['"', '"'], "'": ["'", "'"],
|
||||
};
|
||||
if (selEnd > selStart && WRAP_PAIRS[e.key]) {
|
||||
const [open, close] = WRAP_PAIRS[e.key];
|
||||
const selected = message.slice(selStart, selEnd);
|
||||
const next = `${message.slice(0, selStart)}${open}${selected}${close}${message.slice(selEnd)}`;
|
||||
applyEdit(next, selStart + open.length, selEnd + open.length);
|
||||
ta.replaceRange(
|
||||
edit.from,
|
||||
edit.to,
|
||||
edit.insert,
|
||||
edit.selectionStart,
|
||||
edit.selectionEnd,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Typing the third backtick at line start expands into a fenced
|
||||
// code block with the caret on the empty middle line (Slack-like).
|
||||
if (e.key === '`' && selStart === selEnd) {
|
||||
const before = message.slice(0, selStart);
|
||||
if (/(^|\n)``$/.test(before)) {
|
||||
const after = message.slice(selEnd);
|
||||
const next = `${before}\`\n\n\`\`\`${after}`;
|
||||
const caret = before.length + 2; // after the completed ``` and first newline
|
||||
applyEdit(next, caret, caret);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1613,15 +1669,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Enter/Ctrl+Enter based on selected follow-up behavior.
|
||||
if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) {
|
||||
// Handle Enter/Ctrl+Enter based on selected follow-up behavior. On
|
||||
// mobile, and in desktop focus mode, plain Enter writes a newline and
|
||||
// only Cmd/Ctrl+Enter sends: both are surfaces for composing long
|
||||
// prompts, where an accidental send costs more than an extra keypress.
|
||||
const requiresModifierToSend = isMobile || isDesktopExpanded;
|
||||
if (e.key === 'Enter' && !e.shiftKey && (!requiresModifierToSend || e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
const isCtrlEnter = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Queueing / steering only works when there's an existing busy
|
||||
// session (or an active auto-review run).
|
||||
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
|
||||
const canQueue = !isBtwActive && inputMode === 'normal' && hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
|
||||
|
||||
if (followUpBehavior === 'queue') {
|
||||
if (isCtrlEnter || !canQueue) {
|
||||
@@ -1653,26 +1713,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
containerRef: dropZoneRef,
|
||||
});
|
||||
|
||||
const startAbortIndicator = React.useCallback(() => {
|
||||
if (abortTimeoutRef.current) {
|
||||
clearTimeout(abortTimeoutRef.current);
|
||||
abortTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setShowAbortStatus(true);
|
||||
|
||||
abortTimeoutRef.current = setTimeout(() => {
|
||||
setShowAbortStatus(false);
|
||||
abortTimeoutRef.current = null;
|
||||
}, 1800);
|
||||
}, []);
|
||||
|
||||
const handleAbort = React.useCallback(() => {
|
||||
clearAbortPrompt();
|
||||
startAbortIndicator();
|
||||
|
||||
void abortCurrentOperation(currentSessionId || undefined);
|
||||
}, [abortCurrentOperation, clearAbortPrompt, currentSessionId, startAbortIndicator]);
|
||||
// btw mode: the stop button stops the fork's turn, not the main
|
||||
// session's.
|
||||
const abortTarget = isBtwActive && btwSessionId ? btwSessionId : currentSessionId;
|
||||
void abortCurrentOperation(abortTarget || undefined);
|
||||
}, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive]);
|
||||
|
||||
const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => {
|
||||
const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction);
|
||||
@@ -2369,6 +2418,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
const isMiniChatSurface = chatSurfaceMode === 'mini-chat';
|
||||
const showDesktopDraftPresentation = (newSessionDraftOpen || draftPresentationExiting)
|
||||
&& !isDesktopExpanded
|
||||
&& !isMobile
|
||||
&& !isVSCode
|
||||
&& !isMiniChatSurface;
|
||||
const draftPresentationClassName = cn(
|
||||
'transition-opacity duration-[120ms] ease-out motion-reduce:transition-none',
|
||||
draftPresentationExiting && 'pointer-events-none opacity-0',
|
||||
);
|
||||
|
||||
const hasPendingChanges = React.useMemo(() => {
|
||||
if (isMiniChatSurface) {
|
||||
@@ -2382,7 +2440,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) {
|
||||
if (!showDraftTargetSelectors || !selectedDraftProject || selectedDraftProject.kind === 'chat' || !selectedDraftDirectory) {
|
||||
return;
|
||||
}
|
||||
if (newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory || newSessionDraft?.preserveDirectoryOverride) {
|
||||
@@ -2507,31 +2565,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
t,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
|
||||
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
|
||||
startAbortIndicator();
|
||||
if (currentSessionId) {
|
||||
acknowledgeSessionAbort(currentSessionId);
|
||||
}
|
||||
}
|
||||
prevWasAbortedRef.current = pendingAbortBanner;
|
||||
}, [
|
||||
abortPromptSessionId,
|
||||
acknowledgeSessionAbort,
|
||||
currentSessionId,
|
||||
showAbortStatus,
|
||||
startAbortIndicator,
|
||||
]);
|
||||
useKeybind('toggle_permission_auto_accept', () => {
|
||||
if (!isPermissionAutoAcceptInteractive) return false;
|
||||
handlePermissionAutoAcceptToggle();
|
||||
});
|
||||
|
||||
// Acknowledging the abort record is what lets the working chip resume for
|
||||
// the next run; the old "Aborted" banner that used to accompany it is gone.
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (abortTimeoutRef.current) {
|
||||
clearTimeout(abortTimeoutRef.current);
|
||||
abortTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const pendingAbort = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
|
||||
if (!prevWasAbortedRef.current && pendingAbort && currentSessionId) {
|
||||
acknowledgeSessionAbort(currentSessionId);
|
||||
}
|
||||
prevWasAbortedRef.current = pendingAbort;
|
||||
}, [abortPromptSessionId, acknowledgeSessionAbort, currentSessionId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -2546,8 +2593,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
)}
|
||||
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
|
||||
>
|
||||
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
|
||||
<div className="chat-input-column mb-7 text-center">
|
||||
{showDesktopDraftPresentation ? (
|
||||
<div className={cn('chat-input-column mb-7 text-center', draftPresentationClassName)}>
|
||||
<h1 className="text-balance text-2xl font-normal tracking-tight text-foreground md:text-3xl">
|
||||
{renderDraftTitle(
|
||||
draftProjectLabel
|
||||
@@ -2567,16 +2614,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
<AutoReviewBanner />
|
||||
{hasDrafts ? (
|
||||
<ComposerContextChips
|
||||
terminalDrafts={terminalContextDrafts}
|
||||
reviewCount={reviewCount}
|
||||
prCommentCount={prCommentCount}
|
||||
prCheckCount={prCheckCount}
|
||||
previewConsoleCount={previewConsoleCount}
|
||||
previewAnnotationCount={previewAnnotationCount}
|
||||
draftTarget={inlineDraftTarget}
|
||||
onRemoveDraft={removeInlineCommentDraft}
|
||||
onRemoveReviewDrafts={removeReviewDrafts}
|
||||
onRemovePreviewDrafts={removePreviewDrafts}
|
||||
colors={currentTheme.colors}
|
||||
/>
|
||||
) : null}
|
||||
@@ -2610,29 +2648,29 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
sessionId={currentSessionId}
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
/>
|
||||
<MemoStatusRow
|
||||
showAbortStatus={showAbortStatus}
|
||||
showAssistantStatus={false}
|
||||
<MemoComposerStatusBar
|
||||
showTodos={composerStatusExtrasEnabled}
|
||||
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
|
||||
? null
|
||||
: <PendingChangesBar />}
|
||||
/>
|
||||
{!isMobile && showDraftTargetSelectors && selectedDraftProject ? (
|
||||
<DraftTargetSelectors
|
||||
projects={draftProjects}
|
||||
selectedProject={selectedDraftProject}
|
||||
selectedDirectory={selectedDraftDirectory}
|
||||
selectedBranchLabel={selectedDraftBranchLabel}
|
||||
selectedBranchIsKnown={selectedDraftBranchIsKnown}
|
||||
projectRootBranchOption={projectRootBranchOption}
|
||||
worktreeBranchOptions={worktreeBranchOptions}
|
||||
branchItems={draftBranchItems}
|
||||
showBranchSelector={shouldShowDraftBranchSelector}
|
||||
onProjectChange={handleDraftProjectChange}
|
||||
onDirectoryChange={handleDraftDirectoryChange}
|
||||
theme={currentTheme}
|
||||
/>
|
||||
{!isMobile && (showDraftTargetSelectors || draftPresentationExiting) && selectedDraftProject ? (
|
||||
<div className={draftPresentationClassName}>
|
||||
<DraftTargetSelectors
|
||||
projects={draftProjects}
|
||||
selectedProject={selectedDraftProject}
|
||||
selectedDirectory={selectedDraftDirectory}
|
||||
selectedBranchLabel={selectedDraftBranchLabel}
|
||||
selectedBranchIsKnown={selectedDraftBranchIsKnown}
|
||||
projectRootBranchOption={projectRootBranchOption}
|
||||
worktreeBranchOptions={worktreeBranchOptions}
|
||||
branchItems={draftBranchItems}
|
||||
showBranchSelector={shouldShowDraftBranchSelector}
|
||||
onProjectChange={handleDraftProjectChange}
|
||||
onDirectoryChange={handleDraftDirectoryChange}
|
||||
theme={currentTheme}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{isMobile && showDraftTargetSelectors && selectedDraftProject ? (
|
||||
<MobileDraftTargetTriggers
|
||||
@@ -2799,13 +2837,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}}
|
||||
onFocus={mobileShell.onEditorFocus}
|
||||
onBlur={mobileShell.onEditorBlur}
|
||||
placeholder={currentSessionId || newSessionDraftOpen
|
||||
? inputMode === 'shell'
|
||||
? t('chat.chatInput.placeholder.shell')
|
||||
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
|
||||
: t('chat.chatInput.placeholder.selectSession')}
|
||||
placeholder={isBtwActive
|
||||
? t('chat.btw.mainComposerPlaceholder')
|
||||
: currentSessionId || newSessionDraftOpen
|
||||
? inputMode === 'shell'
|
||||
? t('chat.chatInput.placeholder.shell')
|
||||
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
|
||||
: t('chat.chatInput.placeholder.selectSession')}
|
||||
editable={Boolean(currentSessionId || newSessionDraftOpen)}
|
||||
autoCorrect={isMobile}
|
||||
autoCorrect={composerAutoCorrect({ isMobile })}
|
||||
autoCapitalize={isMobile ? 'sentences' : 'none'}
|
||||
spellCheck={isMobile || inputSpellcheckEnabled}
|
||||
fillContainer={isComposerExpanded}
|
||||
@@ -2896,12 +2936,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
|
||||
{showDesktopDraftPresentation ? (
|
||||
<DraftPresetChips
|
||||
onSubmit={(starter) => submitPresetPrompt(starter.submitText, starter.ref.type)}
|
||||
className="chat-input-column mt-4"
|
||||
className={cn('chat-input-column mt-4', draftPresentationClassName)}
|
||||
/>
|
||||
) : null}
|
||||
{currentSessionId ? <BtwPanel parentSessionId={currentSessionId} panel={btwPanel} /> : null}
|
||||
</form>
|
||||
|
||||
{/* Issue Picker Dialog */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -3,17 +3,29 @@ import { cn } from '@/lib/utils';
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
|
||||
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||
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>
|
||||
|
||||
@@ -47,8 +47,12 @@ 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>
|
||||
);
|
||||
|
||||
@@ -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 { 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', () => {
|
||||
@@ -110,3 +474,120 @@ describe('localPathFromFileUrl', () => {
|
||||
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,7 +20,12 @@ 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, type MarkdownImageMode } from './markdown/markdownCore';
|
||||
import {
|
||||
getCachedMarkdownBlocks,
|
||||
renderMarkdownBlocks,
|
||||
renderMarkdownSync,
|
||||
type MarkdownImageMode,
|
||||
} from './markdown/markdownCore';
|
||||
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
|
||||
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
||||
import {
|
||||
@@ -42,7 +48,10 @@ import {
|
||||
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();
|
||||
@@ -55,7 +64,7 @@ const useCurrentMermaidTheme = () => {
|
||||
: fallbackLight);
|
||||
};
|
||||
|
||||
const useExternalLinkInteractions = ({
|
||||
const useLinkInteractions = ({
|
||||
containerRef,
|
||||
enabled,
|
||||
}: {
|
||||
@@ -63,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]);
|
||||
};
|
||||
|
||||
@@ -151,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
|
||||
@@ -361,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);
|
||||
};
|
||||
@@ -440,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
|
||||
@@ -493,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);
|
||||
}
|
||||
@@ -521,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)) {
|
||||
@@ -621,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, {
|
||||
@@ -754,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);
|
||||
@@ -838,6 +788,7 @@ const useMorphdomMarkdown = ({
|
||||
imageMode = 'inline',
|
||||
syntaxVars,
|
||||
ctx,
|
||||
domCacheKey,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
text: string;
|
||||
@@ -845,12 +796,20 @@ const useMorphdomMarkdown = ({
|
||||
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) {
|
||||
@@ -866,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
|
||||
@@ -875,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, imageMode);
|
||||
// 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, imageMode, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
mermaidViewerRef.current?.cleanup();
|
||||
@@ -905,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, imageMode).then((blocks) => {
|
||||
if (!active) return;
|
||||
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, {
|
||||
@@ -933,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) {
|
||||
@@ -951,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, imageMode, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -1034,10 +1110,37 @@ 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 { 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}`}`;
|
||||
|
||||
@@ -1045,9 +1148,10 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
containerRef,
|
||||
text: content,
|
||||
streaming: live,
|
||||
imageMode: variant === 'assistant' ? 'label' : 'inline',
|
||||
imageMode,
|
||||
syntaxVars,
|
||||
ctx,
|
||||
domCacheKey,
|
||||
});
|
||||
|
||||
const markdownContent = (
|
||||
@@ -1085,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;
|
||||
@@ -1126,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);
|
||||
|
||||
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)
|
||||
@@ -704,6 +696,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 +738,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return currentVariant;
|
||||
}
|
||||
|
||||
if (!currentSessionId && settingsDefaultVariant && variantOptions.includes(settingsDefaultVariant)) {
|
||||
return settingsDefaultVariant;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
currentAgentName,
|
||||
@@ -735,7 +747,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
currentVariant,
|
||||
getAgentModelVariantForSession,
|
||||
getModelVariantOptions,
|
||||
settingsDefaultVariant,
|
||||
uiAgentName,
|
||||
]);
|
||||
|
||||
@@ -759,7 +770,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 +784,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 +909,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 +945,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') {
|
||||
@@ -1129,18 +1148,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 +1174,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 +1191,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
currentVariant,
|
||||
currentVariantSelection.override,
|
||||
effectiveCurrentVariant,
|
||||
getAgentModelVariantForSession,
|
||||
resolveInheritedVariantForModel,
|
||||
setCurrentVariant,
|
||||
setCurrentVariantOverride,
|
||||
settingsDefaultVariant,
|
||||
]);
|
||||
|
||||
@@ -2256,7 +2283,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 +2348,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 +2410,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 +2654,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 +2744,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 +2760,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 +2782,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 +2794,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,25 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { SimpleMarkdownRenderer } from './MarkdownRenderer';
|
||||
import { QuestionMarkdown } from './QuestionMarkdown';
|
||||
|
||||
describe('QuestionMarkdown', () => {
|
||||
test('delegates exact content to the tool markdown renderer', () => {
|
||||
const content = 'Choose **one** from `mode`: [details](https://example.com)';
|
||||
const element = QuestionMarkdown({ content, size: 'meta' });
|
||||
|
||||
expect(element.type).toBe(SimpleMarkdownRenderer);
|
||||
expect(element.props.content).toBe(content);
|
||||
expect(element.props.variant).toBe('tool');
|
||||
expect(element.props.fallbackContent.props.children).toBe(content);
|
||||
expect(element.props.fallbackContent.props.className).toContain('whitespace-pre-wrap');
|
||||
});
|
||||
|
||||
test('preserves question typography size and caller classes', () => {
|
||||
const meta = QuestionMarkdown({ content: 'Meta', size: 'meta', className: 'font-medium text-foreground' });
|
||||
const micro = QuestionMarkdown({ content: 'Micro', size: 'micro', className: 'text-muted-foreground' });
|
||||
|
||||
expect(meta.props.className).toBe('question-markdown typography-meta font-medium text-foreground');
|
||||
expect(micro.props.className).toBe('question-markdown typography-micro text-muted-foreground');
|
||||
});
|
||||
});
|
||||
@@ -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 { 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>
|
||||
);
|
||||
|
||||
+13
-10
@@ -226,20 +226,23 @@ describe('issue #2903 busy embedded subagent status-line-only', () => {
|
||||
expect(chatContainerSource).toContain('void ensureSessionRenderable(currentSessionId);');
|
||||
});
|
||||
|
||||
test('empty+busy branch skips empty state so StatusRowContainer can stand alone', () => {
|
||||
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('<ChatEmptyState');
|
||||
expect(chatContainerSource).toContain('<StatusRowContainer />');
|
||||
|
||||
const emptyBusyGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
|
||||
const emptyStateReturn = chatContainerSource.indexOf(emptyBusyGuard);
|
||||
expect(emptyStateReturn).toBeGreaterThan(-1);
|
||||
const emptyStateBlock = chatContainerSource.slice(
|
||||
emptyStateReturn,
|
||||
emptyStateReturn + 1600,
|
||||
const emptyIdleGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
|
||||
const emptyIdleReturn = chatContainerSource.indexOf(emptyIdleGuard);
|
||||
expect(emptyIdleReturn).toBeGreaterThan(-1);
|
||||
const emptyIdleBlock = chatContainerSource.slice(
|
||||
emptyIdleReturn,
|
||||
emptyIdleReturn + 1600,
|
||||
);
|
||||
expect(emptyStateBlock).toContain('<ChatEmptyState');
|
||||
expect(emptyStateBlock).not.toContain('<StatusRowContainer />');
|
||||
expect(emptyIdleBlock).not.toContain('<StatusRowContainer />');
|
||||
});
|
||||
|
||||
test('visibility handshake remains as defense-in-depth for background work', () => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
|
||||
@@ -102,6 +112,14 @@ 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
|
||||
is cheaper and far simpler than incremental mapping, and it keeps the editor
|
||||
@@ -114,10 +132,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
|
||||
@@ -127,6 +149,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,6 +34,7 @@ import {
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ComposerLanguageContext } from '../language/tokenize';
|
||||
import type { ComposerAutoCorrect } from './autocorrect';
|
||||
import { composerLanguage, setLanguageContext } from './composerLanguage';
|
||||
import type { ComposerEditorViewStore } from './viewStore';
|
||||
import { composerEditorTheme, composerSelectionExtension } from './theme';
|
||||
@@ -63,8 +64,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 +90,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 +161,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
placeholder,
|
||||
editable = true,
|
||||
spellCheck = false,
|
||||
autoCorrect = false,
|
||||
autoCorrect = 'off',
|
||||
autoCapitalize = 'none',
|
||||
fillContainer = false,
|
||||
maxLines = 8,
|
||||
@@ -287,7 +291,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'] }
|
||||
@@ -454,7 +458,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]);
|
||||
|
||||
@@ -516,12 +520,13 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
},
|
||||
replaceRange(from, to, text, caret) {
|
||||
replaceRange(from, to, text, selectionStart, selectionEnd = selectionStart) {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const anchor = selectionStart ?? from + text.length;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: caret ?? from + text.length },
|
||||
selection: { anchor, head: selectionEnd ?? anchor },
|
||||
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,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';
|
||||
}
|
||||
@@ -20,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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
@@ -57,7 +60,9 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
|
||||
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
|
||||
|
||||
@@ -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)] px-[0.3125rem] py-0.5',
|
||||
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
|
||||
// A `~path` is written for the reader's benefit, not to attach anything —
|
||||
// it takes the same colour as a file mention, since it names the same kind
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { mentionServerQuery, rankFileMentionResults, tokenizeMentionQuery } from './fileMentionResults';
|
||||
|
||||
const hit = (relativePath: string) => {
|
||||
const name = relativePath.split('/').filter(Boolean).pop() ?? relativePath;
|
||||
return {
|
||||
name,
|
||||
path: `/root/${relativePath}`,
|
||||
relativePath,
|
||||
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
describe('tokenizeMentionQuery', () => {
|
||||
test('normalizes leading ./ and slashes and splits on whitespace', () => {
|
||||
expect(tokenizeMentionQuery('./Solo Team')).toEqual(['solo', 'team']);
|
||||
expect(tokenizeMentionQuery(' ')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mentionServerQuery', () => {
|
||||
test('uses the longest token for the server search', () => {
|
||||
expect(mentionServerQuery('team solo-is-a')).toBe('solo-is-a');
|
||||
expect(mentionServerQuery('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankFileMentionResults', () => {
|
||||
test('ranks files and directories together by match quality, not by category', () => {
|
||||
const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')];
|
||||
const directories = [hit('machine-learning/tensorflow/'), hit('solo-is-a-team-size/')];
|
||||
|
||||
const ranked = rankFileMentionResults(files, directories, 'solo');
|
||||
const paths = ranked.map((entry) => entry.relativePath);
|
||||
|
||||
expect(paths.slice(0, 2)).toEqual(['solo-is-a-team-size/', 'solo-is-a-team-size/index.md']);
|
||||
expect(paths).not.toContain('machine-learning/tensorflow/');
|
||||
});
|
||||
|
||||
test('multi-token queries match tokens in any order across the path', () => {
|
||||
const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')];
|
||||
|
||||
const ranked = rankFileMentionResults(files, [], 'team solo');
|
||||
expect(ranked.map((entry) => entry.relativePath)).toEqual(['solo-is-a-team-size/index.md']);
|
||||
});
|
||||
|
||||
test('tags each result with its kind', () => {
|
||||
const ranked = rankFileMentionResults([hit('a/readme.md')], [hit('a/')], 'a');
|
||||
expect(ranked.find((entry) => entry.relativePath === 'a/')?.kind).toBe('directory');
|
||||
expect(ranked.find((entry) => entry.relativePath === 'a/readme.md')?.kind).toBe('file');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
|
||||
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
|
||||
|
||||
export type FileMentionHit = ProjectFileSearchHit & { kind: 'file' | 'directory' };
|
||||
|
||||
export const tokenizeMentionQuery = (query: string): string[] =>
|
||||
(query ?? '')
|
||||
.trim()
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/^\/+/, '')
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
/**
|
||||
* The opencode file search takes a single term, so multi-word queries send the
|
||||
* most selective (longest) token and the remaining tokens filter client-side.
|
||||
*/
|
||||
export const mentionServerQuery = (query: string): string => {
|
||||
const tokens = tokenizeMentionQuery(query);
|
||||
if (tokens.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return tokens.reduce((longest, token) => (token.length > longest.length ? token : longest));
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge directory and file hits into one list ranked by match quality against
|
||||
* the full relative path. Multi-token queries require every token to appear
|
||||
* somewhere in the path, in any order.
|
||||
*/
|
||||
export function rankFileMentionResults(
|
||||
files: ProjectFileSearchHit[],
|
||||
directories: ProjectFileSearchHit[],
|
||||
query: string,
|
||||
limit = 20,
|
||||
): FileMentionHit[] {
|
||||
const merged: FileMentionHit[] = [
|
||||
...directories.map((hit) => ({ ...hit, kind: 'directory' as const })),
|
||||
...files.map((hit) => ({ ...hit, kind: 'file' as const })),
|
||||
];
|
||||
|
||||
const tokens = tokenizeMentionQuery(query);
|
||||
if (tokens.length === 0) {
|
||||
return merged.slice(0, limit);
|
||||
}
|
||||
|
||||
const pathOf = (hit: FileMentionHit) => hit.relativePath || hit.name;
|
||||
const candidates = tokens.length === 1
|
||||
? merged
|
||||
: merged.filter((hit) => {
|
||||
const haystack = pathOf(hit).toLowerCase();
|
||||
return tokens.every((token) => haystack.includes(token));
|
||||
});
|
||||
|
||||
const primary = tokens.reduce((longest, token) => (token.length > longest.length ? token : longest));
|
||||
return scoreByFuzzyQuery(candidates, primary, pathOf, { limit, threshold: 0.4 }).map(
|
||||
(scored) => scored.item,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { fileReferenceExists } from './fileReferenceStat';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const calls: Array<{ url: string; headers: Headers }> = [];
|
||||
|
||||
const stubFetchWith = (respond: () => Response) => {
|
||||
calls.length = 0;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input instanceof Request ? input.url : input.toString();
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({ url, headers });
|
||||
return respond();
|
||||
// SAFETY: the stub preserves the fetch signature; every caller in this
|
||||
// file restores globalThis.fetch in afterEach.
|
||||
}) as typeof fetch;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('fileReferenceExists directory scoping (issue 3019)', () => {
|
||||
test('sends the session directory on the stat probe', async () => {
|
||||
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-b/src/index.ts', isFile: true, size: 12 }), { status: 200 }));
|
||||
|
||||
const exists = await fileReferenceExists('/repo-b/src/index.ts', '/repo-b');
|
||||
|
||||
expect(exists).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toBe('/api/fs/stat?path=%2Frepo-b%2Fsrc%2Findex.ts&optional=true');
|
||||
expect(calls[0].headers.get('x-opencode-directory')).toBe('/repo-b');
|
||||
});
|
||||
|
||||
test('treats a workspace rejection under one directory as unknown under another directory', async () => {
|
||||
// Directory A resolves the workspace on the server (the browsed
|
||||
// lastDirectory), so the probe for a path under B is rejected with 400
|
||||
// and resolves false. The same path probed under B itself must issue a
|
||||
// fresh request rather than reuse A's cached rejection.
|
||||
stubFetchWith(() => {
|
||||
const directoryHint = calls[calls.length - 1]?.headers.get('x-opencode-directory') ?? null;
|
||||
if (directoryHint !== '/repo-b') {
|
||||
return new Response(JSON.stringify({ error: 'Path is outside of active workspace' }), { status: 400 });
|
||||
}
|
||||
return new Response(JSON.stringify({ path: '/repo-b/lib/main.ts', isFile: true, size: 12 }), { status: 200 });
|
||||
});
|
||||
|
||||
const rejectedUnderA = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-a');
|
||||
const acceptedUnderB = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-b');
|
||||
|
||||
expect(rejectedUnderA).toBe(false);
|
||||
expect(acceptedUnderB).toBe(true);
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('serves a repeated probe under the same directory from the cache', async () => {
|
||||
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-c/lib.ts', isFile: true, size: 4 }), { status: 200 }));
|
||||
|
||||
await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
|
||||
const warm = await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
|
||||
|
||||
expect(warm).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
import { normalizeReferencePath } from './fileReferenceParser';
|
||||
|
||||
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
|
||||
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
|
||||
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
// NUL cannot occur in a real path, so a directory-qualified key cannot collide
|
||||
// with a differently scoped entry.
|
||||
const statCacheKey = (directory: string, normalizedPath: string): string => `${directory}\u0000${normalizedPath}`;
|
||||
|
||||
export const fileReferenceExists = (resolvedPath: string, effectiveDirectory: string): Promise<boolean> => {
|
||||
const normalizedPath = normalizeReferencePath(resolvedPath);
|
||||
if (!normalizedPath) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const cacheKey = statCacheKey(effectiveDirectory, normalizedPath);
|
||||
const cached = FILE_REFERENCE_STAT_CACHE.get(cacheKey);
|
||||
if (cached) {
|
||||
FILE_REFERENCE_STAT_CACHE.delete(cacheKey);
|
||||
FILE_REFERENCE_STAT_CACHE.set(cacheKey, 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',
|
||||
// The stat route resolves the workspace from this header. Without it
|
||||
// the server falls back to the browsed lastDirectory, which rejects
|
||||
// session-local files with 400 whenever the two directories differ.
|
||||
headers: effectiveDirectory ? { 'x-opencode-directory': effectiveDirectory } : undefined,
|
||||
})
|
||||
.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(cacheKey, request);
|
||||
return request;
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
CHAT_LIST_ANCHOR_OFFSET,
|
||||
getAnchoredTurnMetrics,
|
||||
getRowBottom,
|
||||
resolveChatListAnchoredEndSpace,
|
||||
resolveTimelineIsAtEnd,
|
||||
type TimelineListMeasurementState,
|
||||
} from './timelineScrollAnchoring';
|
||||
|
||||
const buildState = ({
|
||||
positions,
|
||||
sizes,
|
||||
scroll = 0,
|
||||
scrollLength = 700,
|
||||
}: {
|
||||
readonly positions: readonly number[];
|
||||
readonly sizes: readonly number[];
|
||||
readonly scroll?: number;
|
||||
readonly scrollLength?: number;
|
||||
}): TimelineListMeasurementState => ({
|
||||
data: positions.map((_, index) => index),
|
||||
scroll,
|
||||
scrollLength,
|
||||
positionAtIndex: (index) => positions[index],
|
||||
sizeAtIndex: (index) => sizes[index],
|
||||
});
|
||||
|
||||
describe('getRowBottom', () => {
|
||||
test('measures row bottoms from list row position and size', () => {
|
||||
const state = buildState({ positions: [0, 120], sizes: [80, 40] });
|
||||
|
||||
expect(getRowBottom(state, 1)).toBe(160);
|
||||
});
|
||||
|
||||
test('returns null for unmeasured rows', () => {
|
||||
const state = buildState({ positions: [0], sizes: [80] });
|
||||
|
||||
expect(getRowBottom(state, 5)).toBeNull();
|
||||
});
|
||||
|
||||
test('treats a zero-height row as one pixel tall', () => {
|
||||
const state = buildState({ positions: [0, 120], sizes: [120, 0] });
|
||||
|
||||
expect(getRowBottom(state, 1)).toBe(121);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnchoredTurnMetrics', () => {
|
||||
test('returns null for an empty timeline', () => {
|
||||
const state = buildState({ positions: [], sizes: [] });
|
||||
|
||||
expect(getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 0,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
test('treats the active turn as fitting when it fits above the composer', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 300, 460],
|
||||
sizes: [240, 80, 140],
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.turnHeight).toBe(300);
|
||||
expect(metrics?.usableViewportHeight).toBe(564);
|
||||
expect(metrics?.overflowsUsableViewport).toBe(false);
|
||||
expect(metrics?.targetScrollToRevealEnd).toBe(36);
|
||||
expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
|
||||
});
|
||||
|
||||
test('targets the real row end instead of any temporary reserved tail', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 1720, 1880],
|
||||
sizes: [1600, 80, 120],
|
||||
scroll: 1900,
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.lastBottom).toBe(2000);
|
||||
expect(metrics?.targetScrollToRevealEnd).toBe(1436);
|
||||
expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
|
||||
});
|
||||
|
||||
test('reports overflow only for the current anchored turn', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 900, 1180],
|
||||
sizes: [800, 220, 300],
|
||||
scroll: 900,
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.turnHeight).toBe(580);
|
||||
expect(metrics?.usableViewportHeight).toBe(564);
|
||||
expect(metrics?.overflowsUsableViewport).toBe(true);
|
||||
});
|
||||
|
||||
test('returns the minimal positive scroll delta needed to reveal the turn end', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 900, 1180],
|
||||
sizes: [800, 220, 360],
|
||||
scroll: 900,
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.lastBottom).toBe(1540);
|
||||
expect(metrics?.visibleUsableBottom).toBe(1464);
|
||||
expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
|
||||
});
|
||||
|
||||
test('subtracts composer height from usable viewport height', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 300],
|
||||
sizes: [120, 470],
|
||||
scrollLength: 700,
|
||||
});
|
||||
|
||||
const withoutComposer = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 0,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
const withComposer = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 220,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(withoutComposer?.overflowsUsableViewport).toBe(false);
|
||||
expect(withComposer?.overflowsUsableViewport).toBe(true);
|
||||
});
|
||||
|
||||
test('clamps an out-of-range anchor index to the last row', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 300],
|
||||
sizes: [240, 80],
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 99,
|
||||
composerOverlayHeight: 0,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.anchorTop).toBe(300);
|
||||
expect(metrics?.turnHeight).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimelineIsAtEnd', () => {
|
||||
test('uses a tight distance band against the full content length', () => {
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
|
||||
});
|
||||
|
||||
test('falls back to the list flags when distances are unavailable', () => {
|
||||
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
|
||||
});
|
||||
|
||||
test('reports nothing without a state', () => {
|
||||
expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveChatListAnchoredEndSpace', () => {
|
||||
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'a' }];
|
||||
|
||||
test('returns nothing when no anchor is set', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, null, (row) => row.id)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('returns nothing when the anchor is not in the list', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, 'z', (row) => row.id)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('resolves the last occurrence so a resent message anchors to its live row', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, 'a', (row) => row.id)).toEqual({
|
||||
anchorIndex: 2,
|
||||
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
});
|
||||
});
|
||||
|
||||
test('honours an explicit anchor offset', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, 'b', (row) => row.id, { anchorOffset: 40 })).toEqual({
|
||||
anchorIndex: 1,
|
||||
anchorOffset: 40,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// Anchored-turn scroll geometry for the chat timeline.
|
||||
//
|
||||
// The timeline has three mutually exclusive scroll modes:
|
||||
//
|
||||
// • `following-end` — stay pinned to the live edge as content grows.
|
||||
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
|
||||
// of the viewport and the reply streams into reserved space below it. The
|
||||
// viewport does NOT move until the turn outgrows the usable viewport.
|
||||
// • `free-scrolling` — the user took over; nothing moves the scroll
|
||||
// position until they opt back in.
|
||||
//
|
||||
// This module is pure geometry: it reads measurements from the virtualized
|
||||
// list and answers "how far, if at all, must we scroll to reveal the end of
|
||||
// the anchored turn". Keeping it free of DOM and React makes the mode machine
|
||||
// testable without a renderer.
|
||||
//
|
||||
// "Usable viewport" is the visible height minus the composer overlay (the
|
||||
// composer floats over the list) minus the anchor offset, so a turn is only
|
||||
// considered overflowing when it genuinely cannot be read.
|
||||
|
||||
export type TimelineScrollMode = 'following-end' | 'anchoring-new-turn' | 'free-scrolling';
|
||||
|
||||
// Distance from the top of the viewport at which an anchored user message
|
||||
// parks. Small enough to read as "at the top", large enough not to collide
|
||||
// with the timeline's top fade.
|
||||
export const CHAT_LIST_ANCHOR_OFFSET = 16;
|
||||
|
||||
export interface TimelineListMeasurementState {
|
||||
readonly data: readonly unknown[];
|
||||
readonly scroll: number;
|
||||
readonly scrollLength: number;
|
||||
readonly positionAtIndex: (index: number) => number | undefined;
|
||||
readonly sizeAtIndex: (index: number) => number | undefined;
|
||||
}
|
||||
|
||||
export interface AnchoredTurnMetrics {
|
||||
readonly anchorTop: number;
|
||||
readonly lastBottom: number;
|
||||
readonly turnHeight: number;
|
||||
readonly usableViewportHeight: number;
|
||||
readonly visibleUsableBottom: number;
|
||||
readonly overflowsUsableViewport: boolean;
|
||||
readonly targetScrollToRevealEnd: number;
|
||||
readonly scrollDeltaToRevealEnd: number;
|
||||
}
|
||||
|
||||
export const getRowBottom = (
|
||||
state: TimelineListMeasurementState,
|
||||
index: number,
|
||||
): number | null => {
|
||||
const top = state.positionAtIndex(index);
|
||||
const height = state.sizeAtIndex(index);
|
||||
if (
|
||||
typeof top !== 'number'
|
||||
|| typeof height !== 'number'
|
||||
|| !Number.isFinite(top)
|
||||
|| !Number.isFinite(height)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Rows measured at zero height would make an anchored turn look empty and
|
||||
// suppress the reveal scroll; treat them as one pixel tall instead.
|
||||
return top + Math.max(1, height);
|
||||
};
|
||||
|
||||
export const getAnchoredTurnMetrics = ({
|
||||
state,
|
||||
anchorIndex,
|
||||
composerOverlayHeight,
|
||||
anchorOffset,
|
||||
}: {
|
||||
readonly state: TimelineListMeasurementState;
|
||||
readonly anchorIndex: number;
|
||||
readonly composerOverlayHeight: number;
|
||||
readonly anchorOffset: number;
|
||||
}): AnchoredTurnMetrics | null => {
|
||||
if (state.data.length === 0) return null;
|
||||
|
||||
const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
|
||||
const anchorTop = state.positionAtIndex(boundedAnchorIndex);
|
||||
// The LAST row bottom, not the content length: the reserved anchored end
|
||||
// space lives past it, and targeting that reserved tail would scroll the
|
||||
// real content off the top.
|
||||
const lastBottom = getRowBottom(state, state.data.length - 1);
|
||||
if (typeof anchorTop !== 'number' || !Number.isFinite(anchorTop) || lastBottom === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usableViewportHeight = Math.max(
|
||||
0,
|
||||
state.scrollLength - composerOverlayHeight - anchorOffset,
|
||||
);
|
||||
const turnHeight = Math.max(0, lastBottom - anchorTop);
|
||||
const visibleUsableBottom = state.scroll + usableViewportHeight;
|
||||
const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
|
||||
// Never negative: revealing the end must not scroll the timeline backwards.
|
||||
const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
|
||||
|
||||
return {
|
||||
anchorTop,
|
||||
lastBottom,
|
||||
turnHeight,
|
||||
usableViewportHeight,
|
||||
visibleUsableBottom,
|
||||
overflowsUsableViewport: turnHeight > usableViewportHeight,
|
||||
targetScrollToRevealEnd,
|
||||
scrollDeltaToRevealEnd,
|
||||
};
|
||||
};
|
||||
|
||||
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
|
||||
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
|
||||
// follow while the user had genuinely scrolled away, yanking them back on the
|
||||
// next stream chunk. Distance is measured against the full content length —
|
||||
// reserved anchored end space included — so a parked anchored turn counts as
|
||||
// the live edge.
|
||||
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
|
||||
|
||||
export const resolveTimelineIsAtEnd = (
|
||||
state: {
|
||||
readonly contentLength?: number;
|
||||
readonly scroll?: number;
|
||||
readonly scrollLength?: number;
|
||||
readonly isNearEnd?: boolean;
|
||||
readonly isAtEnd?: boolean;
|
||||
} | undefined,
|
||||
): boolean | undefined => {
|
||||
if (!state) return undefined;
|
||||
const { contentLength, scroll, scrollLength } = state;
|
||||
if (
|
||||
typeof contentLength === 'number'
|
||||
&& typeof scroll === 'number'
|
||||
&& typeof scrollLength === 'number'
|
||||
&& Number.isFinite(contentLength)
|
||||
) {
|
||||
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
|
||||
}
|
||||
return state.isNearEnd ?? state.isAtEnd;
|
||||
};
|
||||
|
||||
export interface ChatListAnchoredEndSpace {
|
||||
readonly anchorIndex: number;
|
||||
readonly anchorOffset: number;
|
||||
}
|
||||
|
||||
// Finds the anchored row from the BACK of the list: a retried or re-sent
|
||||
// message id can appear more than once, and the live one is always the last.
|
||||
export const resolveChatListAnchoredEndSpace = <Item, AnchorId>(
|
||||
items: readonly Item[],
|
||||
anchorId: AnchorId | null,
|
||||
getAnchorId: (item: Item) => AnchorId | null,
|
||||
options: { readonly anchorOffset?: number } = {},
|
||||
): ChatListAnchoredEndSpace | undefined => {
|
||||
if (anchorId === null) return undefined;
|
||||
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (item !== undefined && getAnchorId(item) === anchorId) {
|
||||
return {
|
||||
anchorIndex: index,
|
||||
anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { commitStreamedText } from './streamTextCommit';
|
||||
|
||||
describe('commitStreamedText', () => {
|
||||
test('holds an incomplete short paragraph entirely', () => {
|
||||
expect(commitStreamedText('An unfinished thought abo')).toBe('');
|
||||
});
|
||||
|
||||
test('commits up to the last complete line', () => {
|
||||
expect(commitStreamedText('First paragraph.\n\nSecond par')).toBe('First paragraph.\n\n');
|
||||
});
|
||||
|
||||
test('reveals code fences line by line', () => {
|
||||
const text = '```py\nprint("a")\nprint("b';
|
||||
expect(commitStreamedText(text)).toBe('```py\nprint("a")\n');
|
||||
});
|
||||
|
||||
test('releases a long held paragraph at the last sentence boundary', () => {
|
||||
const sentence = 'A finished sentence lives here. ';
|
||||
const text = sentence.repeat(12) + 'and an unfinished trail';
|
||||
expect(commitStreamedText(text)).toBe(sentence.repeat(12));
|
||||
});
|
||||
|
||||
test('falls back to the last word boundary without sentences', () => {
|
||||
const words = 'word '.repeat(70);
|
||||
const text = words + 'unfinishe';
|
||||
expect(commitStreamedText(text)).toBe(words);
|
||||
});
|
||||
|
||||
test('keeps unbreakable runs intact rather than splitting them', () => {
|
||||
const run = 'x'.repeat(400);
|
||||
expect(commitStreamedText(run)).toBe(run);
|
||||
});
|
||||
|
||||
test('empty input stays empty', () => {
|
||||
expect(commitStreamedText('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// Block-level streaming reveal.
|
||||
//
|
||||
// Token-by-token streaming mutates the trailing paragraph in place on every
|
||||
// tick: words rewrap, the last line jitters, and the reader's eye fights the
|
||||
// motion. Committing only up to the last COMPLETE line keeps every rendered
|
||||
// block immutable once it appears — prose arrives a paragraph at a time (a
|
||||
// markdown paragraph is one logical line), code fences reveal line by line,
|
||||
// tables row by row — and the only remaining motion is the follow scroll.
|
||||
//
|
||||
// A paragraph with no newline for a long stretch must not stall the stream,
|
||||
// so once the held tail outgrows a threshold it is committed at the last
|
||||
// sentence boundary (falling back to the last word boundary).
|
||||
|
||||
const HOLD_MAX_CHARS = 320;
|
||||
|
||||
const SENTENCE_END = /[.!?…][)"'»”’]?\s/g;
|
||||
|
||||
export const commitStreamedText = (text: string): string => {
|
||||
if (text.length === 0) return text;
|
||||
|
||||
const lastNewline = text.lastIndexOf('\n');
|
||||
const committed = lastNewline === -1 ? '' : text.slice(0, lastNewline + 1);
|
||||
const held = text.slice(committed.length);
|
||||
|
||||
if (held.length <= HOLD_MAX_CHARS) {
|
||||
return committed;
|
||||
}
|
||||
|
||||
// The held paragraph got long: release it up to the last finished
|
||||
// sentence so the block still never mutates mid-sentence.
|
||||
let lastSentenceEnd = -1;
|
||||
for (const match of held.matchAll(SENTENCE_END)) {
|
||||
lastSentenceEnd = match.index + match[0].length;
|
||||
}
|
||||
if (lastSentenceEnd > 0) {
|
||||
return committed + held.slice(0, lastSentenceEnd);
|
||||
}
|
||||
|
||||
// No sentence boundary either (a URL, a very long token run): release up
|
||||
// to the last word boundary, keeping only the incomplete word held.
|
||||
const lastSpace = held.lastIndexOf(' ');
|
||||
if (lastSpace > 0) {
|
||||
return committed + held.slice(0, lastSpace + 1);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
@@ -64,8 +64,7 @@ describe('buildLiveStreamingEntry', () => {
|
||||
const entry = turnEntry(assistant);
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId: 'assistant_other',
|
||||
liveParts: [textPart('part_live', 'live')],
|
||||
livePartsByMessageId: { assistant_other: [textPart('part_live', 'live')] },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
@@ -79,8 +78,7 @@ describe('buildLiveStreamingEntry', () => {
|
||||
const liveParts = [reasoningPart('part_1_live', 'thinking')];
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId: 'assistant_1',
|
||||
liveParts,
|
||||
livePartsByMessageId: { assistant_1: liveParts },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
@@ -102,8 +100,7 @@ describe('buildLiveStreamingEntry', () => {
|
||||
const liveParts = [textPart('part_1_live', 'live')];
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId: 'assistant_1',
|
||||
liveParts,
|
||||
livePartsByMessageId: { assistant_1: liveParts },
|
||||
showTextJustificationActivity: false,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
@@ -121,8 +118,7 @@ describe('buildLiveStreamingEntry', () => {
|
||||
const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId: 'assistant_1',
|
||||
liveParts: [synthetic, visible],
|
||||
livePartsByMessageId: { assistant_1: [synthetic, visible] },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
@@ -131,4 +127,39 @@ describe('buildLiveStreamingEntry', () => {
|
||||
if (next.kind !== 'turn') return;
|
||||
expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
|
||||
});
|
||||
|
||||
test('keeps a finished step message on its live parts after the stream moves on', () => {
|
||||
const finished = message('assistant_1', 'assistant', 'user_1', []);
|
||||
const streaming = message('assistant_2', 'assistant', 'user_1', []);
|
||||
const entry = turnEntry(finished);
|
||||
if (entry.kind !== 'turn') return;
|
||||
entry.turn.assistantMessageIds = ['assistant_1', 'assistant_2'];
|
||||
entry.turn.assistantMessages = [finished, streaming];
|
||||
const finishedLive = [textPart('part_tool_done', 'tool output')];
|
||||
const streamingLive = [textPart('part_streaming', 'streaming')];
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
livePartsByMessageId: { assistant_1: finishedLive, assistant_2: streamingLive },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
|
||||
expect(next.kind).toBe('turn');
|
||||
if (next.kind !== 'turn') return;
|
||||
expect(next.turn.assistantMessages[0]?.parts).toEqual(finishedLive);
|
||||
expect(next.turn.assistantMessages[1]?.parts).toEqual(streamingLive);
|
||||
});
|
||||
|
||||
test('never erases record parts with an empty live array', () => {
|
||||
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'kept')]);
|
||||
const entry = turnEntry(assistant);
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
livePartsByMessageId: { assistant_1: [] },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
|
||||
expect(next).toBe(entry);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,13 @@ export type StreamingTailEntry =
|
||||
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
|
||||
|
||||
type BuildLiveStreamingEntryOptions = {
|
||||
activeStreamingMessageId: string | null | undefined;
|
||||
liveParts: Part[];
|
||||
// Live parts for EVERY message of the streaming tail, not only the one
|
||||
// currently streaming: when the stream moves to the next step message, the
|
||||
// previous message's base record can still lag behind the part store, and
|
||||
// rendering it from that stale snapshot briefly drops its completed tool
|
||||
// parts — remounting them (and replaying their reveal animation) once the
|
||||
// record catches up.
|
||||
livePartsByMessageId: Readonly<Record<string, Part[]>>;
|
||||
showTextJustificationActivity: boolean;
|
||||
showTurnChangedFiles: boolean;
|
||||
mergeHiddenUserTurns?: { planModeEnabled: boolean };
|
||||
@@ -24,10 +29,12 @@ type BuildLiveStreamingEntryOptions = {
|
||||
|
||||
const withLiveParts = (
|
||||
message: ChatMessageEntry,
|
||||
activeStreamingMessageId: string,
|
||||
liveParts: Part[],
|
||||
livePartsByMessageId: Readonly<Record<string, Part[]>>,
|
||||
): ChatMessageEntry => {
|
||||
if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) {
|
||||
const liveParts = livePartsByMessageId[message.info.id];
|
||||
// An empty live array is ambiguous — the store may simply not have loaded
|
||||
// this message's parts — and must never erase parts the record does have.
|
||||
if (!liveParts || liveParts.length === 0 || message.parts === liveParts) {
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -41,13 +48,10 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
|
||||
entry: TEntry,
|
||||
options: BuildLiveStreamingEntryOptions,
|
||||
): TEntry => {
|
||||
const activeStreamingMessageId = options.activeStreamingMessageId;
|
||||
if (!activeStreamingMessageId) {
|
||||
return entry;
|
||||
}
|
||||
const livePartsByMessageId = options.livePartsByMessageId;
|
||||
|
||||
if (entry.kind === 'ungrouped') {
|
||||
const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts);
|
||||
const message = withLiveParts(entry.message, livePartsByMessageId);
|
||||
if (message === entry.message) {
|
||||
return entry;
|
||||
}
|
||||
@@ -59,7 +63,7 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
|
||||
|
||||
let changed = false;
|
||||
const assistantMessages = entry.turn.assistantMessages.map((message) => {
|
||||
const next = withLiveParts(message, activeStreamingMessageId, options.liveParts);
|
||||
const next = withLiveParts(message, livePartsByMessageId);
|
||||
if (next !== message) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -43,28 +43,30 @@ export type DecorateContext = {
|
||||
onPreviewLoopback?: (url: string) => void;
|
||||
};
|
||||
|
||||
// Reference the app's icon sprite (injected into <body> by the shared Icon
|
||||
// component) so DOM-built controls use the same themed icons as the rest of
|
||||
// the app. Sprite symbols are registered under `#oc-<name>`.
|
||||
const spriteIcon = (name: IconName): string =>
|
||||
`<svg class="remixicon size-3.5" viewBox="0 0 24 24" aria-hidden="true"><use href="#oc-${name}"></use></svg>`;
|
||||
|
||||
const ICONS = {
|
||||
copy: spriteIcon('file-copy'),
|
||||
check: spriteIcon('check'),
|
||||
download: spriteIcon('download'),
|
||||
zoomIn: spriteIcon('add'),
|
||||
zoomOut: spriteIcon('subtract'),
|
||||
fit: spriteIcon('refresh'),
|
||||
textWrap: spriteIcon('text-wrap'),
|
||||
image: spriteIcon('file-image'),
|
||||
} as const;
|
||||
copy: 'file-copy',
|
||||
check: 'check',
|
||||
download: 'download',
|
||||
zoomIn: 'add',
|
||||
zoomOut: 'subtract',
|
||||
fit: 'refresh',
|
||||
textWrap: 'text-wrap',
|
||||
image: 'file-image',
|
||||
} as const satisfies Record<string, IconName>;
|
||||
|
||||
const ICON_BTN_CLASS =
|
||||
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--interactive-focus-ring)]';
|
||||
|
||||
const setIconHtml = (el: Element, html: string): void => {
|
||||
el.innerHTML = html;
|
||||
const setIcon = (el: Element, icon: keyof typeof ICONS): void => {
|
||||
const iconName = ICONS[icon];
|
||||
const svg = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', 'remixicon size-3.5');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
const use = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use.setAttribute('href', `#oc-${iconName}`);
|
||||
svg.appendChild(use);
|
||||
el.replaceChildren(svg);
|
||||
};
|
||||
|
||||
const decorateImageLabels = (root: HTMLElement): void => {
|
||||
@@ -74,7 +76,7 @@ const decorateImageLabels = (root: HTMLElement): void => {
|
||||
icon.className = 'inline-flex shrink-0';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true');
|
||||
setIconHtml(icon, ICONS.image);
|
||||
setIcon(icon, 'image');
|
||||
label.prepend(icon);
|
||||
}
|
||||
};
|
||||
@@ -86,7 +88,7 @@ const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string):
|
||||
button.setAttribute('data-md-action', slot);
|
||||
button.setAttribute('title', title);
|
||||
button.setAttribute('aria-label', title);
|
||||
setIconHtml(button, ICONS[icon]);
|
||||
setIcon(button, icon);
|
||||
return button;
|
||||
};
|
||||
|
||||
@@ -131,6 +133,9 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
const code = pre.querySelector<HTMLElement>(':scope > code');
|
||||
if (!code || code.hasAttribute('data-md-code-lines')) return;
|
||||
|
||||
// The real gutter takes over the reserved footprint.
|
||||
pre.removeAttribute('data-md-gutter-reserved');
|
||||
|
||||
const text = code.textContent ?? '';
|
||||
const hasTrailingNewline = text.endsWith('\n');
|
||||
const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n');
|
||||
@@ -151,9 +156,8 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
row.setAttribute('data-md-code-line', '');
|
||||
|
||||
const number = document.createElement('span');
|
||||
number.setAttribute('data-md-code-line-number', '');
|
||||
number.setAttribute('data-md-code-line-number', String(index + 1));
|
||||
number.setAttribute('aria-hidden', 'true');
|
||||
number.textContent = String(index + 1);
|
||||
|
||||
const content = document.createElement('span');
|
||||
content.setAttribute('data-md-code-line-content', '');
|
||||
@@ -163,7 +167,6 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
} else {
|
||||
content.textContent = sourceLine;
|
||||
}
|
||||
|
||||
row.append(number, content);
|
||||
fragment.appendChild(row);
|
||||
if (index < sourceLines.length - 1 || hasTrailingNewline) {
|
||||
@@ -196,11 +199,11 @@ export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: bool
|
||||
};
|
||||
|
||||
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
|
||||
setIconHtml(button, ICONS.check);
|
||||
setIcon(button, 'check');
|
||||
button.setAttribute('title', copiedTitle);
|
||||
button.setAttribute('aria-label', copiedTitle);
|
||||
window.setTimeout(() => {
|
||||
setIconHtml(button, ICONS[restore]);
|
||||
setIcon(button, restore);
|
||||
button.setAttribute('title', restoreTitle);
|
||||
button.setAttribute('aria-label', restoreTitle);
|
||||
}, 2000);
|
||||
@@ -263,7 +266,15 @@ const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
pre.style.margin = '0';
|
||||
pre.style.background = 'transparent';
|
||||
pre.classList.add('min-w-0', 'w-full', 'flex-1');
|
||||
if (!ctx.deferCodeLineNumberSync) layoutCodeLines(pre);
|
||||
if (!ctx.deferCodeLineNumberSync) {
|
||||
layoutCodeLines(pre);
|
||||
} else {
|
||||
// Streaming defers the per-line gutter markup, but the gutter's
|
||||
// horizontal footprint is reserved immediately — otherwise the
|
||||
// end-of-stream decorate pass shifts every code line right by the
|
||||
// gutter column and the finished message visibly jumps.
|
||||
pre.setAttribute('data-md-gutter-reserved', '');
|
||||
}
|
||||
body.appendChild(pre);
|
||||
wrapper.appendChild(header);
|
||||
wrapper.appendChild(body);
|
||||
@@ -492,7 +503,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
preview.setAttribute('data-md-url', href);
|
||||
preview.setAttribute('title', ctx.labels.previewTitle);
|
||||
preview.setAttribute('aria-label', ctx.labels.previewLabel);
|
||||
setIconHtml(preview, ICONS.download);
|
||||
setIcon(preview, 'download');
|
||||
anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
|
||||
}
|
||||
}
|
||||
@@ -530,6 +541,67 @@ const closeAllMenus = (container: HTMLElement): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const getContainingMarkdownCode = (node: Node): HTMLElement | null => {
|
||||
const element = node.nodeType === 1 ? node as Element : node.parentElement;
|
||||
return element?.closest<HTMLElement>('pre code[data-md-code-lines]') ?? null;
|
||||
};
|
||||
|
||||
const getMarkdownCodeSelectionText = (range: Range): string | null => {
|
||||
const code = getContainingMarkdownCode(range.startContainer);
|
||||
if (!code || code !== getContainingMarkdownCode(range.endContainer)) return null;
|
||||
// Line numbers are CSS-generated, so the DOM range is already the exact
|
||||
// source selection, including boundaries between rows and empty lines.
|
||||
return range.toString();
|
||||
};
|
||||
|
||||
type MarkdownCopyState = {
|
||||
registrations: number;
|
||||
handler: (event: ClipboardEvent) => void;
|
||||
menuHandler: (event: Event) => void;
|
||||
};
|
||||
|
||||
const markdownCopyStates = new WeakMap<Document, MarkdownCopyState>();
|
||||
|
||||
const registerMarkdownCodeCopy = (doc: Document): (() => void) => {
|
||||
let state = markdownCopyStates.get(doc);
|
||||
if (!state) {
|
||||
const getSelectedText = (): string | null => {
|
||||
const selection = doc.getSelection();
|
||||
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
|
||||
return getMarkdownCodeSelectionText(selection.getRangeAt(0));
|
||||
};
|
||||
const handler = (event: ClipboardEvent) => {
|
||||
if (!event.clipboardData) return;
|
||||
const text = getSelectedText();
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.clipboardData.setData('text/plain', text);
|
||||
};
|
||||
const menuHandler = (event: Event) => {
|
||||
const text = getSelectedText();
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
void copyTextToClipboard(text);
|
||||
};
|
||||
state = { registrations: 0, handler, menuHandler };
|
||||
markdownCopyStates.set(doc, state);
|
||||
doc.addEventListener('copy', handler, true);
|
||||
doc.defaultView?.addEventListener('openchamber:copy', menuHandler);
|
||||
}
|
||||
state.registrations += 1;
|
||||
|
||||
return () => {
|
||||
const current = markdownCopyStates.get(doc);
|
||||
if (!current) return;
|
||||
current.registrations -= 1;
|
||||
if (current.registrations > 0) return;
|
||||
doc.removeEventListener('copy', current.handler, true);
|
||||
doc.defaultView?.removeEventListener('openchamber:copy', current.menuHandler);
|
||||
markdownCopyStates.delete(doc);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach a single delegated click listener for all in-markdown actions: code
|
||||
* copy, table copy/download menus, mermaid copy/download, loopback preview.
|
||||
@@ -539,6 +611,7 @@ export const attachMarkdownInteractions = (
|
||||
container: HTMLElement,
|
||||
ctx: DecorateContext,
|
||||
): (() => void) => {
|
||||
const unregisterCodeCopy = registerMarkdownCodeCopy(container.ownerDocument);
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
@@ -554,7 +627,12 @@ export const attachMarkdownInteractions = (
|
||||
if (action === 'copy-code') {
|
||||
const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code');
|
||||
const text = code ? getMarkdownCodeText(code) : '';
|
||||
if (text) void copyTextToClipboard(text).then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy));
|
||||
if (text) {
|
||||
actionEl.setAttribute('data-md-copy-pending', '');
|
||||
void copyTextToClipboard(text)
|
||||
.then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy))
|
||||
.finally(() => actionEl.removeAttribute('data-md-copy-pending'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -640,5 +718,8 @@ export const attachMarkdownInteractions = (
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
return () => container.removeEventListener('click', handleClick);
|
||||
return () => {
|
||||
unregisterCodeCopy();
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
import { DetachedMarkdownDomCache, type DetachedMarkdownDom } from './detachedMarkdownDomCache';
|
||||
|
||||
Object.assign(globalThis, { document: new Window().document });
|
||||
|
||||
const keyFor = ({ scope, id, locale, directory }: DetachedMarkdownDom) => ({ scope, id, locale, directory });
|
||||
|
||||
const createEntry = (
|
||||
document: Document,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
partId: string,
|
||||
): DetachedMarkdownDom => {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const node = document.createElement('p');
|
||||
node.textContent = `${messageId}:${partId}`;
|
||||
fragment.appendChild(node);
|
||||
return {
|
||||
scope: `runtime:${sessionId}`,
|
||||
id: `${messageId}:${partId}`,
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
fragment,
|
||||
};
|
||||
};
|
||||
|
||||
describe('DetachedMarkdownDomCache', () => {
|
||||
test('consumes the original DOM fragment once and rejects another locale', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const entry = createEntry(document, 'session-a', 'message-a', 'part-a');
|
||||
const originalNode = entry.fragment.firstChild;
|
||||
|
||||
cache.store(entry);
|
||||
expect(cache.take({ ...keyFor(entry), locale: 'zh' })).toBeNull();
|
||||
cache.store(entry);
|
||||
const restored = cache.take(keyFor(entry));
|
||||
expect(restored?.firstChild).toBe(originalNode);
|
||||
expect(cache.take(keyFor(entry))).toBeNull();
|
||||
});
|
||||
|
||||
test('bounds entries per session and evicts the least recently used session', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
cache.store(createEntry(document, 'session-a', 'message-1', 'part'));
|
||||
cache.store(createEntry(document, 'session-a', 'message-2', 'part'));
|
||||
cache.store(createEntry(document, 'session-a', 'message-3', 'part'));
|
||||
cache.store(createEntry(document, 'session-b', 'message-4', 'part'));
|
||||
cache.store(createEntry(document, 'session-c', 'message-5', 'part'));
|
||||
expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
|
||||
expect(cache.take({
|
||||
scope: 'runtime:session-a',
|
||||
id: 'message-2:part',
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
})).toBeNull();
|
||||
expect(cache.take({
|
||||
scope: 'runtime:session-c',
|
||||
id: 'message-5:part',
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
})).not.toBeNull();
|
||||
});
|
||||
|
||||
test('isolates identities by runtime and replaces an identity without growing stats', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const first = createEntry(document, 'session', 'message', 'part');
|
||||
const replacement = createEntry(document, 'session', 'message', 'part');
|
||||
const replacementNode = replacement.fragment.firstChild;
|
||||
const otherRuntime = createEntry(document, 'other-runtime-session', 'message', 'part');
|
||||
|
||||
cache.store(first);
|
||||
cache.store(replacement);
|
||||
cache.store(otherRuntime);
|
||||
|
||||
expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
|
||||
expect(cache.take(keyFor(otherRuntime))).not.toBeNull();
|
||||
expect(cache.take(keyFor(replacement))?.firstChild).toBe(replacementNode);
|
||||
});
|
||||
|
||||
test('does not restore file-link DOM under another directory', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const entry = createEntry(document, 'session', 'message', 'part');
|
||||
|
||||
cache.store(entry);
|
||||
|
||||
expect(cache.take({ ...keyFor(entry), directory: '/repo-b' })).toBeNull();
|
||||
});
|
||||
|
||||
test('refreshes session LRU and clears all entries', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const sessionA = createEntry(document, 'session-a', 'message-a', 'part');
|
||||
const sessionB = createEntry(document, 'session-b', 'message-b', 'part');
|
||||
const sessionC = createEntry(document, 'session-c', 'message-c', 'part');
|
||||
cache.store(sessionA);
|
||||
cache.store(sessionB);
|
||||
cache.store(sessionA);
|
||||
cache.store(sessionC);
|
||||
expect(cache.take(keyFor(sessionB))).toBeNull();
|
||||
|
||||
cache.clear();
|
||||
expect(cache.stats()).toEqual({ sessions: 0, entries: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
export type DetachedMarkdownDomKey = {
|
||||
scope: string;
|
||||
id: string;
|
||||
locale: string;
|
||||
directory: string;
|
||||
};
|
||||
|
||||
export type DetachedMarkdownDom = DetachedMarkdownDomKey & {
|
||||
// The fragment owns the original nodes. take() consumes it once by moving
|
||||
// those nodes back into a renderer; nothing is cloned or serialized.
|
||||
fragment: DocumentFragment;
|
||||
};
|
||||
|
||||
export type DetachedMarkdownDomCacheStats = {
|
||||
sessions: number;
|
||||
entries: number;
|
||||
};
|
||||
|
||||
// Holds detached, fully decorated Markdown DOM. The cache is intentionally
|
||||
// small: it accelerates recent-session and reverse-scroll remounts without
|
||||
// retaining whole session trees or depending on browser-specific byte guesses.
|
||||
type DetachedMarkdownDomCacheLimits = {
|
||||
maxSessions: number;
|
||||
maxEntriesPerSession: number;
|
||||
};
|
||||
|
||||
type SessionCache = Map<string, DetachedMarkdownDom>;
|
||||
|
||||
const DEFAULT_LIMITS: DetachedMarkdownDomCacheLimits = {
|
||||
// Eight buckets cover a broader recent-session working set without
|
||||
// coupling eviction to React commit or microtask timing.
|
||||
maxSessions: 8,
|
||||
maxEntriesPerSession: 4,
|
||||
};
|
||||
|
||||
export class DetachedMarkdownDomCache {
|
||||
private readonly maxSessions: number;
|
||||
private readonly maxEntriesPerSession: number;
|
||||
private readonly sessions = new Map<string, SessionCache>();
|
||||
|
||||
constructor(limits: DetachedMarkdownDomCacheLimits = DEFAULT_LIMITS) {
|
||||
this.maxSessions = Math.max(1, limits.maxSessions);
|
||||
this.maxEntriesPerSession = Math.max(1, limits.maxEntriesPerSession);
|
||||
}
|
||||
|
||||
store(entry: DetachedMarkdownDom): void {
|
||||
const sessionKey = entry.scope;
|
||||
const entryKey = entry.id;
|
||||
|
||||
let session = this.sessions.get(sessionKey);
|
||||
if (session === undefined) {
|
||||
session = new Map();
|
||||
this.sessions.set(sessionKey, session);
|
||||
} else {
|
||||
this.refreshSession(sessionKey, session);
|
||||
}
|
||||
|
||||
// A part has one DOM version inside its authoritative runtime/session.
|
||||
session.delete(entryKey);
|
||||
session.set(entryKey, entry);
|
||||
|
||||
while (session.size > this.maxEntriesPerSession) {
|
||||
this.removeOldestEntry(session);
|
||||
}
|
||||
while (this.sessions.size > this.maxSessions) {
|
||||
this.removeOldestSession();
|
||||
}
|
||||
}
|
||||
|
||||
take(key: DetachedMarkdownDomKey): DocumentFragment | null {
|
||||
const sessionKey = key.scope;
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return null;
|
||||
const entryKey = key.id;
|
||||
|
||||
this.refreshSession(sessionKey, session);
|
||||
const entry = session.get(entryKey);
|
||||
if (entry === undefined) return null;
|
||||
|
||||
// A mismatched probe (different locale or directory for the same part)
|
||||
// must not destroy the entry — the matching renderer may still come for
|
||||
// it. Only a real hit transfers ownership out of the cache.
|
||||
if (entry.locale !== key.locale || entry.directory !== key.directory) return null;
|
||||
|
||||
// A fragment is a move-only resource; taking it removes cache ownership.
|
||||
session.delete(entryKey);
|
||||
if (session.size === 0) this.sessions.delete(sessionKey);
|
||||
return entry.fragment;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
stats(): DetachedMarkdownDomCacheStats {
|
||||
let entries = 0;
|
||||
for (const session of this.sessions.values()) {
|
||||
entries += session.size;
|
||||
}
|
||||
return {
|
||||
sessions: this.sessions.size,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
private refreshSession(sessionKey: string, session: SessionCache): void {
|
||||
this.sessions.delete(sessionKey);
|
||||
this.sessions.set(sessionKey, session);
|
||||
}
|
||||
|
||||
private removeOldestEntry(session: SessionCache): void {
|
||||
const oldestKey = session.keys().next().value;
|
||||
if (oldestKey === undefined) return;
|
||||
session.delete(oldestKey);
|
||||
}
|
||||
|
||||
private removeOldestSession(): void {
|
||||
const oldestKey = this.sessions.keys().next().value;
|
||||
if (oldestKey === undefined) return;
|
||||
this.sessions.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
export const detachedMarkdownDomCache = new DetachedMarkdownDomCache();
|
||||
@@ -1,4 +1,5 @@
|
||||
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
|
||||
import { isVSCodeRuntime } from '@/stores/utils/vscodeRuntime';
|
||||
import {
|
||||
contentFingerprint,
|
||||
estimateTokenRunsBytes,
|
||||
@@ -46,6 +47,8 @@ const resultCache = new HighlightResultCache<CachedHighlight>({
|
||||
const inflight = new Map<string, Promise<CachedHighlight | null>>();
|
||||
|
||||
let worker: Worker | undefined;
|
||||
let workerCreation: Promise<Worker | undefined> | undefined;
|
||||
let workerObjectUrl: string | undefined;
|
||||
let nextId = 0;
|
||||
const pending = new Map<number, PendingResolver>();
|
||||
// Theme names whose full definition we've already shipped to the live worker, so
|
||||
@@ -71,31 +74,56 @@ const failAll = (): void => {
|
||||
inflight.clear();
|
||||
worker?.terminate();
|
||||
worker = undefined;
|
||||
workerCreation = undefined;
|
||||
if (workerObjectUrl) {
|
||||
URL.revokeObjectURL(workerObjectUrl);
|
||||
workerObjectUrl = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getWorker = (): Worker | undefined => {
|
||||
if (worker) return worker;
|
||||
const createWorker = async (): Promise<Worker | undefined> => {
|
||||
if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined;
|
||||
try {
|
||||
worker = new Worker(MarkdownShikiWorkerUrl, { type: 'module' });
|
||||
let workerUrl = MarkdownShikiWorkerUrl;
|
||||
if (isVSCodeRuntime(null)) {
|
||||
const response = await fetch(workerUrl);
|
||||
if (!response.ok) throw new Error(`Shiki worker request failed with ${response.status}`);
|
||||
workerObjectUrl = URL.createObjectURL(await response.blob());
|
||||
workerUrl = workerObjectUrl;
|
||||
}
|
||||
|
||||
const instance = new Worker(workerUrl, { type: 'module' });
|
||||
worker = instance;
|
||||
instance.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
const resolve = pending.get(event.data.id);
|
||||
if (!resolve) return;
|
||||
pending.delete(event.data.id);
|
||||
resolve(event.data);
|
||||
};
|
||||
instance.onerror = failAll;
|
||||
instance.onmessageerror = failAll;
|
||||
instance.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
|
||||
return instance;
|
||||
} catch (err) {
|
||||
if (workerObjectUrl) {
|
||||
URL.revokeObjectURL(workerObjectUrl);
|
||||
workerObjectUrl = undefined;
|
||||
}
|
||||
console.error('Failed to create Shiki worker:', err);
|
||||
return undefined;
|
||||
}
|
||||
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
const resolve = pending.get(event.data.id);
|
||||
if (!resolve) return;
|
||||
pending.delete(event.data.id);
|
||||
resolve(event.data);
|
||||
};
|
||||
worker.onerror = failAll;
|
||||
worker.onmessageerror = failAll;
|
||||
worker.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
|
||||
return worker;
|
||||
};
|
||||
|
||||
const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
|
||||
const instance = getWorker();
|
||||
const getWorker = async (): Promise<Worker | undefined> => {
|
||||
if (worker) return worker;
|
||||
workerCreation ??= createWorker().finally(() => {
|
||||
workerCreation = undefined;
|
||||
});
|
||||
return workerCreation;
|
||||
};
|
||||
|
||||
const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
|
||||
const instance = await getWorker();
|
||||
if (!instance) return Promise.resolve(null);
|
||||
const id = ++nextId;
|
||||
return new Promise<MarkdownWorkerResponse | null>((resolve) => {
|
||||
@@ -167,6 +195,12 @@ export const highlightLinesInWorker = async (code: string, lang: string): Promis
|
||||
return result?.type === 'highlightLines' ? result.lines : null;
|
||||
};
|
||||
|
||||
/** Return an already-tokenized line result without scheduling a worker request. */
|
||||
export const getCachedHighlightedLines = (code: string, lang: string): string[] | null => {
|
||||
const cached = resultCache.get(cacheKeyFor('highlightLines', lang, code));
|
||||
return cached?.type === 'highlightLines' ? cached.lines : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tokenize `code` with the given resolved TextMate theme and return per-line
|
||||
* styled runs with offsets — for building CodeMirror decorations that match the
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
type SanitizeAttribute = {
|
||||
attrName: string;
|
||||
attrValue: string;
|
||||
forceKeepAttr?: boolean;
|
||||
};
|
||||
|
||||
class TestAnchorElement {
|
||||
target = '';
|
||||
|
||||
setAttribute(name: string, value: string): void {
|
||||
if (name === 'target') this.target = value;
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeHooks: {
|
||||
uponSanitizeAttribute?: (node: unknown, data: SanitizeAttribute) => void;
|
||||
afterSanitizeAttributes?: (node: unknown) => void;
|
||||
} = {};
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: {},
|
||||
HTMLAnchorElement: TestAnchorElement,
|
||||
});
|
||||
|
||||
mock.module('dompurify', () => ({
|
||||
default: {
|
||||
isSupported: true,
|
||||
addHook: () => undefined,
|
||||
sanitize: (html: string) => html,
|
||||
addHook: (name: keyof typeof sanitizeHooks, hook: never) => {
|
||||
sanitizeHooks[name] = hook;
|
||||
},
|
||||
sanitize: (html: string) => html.replace(/ href="([^"]*)"/g, (attribute, href: string) => {
|
||||
const anchor = new TestAnchorElement();
|
||||
const data: SanitizeAttribute = { attrName: 'href', attrValue: href };
|
||||
sanitizeHooks.uponSanitizeAttribute?.(anchor, data);
|
||||
sanitizeHooks.afterSanitizeAttributes?.(anchor);
|
||||
|
||||
return data.forceKeepAttr || /^(?:https?|mailto|tel):/i.test(href) ? attribute : '';
|
||||
}),
|
||||
},
|
||||
}));
|
||||
mock.module('./markdown-worker', () => ({
|
||||
@@ -16,7 +49,10 @@ import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from '
|
||||
const {
|
||||
__markdownImageCandidateCacheForTests,
|
||||
extractMarkdownImageCandidates,
|
||||
getCachedMarkdownBlocks,
|
||||
renderMarkdownBlocks,
|
||||
renderMarkdownSync,
|
||||
resetMarkdownHtmlCacheForTests,
|
||||
} = await import('./markdownCore');
|
||||
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
|
||||
|
||||
@@ -40,6 +76,62 @@ describe('markdown sanitization', () => {
|
||||
expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false);
|
||||
expect(isLocalFileUrl('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps app and local file links while stripping blocked schemes', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[app](obsidian://open?vault=Notebook)',
|
||||
'[file](file:///workspace/notes.md)',
|
||||
'[script](javascript:alert(1))',
|
||||
'[diagnostic](ms-msdt:/id%20PCWDiagnostic)',
|
||||
].join('\n\n'), 'inline');
|
||||
|
||||
expect(html).toContain('href="obsidian://open?vault=Notebook"');
|
||||
expect(html).toContain('href="file:///workspace/notes.md"');
|
||||
expect(html).not.toContain('href="javascript:alert(1)"');
|
||||
expect(html).not.toContain('href="ms-msdt:/id%20PCWDiagnostic"');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Markdown block cache reads', () => {
|
||||
test('returns all settled blocks synchronously after a full cache hit', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
const text = '**cached** settled markdown';
|
||||
|
||||
expect(getCachedMarkdownBlocks(text)).toBeNull();
|
||||
const rendered = await renderMarkdownBlocks(text, false);
|
||||
|
||||
expect(getCachedMarkdownBlocks(text)).toEqual(rendered);
|
||||
});
|
||||
|
||||
test('returns null for a cold or partial settled miss', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
const first = 'first settled block';
|
||||
const changed = 'first settled block\n\nsecond settled block';
|
||||
|
||||
await renderMarkdownBlocks(first, false);
|
||||
|
||||
expect(getCachedMarkdownBlocks(changed)).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps image mode identity out of the settled full hit', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
const text = '';
|
||||
|
||||
await renderMarkdownBlocks(text, false, 'inline');
|
||||
|
||||
expect(getCachedMarkdownBlocks(text, 'label')).toBeNull();
|
||||
expect(getCachedMarkdownBlocks(text, 'inline')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('does not treat streaming live-cache entries as settled full hits', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
const text = 'streaming markdown';
|
||||
|
||||
await renderMarkdownBlocks(text, true);
|
||||
|
||||
expect(getCachedMarkdownBlocks(text)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown images', () => {
|
||||
@@ -187,3 +279,30 @@ describe('Markdown images', () => {
|
||||
expect(html).not.toContain('data-openchamber-markdown-image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CJK-aware link parsing', () => {
|
||||
const hrefOf = (html: string): string | null => /<a\b[^>]*href="([^"]*)"/.exec(html)?.[1] ?? null;
|
||||
|
||||
test('bare URL followed by a CJK annotation trims the annotation from the href', () => {
|
||||
const html = renderMarkdownSync('访问 https://example.com/docs(中文说明)了解更多');
|
||||
expect(hrefOf(html)).toBe('https://example.com/docs');
|
||||
});
|
||||
|
||||
test('bare URL followed by CJK punctuation trims the punctuation', () => {
|
||||
expect(hrefOf(renderMarkdownSync('地址 https://example.com/guide,详见'))).toBe(
|
||||
'https://example.com/guide',
|
||||
);
|
||||
expect(hrefOf(renderMarkdownSync('官网 https://example.com。'))).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('correct links are unaffected', () => {
|
||||
expect(hrefOf(renderMarkdownSync('官方文档见 [这里](https://docs.example.com)(中文说明)'))).toBe(
|
||||
'https://docs.example.com',
|
||||
);
|
||||
expect(hrefOf(renderMarkdownSync('[下载](https://dl.example.com/安装包(正式版))'))).toBe(
|
||||
'https://dl.example.com/安装包(正式版)',
|
||||
);
|
||||
expect(hrefOf(renderMarkdownSync('[a](url(1))'))).toBe('url(1)');
|
||||
expect(hrefOf(renderMarkdownSync('[a](url "title")'))).toBe('url');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Marked, marked, type Tokens } from 'marked';
|
||||
import markedLinkifyIt from 'marked-linkify-it';
|
||||
import remend from 'remend';
|
||||
import katex from 'katex';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
||||
import { isAppLinkUrl } from '@/lib/url';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
|
||||
import { highlightCodeInWorker } from './markdown-worker';
|
||||
@@ -177,9 +179,11 @@ type MarkdownBlock = {
|
||||
raw: string;
|
||||
src: string;
|
||||
mode: 'full' | 'live';
|
||||
// When false, skip syntax highlighting for this block. Set for the actively
|
||||
// streaming open code fence so we don't re-tokenize a growing block ~40x/sec
|
||||
// (O(n^2)); it highlights once the fence closes and becomes a stable block.
|
||||
// When false, skip syntax highlighting for this block. Block-level commit
|
||||
// feeds the open fence whole lines at the throttle cadence (<=10/sec), so a
|
||||
// partial fence highlights too and streamed code arrives colored; only a
|
||||
// very large open fence falls back to plain text until it closes, keeping
|
||||
// the repeated worker re-tokenization bounded.
|
||||
highlight: boolean;
|
||||
};
|
||||
|
||||
@@ -200,6 +204,11 @@ const hasOpenFence = (raw: string): boolean => {
|
||||
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last);
|
||||
};
|
||||
|
||||
// Above this, re-highlighting the still-open fence on every committed line
|
||||
// costs more than the colored preview is worth; the block highlights in one
|
||||
// pass when the fence closes.
|
||||
const OPEN_FENCE_HIGHLIGHT_LINE_LIMIT = 300;
|
||||
|
||||
const heal = (text: string): string => {
|
||||
try {
|
||||
return remend(text, { linkMode: 'text-only' });
|
||||
@@ -249,11 +258,13 @@ const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
|
||||
const raw = token.raw ?? '';
|
||||
const isLast = i === tail;
|
||||
const openFence = token.type === 'code' && hasOpenFence(raw);
|
||||
const openFenceHighlight = openFence
|
||||
&& raw.split('\n').length <= OPEN_FENCE_HIGHLIGHT_LINE_LIMIT;
|
||||
blocks.push({
|
||||
raw,
|
||||
src: openFence ? raw : heal(raw),
|
||||
mode: isLast ? 'live' : 'full',
|
||||
highlight: !openFence,
|
||||
highlight: !openFence || openFenceHighlight,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -321,10 +332,15 @@ const blockMathExtension = {
|
||||
},
|
||||
};
|
||||
|
||||
const createParser = (imageMode: MarkdownImageMode) => new Marked().use({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
extensions: [inlineMathExtension, blockMathExtension],
|
||||
// marked's GFM autolink swallows CJK punctuation after a bare URL, so switch
|
||||
// to marked-linkify-it, which treats Unicode punctuation as a URL boundary.
|
||||
// Plain CJK characters right after a URL are still consumed, matching GitHub.
|
||||
const createParser = (imageMode: MarkdownImageMode) => new Marked().use(
|
||||
markedLinkifyIt({ fuzzyLink: false }),
|
||||
{
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
extensions: [inlineMathExtension, blockMathExtension],
|
||||
renderer: {
|
||||
// Assistant output is untrusted. Markdown constructs still render as HTML,
|
||||
// but raw HTML must remain visible text so it cannot introduce active DOM
|
||||
@@ -472,7 +488,10 @@ const ensureSanitizeHook = (): void => {
|
||||
sanitizeHookInstalled = true;
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
||||
if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return;
|
||||
if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true;
|
||||
// DOMPurify's default URI policy strips custom application schemes
|
||||
// (obsidian://, vscode://, ...). Keep them for anchors; dangerous schemes
|
||||
// stay excluded via isAppLinkUrl and clicks go through confirmation.
|
||||
if (isLocalFileUrl(data.attrValue) || isAppLinkUrl(data.attrValue)) data.forceKeepAttr = true;
|
||||
});
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return;
|
||||
@@ -544,6 +563,29 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
|
||||
live: liveBlockCache.size,
|
||||
});
|
||||
|
||||
/**
|
||||
* Read a settled render synchronously when every block is already in the full
|
||||
* cache. Cache reads retain the existing LRU `get` semantics and do not insert
|
||||
* or expand either cache.
|
||||
*/
|
||||
export const getCachedMarkdownBlocks = (
|
||||
text: string,
|
||||
imageMode: MarkdownImageMode = 'inline',
|
||||
): RenderedBlock[] | null => {
|
||||
if (!text) return [];
|
||||
|
||||
const blocks = streamBlocks(text, false);
|
||||
const rendered: RenderedBlock[] = [];
|
||||
for (const block of blocks) {
|
||||
const contentHash = contentFingerprint(block.raw);
|
||||
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
|
||||
const html = fullBlockCache.get(id);
|
||||
if (html === undefined) return null;
|
||||
rendered.push({ id, html });
|
||||
}
|
||||
return rendered;
|
||||
};
|
||||
|
||||
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = await Promise.resolve(parser.parse(block.src));
|
||||
@@ -561,7 +603,10 @@ const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): P
|
||||
* is synchronous (marked is not configured `async`), so this never blocks on a
|
||||
* worker round-trip.
|
||||
*/
|
||||
export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => {
|
||||
export const renderMarkdownSync = (
|
||||
text: string,
|
||||
imageMode: MarkdownImageMode = 'inline',
|
||||
): string => {
|
||||
if (!text) return '';
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = parser.parse(text) as string;
|
||||
|
||||
@@ -22,6 +22,18 @@ type MermaidViewerController = {
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type InternalMermaidViewerController = MermaidViewerController & {
|
||||
viewport: HTMLElement;
|
||||
fitToViewport: (viewport: MermaidViewport) => void;
|
||||
};
|
||||
|
||||
type MermaidViewerRegistryState = {
|
||||
container: HTMLElement;
|
||||
controllers: Map<HTMLElement, InternalMermaidViewerController>;
|
||||
signatures: Map<HTMLElement, string>;
|
||||
disposed: boolean;
|
||||
};
|
||||
|
||||
type MermaidSvgBoundsSource = {
|
||||
viewBox?: string | null;
|
||||
width?: string | number | null;
|
||||
@@ -36,13 +48,8 @@ type MermaidViewerSignatureSource = MermaidSvgBoundsSource & {
|
||||
const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0;
|
||||
|
||||
const parseSvgNumber = (value: string | number | null | undefined): number | null => {
|
||||
if (typeof value === 'number') {
|
||||
return isPositiveFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const match = value.trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
|
||||
if (value === null || value === undefined) return null;
|
||||
const match = String(value).trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -233,10 +240,14 @@ export const zoomMermaidViewBoxAtPoint = ({
|
||||
};
|
||||
|
||||
const controllerByBlock = new WeakMap<HTMLElement, MermaidViewerController>();
|
||||
|
||||
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => (
|
||||
block instanceof HTMLElement ? controllerByBlock.get(block) ?? null : null
|
||||
);
|
||||
const controllerByViewport = new WeakMap<HTMLElement, InternalMermaidViewerController>();
|
||||
const activeControllers = new Set<InternalMermaidViewerController>();
|
||||
const pendingRegistries = new Set<MermaidViewerRegistryState>();
|
||||
// Controllers are non-essential for the static SVG. Initialize all renderers
|
||||
// from one post-presentation batch so geometry reads precede every SVG write.
|
||||
let sharedResizeObserver: ResizeObserver | null = null;
|
||||
let pendingRegistryFlushFrame: number | null = null;
|
||||
let pendingResizeFrame: number | null = null;
|
||||
|
||||
const getSvgViewport = (block: HTMLElement): HTMLElement | null => (
|
||||
block.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]')
|
||||
@@ -272,7 +283,62 @@ const isPanExcludedTarget = (target: EventTarget | null): boolean => (
|
||||
target instanceof Element && Boolean(target.closest('button, a, [role="button"]'))
|
||||
);
|
||||
|
||||
const createMermaidViewerController = (block: HTMLElement): MermaidViewerController | null => {
|
||||
const fitControllers = (controllers: readonly InternalMermaidViewerController[]): void => {
|
||||
const viewportSizes = controllers.map((controller) => getViewportSize(controller.viewport));
|
||||
controllers.forEach((controller, index) => {
|
||||
const viewport = viewportSizes[index];
|
||||
if (viewport) controller.fitToViewport(viewport);
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleActiveControllerFit = (): void => {
|
||||
if (pendingResizeFrame !== null || activeControllers.size === 0) return;
|
||||
pendingResizeFrame = window.requestAnimationFrame(() => {
|
||||
pendingResizeFrame = null;
|
||||
fitControllers(Array.from(activeControllers));
|
||||
});
|
||||
};
|
||||
|
||||
const ensureSharedResizeObserver = (): ResizeObserver | null => {
|
||||
if (sharedResizeObserver) return sharedResizeObserver;
|
||||
const ResizeObserverConstructor = globalThis.ResizeObserver;
|
||||
if (!ResizeObserverConstructor) return null;
|
||||
sharedResizeObserver = new ResizeObserverConstructor((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!(entry.target instanceof HTMLElement)) continue;
|
||||
controllerByViewport.get(entry.target)?.fitToViewport({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
return sharedResizeObserver;
|
||||
};
|
||||
|
||||
const registerController = (controller: InternalMermaidViewerController): void => {
|
||||
if (activeControllers.has(controller)) return;
|
||||
const wasEmpty = activeControllers.size === 0;
|
||||
activeControllers.add(controller);
|
||||
controllerByViewport.set(controller.viewport, controller);
|
||||
ensureSharedResizeObserver()?.observe(controller.viewport);
|
||||
if (wasEmpty) window.addEventListener('resize', scheduleActiveControllerFit);
|
||||
};
|
||||
|
||||
const unregisterController = (controller: InternalMermaidViewerController): void => {
|
||||
if (!activeControllers.delete(controller)) return;
|
||||
sharedResizeObserver?.unobserve(controller.viewport);
|
||||
controllerByViewport.delete(controller.viewport);
|
||||
if (activeControllers.size > 0) return;
|
||||
sharedResizeObserver?.disconnect();
|
||||
sharedResizeObserver = null;
|
||||
window.removeEventListener('resize', scheduleActiveControllerFit);
|
||||
if (pendingResizeFrame !== null) {
|
||||
window.cancelAnimationFrame(pendingResizeFrame);
|
||||
pendingResizeFrame = null;
|
||||
}
|
||||
};
|
||||
|
||||
const createMermaidViewerController = (block: HTMLElement): InternalMermaidViewerController | null => {
|
||||
const viewport = getSvgViewport(block);
|
||||
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
|
||||
if (!viewport || !svg) {
|
||||
@@ -301,8 +367,12 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
|
||||
svg.removeAttribute('height');
|
||||
};
|
||||
|
||||
const fitToViewport = (size: MermaidViewport): void => {
|
||||
applyViewBox(fitMermaidViewBox(contentBox, size));
|
||||
};
|
||||
|
||||
const fit = (): void => {
|
||||
applyViewBox(fitMermaidViewBox(contentBox, getViewportSize(viewport)));
|
||||
fitToViewport(getViewportSize(viewport));
|
||||
};
|
||||
|
||||
const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => {
|
||||
@@ -390,32 +460,24 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
|
||||
}
|
||||
};
|
||||
|
||||
const onResize = (): void => {
|
||||
fit();
|
||||
};
|
||||
|
||||
viewport.addEventListener('wheel', onWheel, { passive: false });
|
||||
viewport.addEventListener('pointerdown', onPointerDown);
|
||||
viewport.addEventListener('pointermove', onPointerMove);
|
||||
viewport.addEventListener('pointerup', stopPan);
|
||||
viewport.addEventListener('pointercancel', stopPan);
|
||||
window.addEventListener('resize', onResize);
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
|
||||
observer?.observe(viewport);
|
||||
fit();
|
||||
|
||||
return {
|
||||
const controller: InternalMermaidViewerController = {
|
||||
viewport,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
fit,
|
||||
fitToViewport,
|
||||
cleanup: () => {
|
||||
unregisterController(controller);
|
||||
viewport.removeEventListener('wheel', onWheel);
|
||||
viewport.removeEventListener('pointerdown', onPointerDown);
|
||||
viewport.removeEventListener('pointermove', onPointerMove);
|
||||
viewport.removeEventListener('pointerup', stopPan);
|
||||
viewport.removeEventListener('pointercancel', stopPan);
|
||||
window.removeEventListener('resize', onResize);
|
||||
observer?.disconnect();
|
||||
if (clearClickSuppressionTimer !== null) {
|
||||
window.clearTimeout(clearClickSuppressionTimer);
|
||||
}
|
||||
@@ -424,42 +486,97 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
|
||||
controllerByBlock.delete(block);
|
||||
},
|
||||
};
|
||||
return controller;
|
||||
};
|
||||
|
||||
export const createMermaidViewerRegistry = (container: HTMLElement): { refresh: () => void; cleanup: () => void } => {
|
||||
const controllers = new Map<HTMLElement, MermaidViewerController>();
|
||||
const signatures = new Map<HTMLElement, string>();
|
||||
|
||||
const refresh = (): void => {
|
||||
for (const [block, controller] of Array.from(controllers.entries())) {
|
||||
const signature = getBlockViewerSignature(block);
|
||||
if (!container.contains(block) || signature !== signatures.get(block)) {
|
||||
controller.cleanup();
|
||||
controllers.delete(block);
|
||||
signatures.delete(block);
|
||||
}
|
||||
const removeStaleControllers = (state: MermaidViewerRegistryState): void => {
|
||||
for (const [block, controller] of state.controllers) {
|
||||
const signature = getBlockViewerSignature(block);
|
||||
if (!state.container.contains(block) || signature !== state.signatures.get(block)) {
|
||||
controller.cleanup();
|
||||
state.controllers.delete(block);
|
||||
state.signatures.delete(block);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const block of Array.from(container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
|
||||
if (controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) {
|
||||
continue;
|
||||
}
|
||||
const controller = createMermaidViewerController(block);
|
||||
if (!controller) {
|
||||
continue;
|
||||
}
|
||||
controllers.set(block, controller);
|
||||
signatures.set(block, getBlockViewerSignature(block));
|
||||
controllerByBlock.set(block, controller);
|
||||
}
|
||||
const collectNewControllers = (state: MermaidViewerRegistryState): InternalMermaidViewerController[] => {
|
||||
if (state.disposed) return [];
|
||||
const newControllers: InternalMermaidViewerController[] = [];
|
||||
for (const block of Array.from(state.container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
|
||||
if (state.controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) continue;
|
||||
const controller = createMermaidViewerController(block);
|
||||
if (!controller) continue;
|
||||
state.controllers.set(block, controller);
|
||||
state.signatures.set(block, getBlockViewerSignature(block));
|
||||
controllerByBlock.set(block, controller);
|
||||
newControllers.push(controller);
|
||||
}
|
||||
return newControllers;
|
||||
};
|
||||
|
||||
const flushPendingRegistries = (): void => {
|
||||
const registries = Array.from(pendingRegistries);
|
||||
pendingRegistries.clear();
|
||||
const newControllers: InternalMermaidViewerController[] = [];
|
||||
for (const state of registries) {
|
||||
if (state.disposed) continue;
|
||||
removeStaleControllers(state);
|
||||
newControllers.push(...collectNewControllers(state));
|
||||
}
|
||||
fitControllers(newControllers);
|
||||
for (const controller of newControllers) registerController(controller);
|
||||
};
|
||||
|
||||
const schedulePendingRegistryFlush = (): void => {
|
||||
if (pendingRegistryFlushFrame !== null) return;
|
||||
pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
|
||||
pendingRegistryFlushFrame = null;
|
||||
pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
|
||||
pendingRegistryFlushFrame = null;
|
||||
flushPendingRegistries();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleRegistryRefresh = (state: MermaidViewerRegistryState): void => {
|
||||
if (state.disposed) return;
|
||||
removeStaleControllers(state);
|
||||
pendingRegistries.add(state);
|
||||
schedulePendingRegistryFlush();
|
||||
};
|
||||
|
||||
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => {
|
||||
if (!(block instanceof HTMLElement)) return null;
|
||||
const existing = controllerByBlock.get(block);
|
||||
if (existing) return existing;
|
||||
|
||||
for (const state of pendingRegistries) {
|
||||
if (!state.container.contains(block)) continue;
|
||||
flushPendingRegistries();
|
||||
return controllerByBlock.get(block) ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const createMermaidViewerRegistry = (container: HTMLElement) => {
|
||||
const state: MermaidViewerRegistryState = {
|
||||
container,
|
||||
controllers: new Map(),
|
||||
signatures: new Map(),
|
||||
disposed: false,
|
||||
};
|
||||
|
||||
const refresh = (): void => scheduleRegistryRefresh(state);
|
||||
|
||||
const cleanup = (): void => {
|
||||
for (const controller of controllers.values()) {
|
||||
state.disposed = true;
|
||||
pendingRegistries.delete(state);
|
||||
for (const controller of state.controllers.values()) {
|
||||
controller.cleanup();
|
||||
}
|
||||
controllers.clear();
|
||||
signatures.clear();
|
||||
state.controllers.clear();
|
||||
state.signatures.clear();
|
||||
};
|
||||
|
||||
refresh();
|
||||
|
||||
@@ -19,7 +19,6 @@ import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialo
|
||||
import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
|
||||
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
@@ -419,20 +418,16 @@ interface MessageBodyProps {
|
||||
onShowPopup: (content: ToolPopupContent) => void;
|
||||
streamPhase: StreamPhase;
|
||||
allowAnimation: boolean;
|
||||
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
|
||||
|
||||
shouldShowHeader?: boolean;
|
||||
hasTextContent?: boolean;
|
||||
onCopyMessage?: () => void | boolean | Promise<void | boolean>;
|
||||
copiedMessage?: boolean;
|
||||
onAuxiliaryContentComplete?: () => void;
|
||||
showReasoningTraces?: boolean;
|
||||
agentMention?: AgentMentionInfo;
|
||||
turnGroupingContext?: TurnGroupingContext;
|
||||
onRevert?: () => void;
|
||||
onFork?: () => void;
|
||||
errorMessage?: string;
|
||||
errorVariant?: 'error' | 'info';
|
||||
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
|
||||
stickyUserHeaderEnabled?: boolean;
|
||||
reviewTransferDirection?: ReviewTransferDirection | null;
|
||||
@@ -490,6 +485,20 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
||||
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
||||
|
||||
// One expanded state for the whole message: text parts and context cards
|
||||
// collapse and expand together, with a single collapse control up here
|
||||
// instead of one per part.
|
||||
const collapsibleUserMessages = useUIStore((state) => state.collapsibleUserMessages);
|
||||
const [messageExpanded, setMessageExpanded] = React.useState(false);
|
||||
const expandMessage = React.useCallback(() => setMessageExpanded(true), []);
|
||||
const collapseMessage = React.useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setMessageExpanded(false);
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!collapsibleUserMessages) setMessageExpanded(false);
|
||||
}, [collapsibleUserMessages]);
|
||||
|
||||
const userContentParts = React.useMemo(() => {
|
||||
return parts.filter((part) => {
|
||||
if (part.type === 'text') {
|
||||
@@ -567,7 +576,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
const formatted = formatTimestampForDisplay(messageCreatedAt, timeFormatPreference);
|
||||
return formatted.length > 0 ? formatted : null;
|
||||
}, [locale, messageCreatedAt, timeFormatPreference]);
|
||||
const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
|
||||
const actionsBlock = chatSurfaceMode !== 'peek' && ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
|
||||
<div className={cn(
|
||||
'group/user-actions',
|
||||
isMobile
|
||||
@@ -717,6 +726,16 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
style={CONTAIN_LAYOUT_STYLE}
|
||||
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
||||
>
|
||||
{collapsibleUserMessages && messageExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={collapseMessage}
|
||||
className="absolute top-0 right-0 z-10 flex items-center justify-center rounded-sm bg-[var(--surface-elevated)] p-0.5 text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
|
||||
aria-label={t('chat.message.userText.collapseAria')}
|
||||
>
|
||||
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'leading-relaxed text-foreground/90 text-base overflow-x-hidden',
|
||||
@@ -726,10 +745,13 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
)}
|
||||
style={useStickyScrollableUserContent ? { maxHeight: 'calc(var(--chat-scroll-height, 100dvh) * 0.4)' } : undefined}
|
||||
>
|
||||
{/* Positional keys, not part ids: the server echo of a just-sent
|
||||
message swaps the optimistic part id, and id-based keys would
|
||||
remount the text subtree (blank frame + height jump). */}
|
||||
{userContentParts.map((part, index) => {
|
||||
if (isSubtaskPart(part)) {
|
||||
return (
|
||||
<React.Fragment key={part.id ?? `user-subtask-${index}`}>
|
||||
<React.Fragment key={`user-subtask-${index}`}>
|
||||
<UserSubtaskPart part={part} />
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -737,7 +759,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
|
||||
if (isShellActionPart(part)) {
|
||||
return (
|
||||
<React.Fragment key={part.id ?? `user-shell-${index}`}>
|
||||
<React.Fragment key={`user-shell-${index}`}>
|
||||
<UserShellActionPart part={part} />
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -752,12 +774,14 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
}
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={part.id ?? `user-text-${index}`}>
|
||||
<React.Fragment key={`user-text-${index}`}>
|
||||
<UserTextPart
|
||||
part={part}
|
||||
messageId={messageId}
|
||||
isMobile={isMobile}
|
||||
agentMention={mentionForPart}
|
||||
messageExpanded={messageExpanded}
|
||||
onExpandMessage={expandMessage}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -1084,14 +1108,11 @@ const AssistantMessageBody = React.memo(({
|
||||
onShowPopup,
|
||||
streamPhase: _streamPhase,
|
||||
allowAnimation: _allowAnimation,
|
||||
onContentChange,
|
||||
hasTextContent = false,
|
||||
onCopyMessage,
|
||||
onAuxiliaryContentComplete,
|
||||
showReasoningTraces = false,
|
||||
turnGroupingContext,
|
||||
errorMessage,
|
||||
errorVariant = 'error',
|
||||
reviewTransferDirection = null,
|
||||
contextPinned,
|
||||
contextPinPending,
|
||||
@@ -1322,16 +1343,6 @@ const AssistantMessageBody = React.memo(({
|
||||
return resolved ? { id: resolved.id, path: resolved.path } : null;
|
||||
}, [availableWorktreesByProject, canUseProjectPlanActions, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]);
|
||||
|
||||
const hasTools = toolParts.length > 0;
|
||||
|
||||
const hasPendingTools = React.useMemo(() => {
|
||||
return toolParts.some((toolPart) => {
|
||||
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
|
||||
const status = state?.status;
|
||||
return status === 'pending' || status === 'running' || status === 'started';
|
||||
});
|
||||
}, [toolParts]);
|
||||
|
||||
const isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => {
|
||||
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
|
||||
const status = state?.status;
|
||||
@@ -1360,86 +1371,6 @@ const AssistantMessageBody = React.memo(({
|
||||
return isActiveTool(toolPart) || isToolFinalized(toolPart);
|
||||
}, [isActiveTool, isToolFinalized]);
|
||||
|
||||
const allToolsFinalized = React.useMemo(() => {
|
||||
if (toolParts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (hasPendingTools) {
|
||||
return false;
|
||||
}
|
||||
return toolParts.every((toolPart) => isToolFinalized(toolPart));
|
||||
}, [toolParts, hasPendingTools, isToolFinalized]);
|
||||
|
||||
const reasoningParts = React.useMemo(() => {
|
||||
return visibleParts.filter((part) => part.type === 'reasoning');
|
||||
}, [visibleParts]);
|
||||
|
||||
const reasoningComplete = React.useMemo(() => {
|
||||
if (reasoningParts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return reasoningParts.every((part) => {
|
||||
const time = (part as Record<string, unknown>).time as { end?: number } | undefined;
|
||||
return typeof time?.end === 'number';
|
||||
});
|
||||
}, [reasoningParts]);
|
||||
|
||||
// Message is considered to have an "open step" if info.finish is not yet present
|
||||
const hasOpenStep = typeof messageFinish !== 'string';
|
||||
|
||||
const shouldHoldForReasoning =
|
||||
reasoningParts.length > 0 &&
|
||||
hasTools &&
|
||||
(hasPendingTools || hasOpenStep || !allToolsFinalized);
|
||||
|
||||
const shouldHoldTools = awaitingMessageCompletion
|
||||
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
|
||||
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
|
||||
|
||||
const hasAuxiliaryContent = hasTools || reasoningParts.length > 0;
|
||||
const isTextlessAssistantMessage = assistantTextParts.length === 0;
|
||||
const auxiliaryContentComplete = hasAuxiliaryContent && isTextlessAssistantMessage && !shouldHoldTools && !shouldHoldReasoning && allToolsFinalized && reasoningComplete;
|
||||
const auxiliaryCompletionAnnouncedRef = React.useRef(false);
|
||||
const soloReasoningScrollTriggeredRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
soloReasoningScrollTriggeredRef.current = false;
|
||||
}, [messageId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!auxiliaryContentComplete) {
|
||||
auxiliaryCompletionAnnouncedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (auxiliaryCompletionAnnouncedRef.current) {
|
||||
return;
|
||||
}
|
||||
auxiliaryCompletionAnnouncedRef.current = true;
|
||||
onAuxiliaryContentComplete?.();
|
||||
}, [auxiliaryContentComplete, onAuxiliaryContentComplete]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (awaitingMessageCompletion) {
|
||||
soloReasoningScrollTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (hasTools) {
|
||||
soloReasoningScrollTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (reasoningParts.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (shouldHoldReasoning || !reasoningComplete) {
|
||||
return;
|
||||
}
|
||||
if (soloReasoningScrollTriggeredRef.current) {
|
||||
return;
|
||||
}
|
||||
soloReasoningScrollTriggeredRef.current = true;
|
||||
onContentChange?.('structural');
|
||||
}, [awaitingMessageCompletion, hasTools, onContentChange, reasoningComplete, reasoningParts.length, shouldHoldReasoning]);
|
||||
|
||||
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
|
||||
|
||||
const handleForkClick = React.useCallback(
|
||||
@@ -1701,9 +1632,9 @@ const AssistantMessageBody = React.memo(({
|
||||
|
||||
const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish;
|
||||
const showErrorMessage = Boolean(errorMessage);
|
||||
const errorIconName = errorVariant === 'info' ? 'information' : 'error-warning';
|
||||
const shouldShowMessageActions = hasCopyableText;
|
||||
const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
|
||||
const isPeekSurface = chatSurfaceMode === 'peek';
|
||||
const shouldShowMessageActions = hasCopyableText && !isPeekSurface;
|
||||
const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)) && !isPeekSurface;
|
||||
const shouldRenderActionsInActivity = isSortedRenderMode;
|
||||
const shouldShowStandaloneMessageActions = showSplitAssistantMessageActions && shouldShowMessageActions && !shouldShowTurnFooter && !shouldRenderActionsInActivity;
|
||||
|
||||
@@ -1794,7 +1725,6 @@ const AssistantMessageBody = React.memo(({
|
||||
expandedTools={expandedTools}
|
||||
onToggleTool={onToggleTool}
|
||||
onShowPopup={onShowPopup}
|
||||
onContentChange={onContentChange}
|
||||
streamPhase={effectiveStreamPhase}
|
||||
showHeader={true}
|
||||
animateRows={animateActivityRows}
|
||||
@@ -1871,7 +1801,6 @@ const AssistantMessageBody = React.memo(({
|
||||
messageId={messageId}
|
||||
streamPhase={effectiveStreamPhase}
|
||||
chatRenderMode={chatRenderMode}
|
||||
onContentChange={onContentChange}
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
</div>
|
||||
@@ -1906,7 +1835,6 @@ const AssistantMessageBody = React.memo(({
|
||||
messageId={messageId}
|
||||
streamPhase={effectiveStreamPhase}
|
||||
chatRenderMode={chatRenderMode}
|
||||
onContentChange={onContentChange}
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
);
|
||||
@@ -1918,7 +1846,6 @@ const AssistantMessageBody = React.memo(({
|
||||
part={part}
|
||||
messageId={messageId}
|
||||
streamPhase={effectiveStreamPhase}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1962,7 +1889,6 @@ const AssistantMessageBody = React.memo(({
|
||||
onToggle={onToggleTool}
|
||||
isMobile={isMobile}
|
||||
alwaysShowActions={alwaysShowMessageActions}
|
||||
onContentChange={onContentChange}
|
||||
onShowPopup={onShowPopup}
|
||||
animateTailText={animatedToolIdsLookup.has(toolPart.id)}
|
||||
/>
|
||||
@@ -2034,7 +1960,6 @@ const AssistantMessageBody = React.memo(({
|
||||
messageActionButtons,
|
||||
renderJustificationActions,
|
||||
sessionId,
|
||||
onContentChange,
|
||||
onShowPopup,
|
||||
onToggleTool,
|
||||
shouldRenderActivityGroup,
|
||||
@@ -2213,17 +2138,9 @@ const AssistantMessageBody = React.memo(({
|
||||
{renderedParts}
|
||||
{showErrorMessage && (
|
||||
<FadeInOnReveal key="assistant-error">
|
||||
<div className={cn(
|
||||
'group/assistant-text relative mt-3 p-3 rounded-lg border break-words max-w-full',
|
||||
errorVariant === 'info'
|
||||
? 'bg-[var(--status-info-background)] border-[var(--status-info-border)]'
|
||||
: 'bg-[var(--status-error-background)] border-[var(--status-error-border)]',
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name={errorIconName} className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
errorVariant === 'info' ? 'text-[var(--status-info)]' : 'text-[var(--status-error)]',
|
||||
)} />
|
||||
<div className="group/assistant-text relative mt-3 max-w-full break-words rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-background)] px-4 py-3 text-base leading-relaxed">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon name="information" className="size-4 shrink-0 text-[var(--status-info)]" />
|
||||
<div className="min-w-0 flex-1 break-words">
|
||||
<SimpleMarkdownRenderer
|
||||
content={errorMessage ?? ''}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSessions } from '@/sync/sync-context';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi';
|
||||
@@ -18,6 +18,8 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
|
||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||
import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat';
|
||||
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
|
||||
|
||||
interface TextSelectionMenuProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
@@ -33,6 +35,8 @@ interface SelectionPayload {
|
||||
plainText: string;
|
||||
markdownText: string;
|
||||
rect: DOMRect;
|
||||
messageId: string | null;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
const normalizeDistilledInsight = (insight: string): string => (
|
||||
@@ -46,6 +50,54 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||
const [selectedText, setSelectedText] = React.useState('');
|
||||
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
|
||||
const [selectedMessageId, setSelectedMessageId] = React.useState<string | null>(null);
|
||||
const [commentMode, setCommentMode] = React.useState(false);
|
||||
const commentModeRef = React.useRef(false);
|
||||
const [commentText, setCommentText] = React.useState('');
|
||||
const commentInputRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// While the comment input owns focus the native selection is gone, so the
|
||||
// quoted fragment is repainted with our own overlay rectangles. Raw
|
||||
// Range.getClientRects() mixes block-container boxes with text boxes and
|
||||
// the translucent overlaps paint double-dark bands, so the rects are taken
|
||||
// from the text nodes only and merged into one strip per visual line.
|
||||
const [commentRects, setCommentRects] = React.useState<DOMRect[] | null>(null);
|
||||
const updateCommentRects = React.useCallback(() => {
|
||||
const range = pendingSelectionRef.current?.range;
|
||||
if (!range) {
|
||||
setCommentRects(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setCommentRects(collectSelectionOverlayRects(range));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!commentMode) return;
|
||||
let frame: number | null = null;
|
||||
const scheduleUpdate = () => {
|
||||
if (frame !== null) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
updateCommentRects();
|
||||
});
|
||||
};
|
||||
document.addEventListener('scroll', scheduleUpdate, { capture: true, passive: true });
|
||||
window.addEventListener('resize', scheduleUpdate);
|
||||
return () => {
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
document.removeEventListener('scroll', scheduleUpdate, { capture: true });
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
};
|
||||
}, [commentMode, updateCommentRects]);
|
||||
|
||||
// Grow the comment box with its content, up to five lines.
|
||||
const resizeCommentInput = React.useCallback(() => {
|
||||
const element = commentInputRef.current;
|
||||
if (!element) return;
|
||||
element.style.height = 'auto';
|
||||
element.style.height = `${Math.min(element.scrollHeight, 120)}px`;
|
||||
}, []);
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const [isOpening, setIsOpening] = React.useState(false);
|
||||
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
|
||||
@@ -55,8 +107,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const openRafRef = React.useRef<number | null>(null);
|
||||
const mouseUpTimeoutRef = React.useRef<number | null>(null);
|
||||
const isMenuVisibleRef = React.useRef(false);
|
||||
const createSession = useSessionUIStore((state) => state.createSession);
|
||||
const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
@@ -64,12 +118,47 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const sessions = useSessions();
|
||||
|
||||
// Mobile: the comment bar is rendered inside the composer form (its
|
||||
// positioning context), so it inherits the runtime's own keyboard handling
|
||||
// — browser viewport resizing and Capacitor choreography alike. This effect
|
||||
// only centers it on the composer pill in the form's local coordinates; no
|
||||
// viewport math, which Safari's keyboard handling reliably breaks for
|
||||
// fixed elements.
|
||||
React.useEffect(() => {
|
||||
if (!commentMode || !isMobile) return;
|
||||
const update = () => {
|
||||
const element = menuRef.current;
|
||||
const host = element?.offsetParent;
|
||||
if (!element || !host) return;
|
||||
const pill = document.querySelector('[data-mobile-composer-pill="true"]')
|
||||
?? document.querySelector('[data-chat-input="true"]');
|
||||
const pillRect = pill?.getBoundingClientRect();
|
||||
if (!pillRect || pillRect.height <= 0) return;
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
element.style.top = `${pillRect.top - hostRect.top + (pillRect.height - element.offsetHeight) / 2}px`;
|
||||
element.style.left = `${pillRect.left - hostRect.left}px`;
|
||||
element.style.width = `${pillRect.width}px`;
|
||||
element.style.bottom = 'auto';
|
||||
};
|
||||
update();
|
||||
const raf = window.requestAnimationFrame(update);
|
||||
// The composer relayouts with its own transitions and timeouts that emit
|
||||
// no event; a light poll keeps the overlay glued to the pill.
|
||||
const poll = window.setInterval(update, 200);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
window.clearInterval(poll);
|
||||
};
|
||||
}, [commentMode, isMobile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
isMenuVisibleRef.current = position.show;
|
||||
}, [position.show]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
activeAddToChatCleanupRef.current?.();
|
||||
activeAddToChatCleanupRef.current = null;
|
||||
if (openRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(openRafRef.current);
|
||||
openRafRef.current = null;
|
||||
@@ -83,6 +172,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
const hideMenu = React.useCallback(() => {
|
||||
pendingSelectionRef.current = null;
|
||||
activeAddToChatCleanupRef.current?.();
|
||||
activeAddToChatCleanupRef.current = null;
|
||||
setCommentRects(null);
|
||||
|
||||
if (!isMenuVisibleRef.current) {
|
||||
return;
|
||||
@@ -97,6 +189,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
setPosition((prev) => ({ ...prev, show: false }));
|
||||
setSelectedText('');
|
||||
setSelectedTextMarkdown('');
|
||||
setSelectedMessageId(null);
|
||||
setCommentMode(false);
|
||||
commentModeRef.current = false;
|
||||
setCommentText('');
|
||||
isMenuVisibleRef.current = false;
|
||||
}, []);
|
||||
|
||||
@@ -118,12 +214,30 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
return Math.min(Math.max(anchorX, minX), maxX);
|
||||
}, []);
|
||||
|
||||
const addMarkdownToChat = React.useCallback((markdownText: string) => {
|
||||
const markdownBlock = wrapMarkdownSelectionForChat(markdownText);
|
||||
setPendingInputText(markdownBlock, 'append');
|
||||
|
||||
hideMenu();
|
||||
|
||||
window.getSelection()?.removeAllRanges();
|
||||
queueMicrotask(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
}, [hideMenu, setPendingInputText]);
|
||||
|
||||
const showMenu = React.useCallback(() => {
|
||||
if (!pendingSelectionRef.current) return;
|
||||
|
||||
const { plainText, markdownText, rect } = pendingSelectionRef.current;
|
||||
const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current;
|
||||
const shouldAnimateIn = !position.show;
|
||||
|
||||
activeAddToChatCleanupRef.current?.();
|
||||
activeAddToChatCleanupRef.current = registerActiveSelectionToolbar({
|
||||
addToChat: () => addMarkdownToChat(markdownText),
|
||||
dismiss: hideMenu,
|
||||
});
|
||||
|
||||
// Position menu above the selection
|
||||
const menuX = isMobile
|
||||
? rect.left + rect.width / 2
|
||||
@@ -132,6 +246,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
setSelectedText(plainText);
|
||||
setSelectedTextMarkdown(markdownText);
|
||||
setSelectedMessageId(messageId);
|
||||
setPosition({
|
||||
x: menuX,
|
||||
y: menuY,
|
||||
@@ -149,7 +264,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
openRafRef.current = null;
|
||||
});
|
||||
}
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
}, [addMarkdownToChat, getDesktopClampedX, hideMenu, isMobile, position.show]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!position.show || isMobile || !menuRef.current) {
|
||||
@@ -168,6 +283,25 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
}));
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
|
||||
// The desktop popup hangs above its anchor, so a tall comment box near the
|
||||
// top of the chat can climb over the app header. On the desktop shell the
|
||||
// header is a window drag zone, which makes the overlapped part of the
|
||||
// textarea untouchable, so the popup is pushed down until its top edge stays
|
||||
// inside the chat container.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!position.show || isMobile || !menuRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const minTop = (container ? container.getBoundingClientRect().top : 0) + 4;
|
||||
const menuTop = menuRef.current.getBoundingClientRect().top;
|
||||
if (menuTop < minTop) {
|
||||
const delta = minTop - menuTop;
|
||||
setPosition((prev) => ({ ...prev, y: prev.y + delta }));
|
||||
}
|
||||
}, [containerRef, isMobile, position.show, position.y, commentMode, commentText]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!position.show || isMobile) {
|
||||
return;
|
||||
@@ -187,6 +321,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
|
||||
const handleSelectionChange = React.useCallback(() => {
|
||||
// While the comment input is open, clicking or typing in it collapses the
|
||||
// text selection; the captured quote must survive that.
|
||||
if (commentModeRef.current) {
|
||||
return;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
const container = containerRef.current;
|
||||
|
||||
@@ -221,10 +360,15 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Store the selection but don't show menu yet if dragging
|
||||
const anchorElement = range.commonAncestorContainer instanceof Element
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
pendingSelectionRef.current = {
|
||||
plainText: text,
|
||||
markdownText: rangeToMarkdown(range, text),
|
||||
rect,
|
||||
messageId: anchorElement?.closest('[data-message-id]')?.getAttribute('data-message-id') ?? null,
|
||||
range: range.cloneRange(),
|
||||
};
|
||||
|
||||
// Only show menu if we're not currently dragging
|
||||
@@ -238,7 +382,12 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
if (!container) return;
|
||||
|
||||
// Track when dragging starts
|
||||
const handleMouseDown = () => {
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
// SAFETY: a MouseEvent target inside the document is always a Node;
|
||||
// `contains` only needs that.
|
||||
if (commentModeRef.current && menuRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
isDraggingRef.current = true;
|
||||
hideMenu();
|
||||
};
|
||||
@@ -254,6 +403,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
// Small delay to ensure selection is finalized
|
||||
mouseUpTimeoutRef.current = window.setTimeout(() => {
|
||||
mouseUpTimeoutRef.current = null;
|
||||
// The click that opened the comment input cleared the selection on
|
||||
// purpose; the input must survive this deferred check.
|
||||
if (commentModeRef.current) {
|
||||
return;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().trim()) {
|
||||
showMenu();
|
||||
@@ -275,7 +429,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(e.target as Node) &&
|
||||
!window.getSelection()?.toString().trim()
|
||||
(commentModeRef.current || !window.getSelection()?.toString().trim())
|
||||
) {
|
||||
hideMenu();
|
||||
}
|
||||
@@ -297,42 +451,40 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
const handleAddToChat = React.useCallback(() => {
|
||||
if (!selectedTextMarkdown) return;
|
||||
addMarkdownToChat(selectedTextMarkdown);
|
||||
}, [addMarkdownToChat, selectedTextMarkdown]);
|
||||
|
||||
const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown);
|
||||
setPendingInputText(markdownBlock, 'append');
|
||||
|
||||
hideMenu();
|
||||
|
||||
// Clear selection
|
||||
const handleOpenComment = React.useCallback(() => {
|
||||
if (!selectedTextMarkdown) return;
|
||||
setCommentMode(true);
|
||||
commentModeRef.current = true;
|
||||
updateCommentRects();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
queueMicrotask(() => {
|
||||
commentInputRef.current?.focus();
|
||||
});
|
||||
}, [selectedTextMarkdown, updateCommentRects]);
|
||||
|
||||
const handleAttachComment = React.useCallback(() => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
if (!selectedTextMarkdown || !sessionKey || !effectiveDirectory) {
|
||||
hideMenu();
|
||||
return;
|
||||
}
|
||||
addContextDraft({ directory: effectiveDirectory, sessionKey }, {
|
||||
source: 'chat-quote',
|
||||
fileLabel: selectedMessageId ?? '',
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
code: selectedTextMarkdown,
|
||||
language: '',
|
||||
text: commentText.trim(),
|
||||
});
|
||||
hideMenu();
|
||||
queueMicrotask(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
|
||||
|
||||
const handleCreateNewSession = React.useCallback(async () => {
|
||||
if (!selectedText) return;
|
||||
|
||||
const session = await createSession(undefined, null, null);
|
||||
if (session) {
|
||||
setPendingInputText(selectedText, 'replace');
|
||||
}
|
||||
|
||||
hideMenu();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}, [selectedText, createSession, setPendingInputText, hideMenu]);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
if (!selectedText) return;
|
||||
|
||||
const result = await copyTextToClipboard(selectedText);
|
||||
if (!result.ok) {
|
||||
console.error('Failed to copy:', result.error);
|
||||
}
|
||||
|
||||
hideMenu();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}, [selectedText, hideMenu]);
|
||||
}, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]);
|
||||
|
||||
const currentSession = React.useMemo(() => {
|
||||
if (!currentSessionId) {
|
||||
@@ -390,15 +542,110 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
if (!position.show) return null;
|
||||
|
||||
const commentHighlightOverlay = commentMode && commentRects && commentRects.length > 0
|
||||
? createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[5]">
|
||||
{commentRects.map((rect, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="oc-chat-comment-rect absolute"
|
||||
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
const commentInput = (
|
||||
<div
|
||||
className={cn(
|
||||
'oc-glass-popover flex items-end gap-2 rounded-3xl border border-[var(--interactive-border)]',
|
||||
'pl-4 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
|
||||
'py-1 pr-1',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={commentInputRef}
|
||||
rows={1}
|
||||
value={commentText}
|
||||
onChange={(event) => {
|
||||
setCommentText(event.target.value);
|
||||
resizeCommentInput();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// Desktop: Enter attaches, Shift+Enter breaks the line. Mobile
|
||||
// keyboards use Enter for line breaks; attaching is the button's job.
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isMobile) {
|
||||
event.preventDefault();
|
||||
handleAttachComment();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
hideMenu();
|
||||
}
|
||||
}}
|
||||
placeholder={t('chat.textSelection.comment.placeholder')}
|
||||
className={cn(
|
||||
'flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60',
|
||||
// The width cap sizes the floating desktop pill; on mobile the pill
|
||||
// spans the bottom bar and the cap would strand slack space to the
|
||||
// right of the attach button.
|
||||
isMobile ? 'w-full min-w-0 py-1.5 text-base leading-6' : 'w-64 max-w-[70vw] py-1.5'
|
||||
)}
|
||||
style={{ minHeight: 0, height: 'auto' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAttachComment}
|
||||
className={cn(
|
||||
'mb-0.5 flex shrink-0 items-center justify-center rounded-full bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity duration-150',
|
||||
isMobile ? 'h-9 w-9' : 'h-8 w-8'
|
||||
)}
|
||||
aria-label={t('chat.textSelection.comment.attach')}
|
||||
title={t('chat.textSelection.comment.attach')}
|
||||
>
|
||||
<Icon name="attachment-2" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Mobile: Show as a bar at the bottom of the screen, above the keyboard
|
||||
if (isMobile) {
|
||||
if (commentMode) {
|
||||
// Overlay the comment input onto the composer pill: rendering into the
|
||||
// composer form (position: relative) inherits the runtime's keyboard
|
||||
// handling in both browser and Capacitor; the centering effect above
|
||||
// glues it to the pill in the form's local coordinates.
|
||||
const composerHost = document.querySelector('form.oc-mobile-composer');
|
||||
const bar = (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
'z-50',
|
||||
composerHost
|
||||
? 'absolute inset-x-0 bottom-[var(--oc-safe-area-bottom-visual,0.5rem)]'
|
||||
: 'oc-chat-comment-bar fixed left-3 right-3 mx-auto max-w-[420px]',
|
||||
)}
|
||||
>
|
||||
{commentInput}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{commentHighlightOverlay}
|
||||
{createPortal(bar, composerHost ?? document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
'fixed left-3 right-3 bottom-0 z-50 mx-auto max-w-[420px]',
|
||||
'rounded-2xl border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] p-2 shadow-lg',
|
||||
'oc-glass-popover rounded-2xl border border-[var(--interactive-border)]',
|
||||
'p-2 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
|
||||
'safe-area-bottom',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
@@ -408,6 +655,22 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={handleOpenComment}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
||||
'text-sm font-medium leading-tight',
|
||||
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
|
||||
'active:opacity-80',
|
||||
'transition-opacity duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.commentOnSelection')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="chat-1" className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.comment')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
className={cn(
|
||||
@@ -421,39 +684,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToChat')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCreateNewSession}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
||||
'text-sm font-medium leading-tight',
|
||||
'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]',
|
||||
'active:opacity-80',
|
||||
'transition-opacity duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="chat-new" className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
||||
'text-sm font-medium leading-tight',
|
||||
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
|
||||
'active:opacity-80',
|
||||
'transition-opacity duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.actions.copy')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="file-copy" className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.copy')}</span>
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
|
||||
</button>
|
||||
|
||||
{!isVSCodeRuntime() ? (
|
||||
@@ -484,80 +715,64 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50"
|
||||
className="app-region-no-drag fixed z-50"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 whitespace-nowrap',
|
||||
'rounded-lg border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] shadow-none',
|
||||
'px-1.5 py-1',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
{commentMode ? (<>{commentHighlightOverlay}{commentInput}</>) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
'flex items-center whitespace-nowrap',
|
||||
'oc-glass-popover rounded-full border border-[var(--interactive-border)]',
|
||||
'shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
|
||||
'p-1',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
title={t('chat.textSelection.title.addToCurrentChat')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" className="h-4 w-4" />
|
||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToChat')}</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleCreateNewSession}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="chat-new" className="h-4 w-4" />
|
||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.newSession')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenComment}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.commentOnSelection')}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.textSelection.actions.comment')}
|
||||
</button>
|
||||
|
||||
{!isVSCodeRuntime() ? (
|
||||
<>
|
||||
<div className="w-px h-4 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleAddToNotes}
|
||||
disabled={isAddingToNotes}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.saveInsightToNotes')}
|
||||
type="button"
|
||||
>
|
||||
{isAddingToNotes ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : <Icon name="booklet" className="h-4 w-4" />}
|
||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{!isVSCodeRuntime() ? (
|
||||
<>
|
||||
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleAddToNotes}
|
||||
disabled={isAddingToNotes}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.saveInsightToNotes')}
|
||||
type="button"
|
||||
>
|
||||
{isAddingToNotes ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : null}
|
||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { readContextPart } from '@/lib/messages/contextParts';
|
||||
|
||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
@@ -96,6 +97,7 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
const synthetic = (part as { synthetic?: boolean }).synthetic === true;
|
||||
if (!synthetic) return true;
|
||||
if (part.type !== 'text') return false;
|
||||
if (readContextPart(part)) return true;
|
||||
const text = (part as { text?: unknown }).text;
|
||||
if (typeof text !== 'string') {
|
||||
return false;
|
||||
@@ -116,6 +118,27 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
const synthetic = rawPart.synthetic === true;
|
||||
|
||||
if (synthetic) {
|
||||
const contextPayload = readContextPart(part);
|
||||
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr') {
|
||||
// SAFETY: same display-only file-part shape the legacy
|
||||
// buildGitHubAttachmentPart produces; consumed by
|
||||
// FileAttachment, which matches on the mime type.
|
||||
return {
|
||||
type: 'file',
|
||||
mime: contextPayload.kind === 'github-issue'
|
||||
? 'application/vnd.github.issue-link'
|
||||
: 'application/vnd.github.pull-request-link',
|
||||
filename: contextPayload.kind === 'github-issue'
|
||||
? `Issue #${contextPayload.number}: ${contextPayload.title}`
|
||||
: `PR #${contextPayload.number}: ${contextPayload.title}`,
|
||||
url: contextPayload.url,
|
||||
} as Part;
|
||||
}
|
||||
if (contextPayload) {
|
||||
// Other context kinds render through UserContextPart.
|
||||
return part;
|
||||
}
|
||||
// Legacy messages: sniff the pre-metadata text format.
|
||||
const attachmentPart = buildGitHubAttachmentPart(text);
|
||||
if (attachmentPart) {
|
||||
return attachmentPart;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { readContextPart } from '@/lib/messages/contextParts';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -54,6 +55,13 @@ export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions
|
||||
}
|
||||
}
|
||||
|
||||
// User-attached context (inline comments, terminal selections, and
|
||||
// such) is synthetic transport-wise but is user content: it renders
|
||||
// as a context block and must survive alongside regular text.
|
||||
if (isSynthetic && readContextPart(part)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only filter out synthetic parts if there are non-synthetic parts present
|
||||
// Otherwise, show synthetic parts so the message is displayed
|
||||
if (isSynthetic && hasNonSynthetic) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import type { StreamPhase, ToolPopupContent } from '../types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
@@ -17,7 +16,6 @@ interface AssistantTextPartProps {
|
||||
messageId: string;
|
||||
streamPhase: StreamPhase;
|
||||
chatRenderMode?: 'sorted' | 'live';
|
||||
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
- Assistant markdown treats raw HTML as inert visible text. The final generated
|
||||
HTML is sanitized as defense in depth, with script and style elements
|
||||
forbidden, so message content cannot inject active DOM or application-wide
|
||||
CSS into any runtime surface.
|
||||
CSS into any runtime surface. Safe custom application links go through the
|
||||
app-link confirmation flow in every supported renderer, including VS Code.
|
||||
- Final assistant Markdown rendering is independent from image gallery
|
||||
extraction: gallery presence never changes the chat body. Assistant image
|
||||
syntax consistently renders as a shared image icon followed by its filename,
|
||||
@@ -86,7 +87,7 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
|
||||
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
|
||||
- The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`.
|
||||
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
|
||||
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its output viewport grows with the content up to `46vh`, then scrolls and follows new output until the user scrolls up; following resumes when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
|
||||
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
|
||||
|
||||
## "I want to change description for Perplexity" (example recipe)
|
||||
@@ -120,6 +121,14 @@ Why: only navigation tools use the compact static path; all other tools need obs
|
||||
## Quick map of files in this folder
|
||||
|
||||
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
|
||||
- User-attached context (inline code comments, terminal selections, browser
|
||||
annotations, PR comments/checks): `UserContextPart.tsx`. `UserTextPart`
|
||||
routes to it when the part's metadata carries an `openchamberContext`
|
||||
payload (see `lib/messages/contextParts.ts`, which owns both the send-time
|
||||
builder and the read-back parser). Linked GitHub issues/PRs are instead
|
||||
converted to link file-parts in `normalizeUserDisplayParts.ts`. Legacy
|
||||
pre-metadata messages still render via text sniffing (`<terminal_context>`
|
||||
blocks, `GitHub issue context (JSON)` prefixes).
|
||||
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
||||
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
||||
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { ReasoningTimelineBlock } from './ReasoningPart';
|
||||
|
||||
@@ -22,14 +21,12 @@ const cleanJustificationText = (text: string): string => {
|
||||
interface JustificationBlockProps {
|
||||
part: Part;
|
||||
messageId: string;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const JustificationBlock: React.FC<JustificationBlockProps> = ({
|
||||
part,
|
||||
messageId,
|
||||
onContentChange,
|
||||
actions,
|
||||
}) => {
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
@@ -47,7 +44,6 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
|
||||
<ReasoningTimelineBlock
|
||||
text={textContent}
|
||||
variant="justification"
|
||||
onContentChange={onContentChange}
|
||||
blockId={part.id || `${messageId}-justification`}
|
||||
time={time}
|
||||
showDuration={chatRenderMode !== 'sorted'}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cn } from '@/lib/utils';
|
||||
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
|
||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||
import type { StreamPhase } from '../types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ToolPopupContent } from '../types';
|
||||
import ToolPart from './ToolPart';
|
||||
import { MinDurationShineText } from './MinDurationShineText';
|
||||
@@ -40,7 +39,6 @@ interface ProgressiveGroupProps {
|
||||
expandedTools: Set<string>;
|
||||
onToggleTool: (toolId: string) => void;
|
||||
onShowPopup: (content: ToolPopupContent) => void;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
streamPhase: StreamPhase;
|
||||
showHeader: boolean;
|
||||
animateRows?: boolean;
|
||||
@@ -376,9 +374,7 @@ interface ExpandableToolRowProps {
|
||||
isMobile: boolean;
|
||||
onToggleTool: (toolId: string) => void;
|
||||
onShowPopup: (content: ToolPopupContent) => void;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
animateTailText: boolean;
|
||||
animateRows: boolean;
|
||||
}
|
||||
|
||||
const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
|
||||
@@ -387,9 +383,7 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
|
||||
isMobile,
|
||||
onToggleTool,
|
||||
onShowPopup,
|
||||
onContentChange,
|
||||
animateTailText,
|
||||
animateRows,
|
||||
}) => {
|
||||
const handleToggle = React.useCallback(() => {
|
||||
onToggleTool(activity.id);
|
||||
@@ -401,23 +395,22 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
|
||||
isExpanded={isExpanded}
|
||||
onToggle={handleToggle}
|
||||
isMobile={isMobile}
|
||||
onContentChange={onContentChange}
|
||||
onShowPopup={onShowPopup}
|
||||
animateTailText={animateTailText}
|
||||
/>
|
||||
);
|
||||
|
||||
const maybeWrapped = animateTailText ? (
|
||||
<ToolRevealOnMount animate={true} wipe>
|
||||
{content}
|
||||
</ToolRevealOnMount>
|
||||
) : content;
|
||||
|
||||
if (!animateRows) {
|
||||
return maybeWrapped;
|
||||
}
|
||||
|
||||
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>;
|
||||
// Wrappers are unconditional: a conditional wrapper changes the element
|
||||
// type at this position when animateTailText/animateRows flip (message
|
||||
// completion), remounting the tool subtree and replaying the reveal wipe.
|
||||
// Both wrappers are inert with animation off.
|
||||
return (
|
||||
<FadeInOnReveal>
|
||||
<ToolRevealOnMount animate={animateTailText} wipe>
|
||||
{content}
|
||||
</ToolRevealOnMount>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
|
||||
@@ -425,9 +418,7 @@ const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.onToggleTool === next.onToggleTool
|
||||
&& prev.onShowPopup === next.onShowPopup
|
||||
&& prev.onContentChange === next.onContentChange
|
||||
&& prev.animateTailText === next.animateTailText
|
||||
&& prev.animateRows === next.animateRows
|
||||
&& prev.activity.id === next.activity.id
|
||||
&& prev.activity.kind === next.activity.kind
|
||||
&& prev.activity.endedAt === next.activity.endedAt
|
||||
@@ -438,14 +429,12 @@ interface StaticGroupedToolRowProps {
|
||||
toolName: string;
|
||||
activities: TurnActivityPart[];
|
||||
animateTailText: boolean;
|
||||
animateRows: boolean;
|
||||
}
|
||||
|
||||
const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
|
||||
toolName,
|
||||
activities,
|
||||
animateTailText,
|
||||
animateRows,
|
||||
}) => {
|
||||
const content = (
|
||||
<StaticToolRow
|
||||
@@ -455,23 +444,22 @@ const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const maybeWrapped = animateTailText ? (
|
||||
<ToolRevealOnMount animate={true} wipe>
|
||||
{content}
|
||||
</ToolRevealOnMount>
|
||||
) : content;
|
||||
|
||||
if (!animateRows) {
|
||||
return maybeWrapped;
|
||||
}
|
||||
|
||||
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>;
|
||||
// Wrappers are unconditional: a conditional wrapper changes the element
|
||||
// type at this position when animateTailText/animateRows flip (message
|
||||
// completion), remounting the tool subtree and replaying the reveal wipe.
|
||||
// Both wrappers are inert with animation off.
|
||||
return (
|
||||
<FadeInOnReveal>
|
||||
<ToolRevealOnMount animate={animateTailText} wipe>
|
||||
{content}
|
||||
</ToolRevealOnMount>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoStaticGroupedToolRow = React.memo(StaticGroupedToolRow, (prev, next) => {
|
||||
return prev.toolName === next.toolName
|
||||
&& prev.animateTailText === next.animateTailText
|
||||
&& prev.animateRows === next.animateRows
|
||||
&& areActivityListsEqual(prev.activities, next.activities);
|
||||
});
|
||||
|
||||
@@ -795,9 +783,8 @@ export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => {
|
||||
/**
|
||||
* Inline reasoning text block — rendered as dimmed italic markdown.
|
||||
*/
|
||||
const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhase }: {
|
||||
const InlineReasoningBlock = React.memo(({ activity, streamPhase }: {
|
||||
activity: TurnActivityPart;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
streamPhase: StreamPhase;
|
||||
}) => {
|
||||
return (
|
||||
@@ -805,7 +792,6 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas
|
||||
part={activity.part}
|
||||
messageId={activity.messageId}
|
||||
streamPhase={streamPhase}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -813,16 +799,14 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas
|
||||
/**
|
||||
* Inline justification text block — rendered as normal assistant text between tools.
|
||||
*/
|
||||
const InlineJustificationBlock = React.memo(({ activity, onContentChange, actions }: {
|
||||
const InlineJustificationBlock = React.memo(({ activity, actions }: {
|
||||
activity: TurnActivityPart;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
actions?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<JustificationBlock
|
||||
part={activity.part}
|
||||
messageId={activity.messageId}
|
||||
onContentChange={onContentChange}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
@@ -837,7 +821,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
expandedTools,
|
||||
onToggleTool,
|
||||
onShowPopup,
|
||||
onContentChange,
|
||||
streamPhase,
|
||||
showHeader,
|
||||
animateRows = true,
|
||||
@@ -898,7 +881,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
<InlineReasoningBlock
|
||||
activity={row.activity}
|
||||
streamPhase={streamPhase}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -909,7 +891,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
<>
|
||||
<InlineJustificationBlock
|
||||
activity={row.activity}
|
||||
onContentChange={onContentChange}
|
||||
actions={renderJustificationActions?.(row.activity)}
|
||||
/>
|
||||
</>
|
||||
@@ -924,9 +905,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
isMobile={isMobile}
|
||||
onToggleTool={onToggleTool}
|
||||
onShowPopup={onShowPopup}
|
||||
onContentChange={onContentChange}
|
||||
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
|
||||
animateRows={animateRows}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -937,7 +916,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
toolName={row.toolName}
|
||||
activities={row.activities}
|
||||
animateTailText={row.activities.some((activity) => animatedToolIds?.has(activity.id))}
|
||||
animateRows={animateRows}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -950,9 +928,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
isMobile={isMobile}
|
||||
onToggleTool={onToggleTool}
|
||||
onShowPopup={onShowPopup}
|
||||
onContentChange={onContentChange}
|
||||
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
|
||||
animateRows={animateRows}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { animate, type AnimationPlaybackControls } from 'motion';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { BusyDots } from './BusyDots';
|
||||
@@ -10,6 +9,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
|
||||
import { commitStreamedText } from '../../lib/streamTextCommit';
|
||||
import type { StreamPhase } from '../types';
|
||||
|
||||
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
|
||||
@@ -81,7 +81,6 @@ const getReasoningSummary = (text: string): string => {
|
||||
type ReasoningTimelineBlockProps = {
|
||||
text: string;
|
||||
variant: ReasoningVariant;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
blockId: string;
|
||||
time?: { start?: number; end?: number };
|
||||
showDuration?: boolean;
|
||||
@@ -99,7 +98,6 @@ type ExpansionState = {
|
||||
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
text,
|
||||
variant,
|
||||
onContentChange,
|
||||
blockId,
|
||||
time,
|
||||
isStreaming = false,
|
||||
@@ -123,11 +121,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
|
||||
const contentMountedRef = React.useRef(false);
|
||||
// Stable handle to onContentChange so the height-animation layout effect can
|
||||
// signal auto-follow without taking onContentChange as a dependency (which
|
||||
// would risk re-running — and thus restarting — the animation on re-render).
|
||||
const onContentChangeRef = React.useRef(onContentChange);
|
||||
onContentChangeRef.current = onContentChange;
|
||||
|
||||
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
||||
const toggleAriaLabel = isExpanded
|
||||
@@ -137,8 +130,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
const handleToggle = React.useCallback(() => {
|
||||
setShouldRenderExpandedContent(true);
|
||||
setExpansion({ expanded: !isExpanded, source: 'user' });
|
||||
onContentChange?.('structural');
|
||||
}, [isExpanded, onContentChange]);
|
||||
}, [isExpanded]);
|
||||
|
||||
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
@@ -159,13 +151,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
});
|
||||
}, [canAutoExpand]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (text.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
onContentChange?.('structural');
|
||||
}, [onContentChange, text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isExpanded || isStreaming) {
|
||||
setShouldRenderExpandedContent(true);
|
||||
@@ -239,11 +224,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
element.style.height = '0px';
|
||||
} else {
|
||||
element.style.height = `${element.scrollHeight}px`;
|
||||
// Only the COLLAPSE animation needs the guard: it shrinks the
|
||||
// timeline and the trailing async scroll events can be misread as a
|
||||
// user scroll-away. Expansion grows the timeline and re-pins cleanly,
|
||||
// and guarding it caused a faint scroll fight while thinking streams.
|
||||
onContentChangeRef.current?.('animation');
|
||||
}
|
||||
|
||||
const animation = animate(
|
||||
@@ -436,14 +416,12 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
|
||||
type ReasoningPartProps = {
|
||||
part: Part;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
messageId: string;
|
||||
streamPhase?: StreamPhase;
|
||||
};
|
||||
|
||||
const ReasoningPart = React.memo(({
|
||||
part,
|
||||
onContentChange,
|
||||
messageId,
|
||||
streamPhase,
|
||||
}: ReasoningPartProps) => {
|
||||
@@ -454,11 +432,14 @@ const ReasoningPart = React.memo(({
|
||||
const time = partWithText.time;
|
||||
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
|
||||
const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number';
|
||||
const throttledText = useStreamingTextThrottle({
|
||||
const throttledTextRaw = useStreamingTextThrottle({
|
||||
text: textContent,
|
||||
isStreaming,
|
||||
identityKey: `${messageId}:${part.id ?? 'reasoning'}`,
|
||||
});
|
||||
// Same block-level reveal as assistant text: a shown reasoning paragraph
|
||||
// never mutates in place.
|
||||
const throttledText = isStreaming ? commitStreamedText(throttledTextRaw) : throttledTextRaw;
|
||||
|
||||
// Show reasoning even if time.end isn't set yet (during streaming)
|
||||
// Only hide if there's no text content
|
||||
@@ -470,7 +451,6 @@ const ReasoningPart = React.memo(({
|
||||
<ReasoningTimelineBlock
|
||||
text={throttledText}
|
||||
variant="thinking"
|
||||
onContentChange={onContentChange}
|
||||
blockId={part.id || `${messageId}-reasoning`}
|
||||
time={time}
|
||||
isStreaming={isStreaming}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getStreamingOutputAppend, getToolOutput, renderTerminalOutput } from './toolOutput';
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
import { tryParseJsonOutput } from '../toolRenderers';
|
||||
import { parseDiffToUnified, tryParseJsonOutput } from '../toolRenderers';
|
||||
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
|
||||
import { getToolDescriptionFallback } from './toolRenderUtils';
|
||||
|
||||
@@ -42,6 +42,29 @@ describe('getToolOutput', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDiffToUnified', () => {
|
||||
test('handles a streamed diff with a bare Index header', () => {
|
||||
expect(parseDiffToUnified('Index:')).toEqual([]);
|
||||
expect(parseDiffToUnified('Index:\n@@ -1,1 +1,1 @@\n-old\n+new')).toEqual([
|
||||
{
|
||||
file: 'file',
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: 'removed', lineNumber: 1, content: 'old' },
|
||||
{ type: 'added', lineNumber: 1, content: 'new' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves spaces when extracting the indexed filename', () => {
|
||||
const [hunk] = parseDiffToUnified('Index: src/my file.ts\n@@ -1,1 +1,1 @@\n-old\n+new');
|
||||
|
||||
expect(hunk?.file).toBe('my file.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTerminalOutput', () => {
|
||||
test('renders carriage-return progress updates as their latest value', () => {
|
||||
expect(renderTerminalOutput('Downloading 10%\r\u001B[2KDownloading 90%')).toBe('Downloading 90%');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMobileAppActions } from '@/apps/mobileAppContext';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { QuestionMarkdown } from '../../QuestionMarkdown';
|
||||
import { MessageFilesDisplay } from '../../FileAttachment';
|
||||
import { getToolMetadata } from '@/lib/toolHelpers';
|
||||
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2';
|
||||
@@ -20,7 +21,6 @@ import { toast } from '@/components/ui';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import type { ToolPopupContent } from '../types';
|
||||
import { PlainDiffFallback } from './PlainDiffFallback';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
renderTodoOutput,
|
||||
tryParseJsonOutput,
|
||||
coerceToText,
|
||||
capToolOutputText,
|
||||
} from '../toolRenderers';
|
||||
import { JsonTreeViewer } from '@/components/ui/JsonTreeViewer';
|
||||
import { JsonSummaryView } from './JsonSummaryView';
|
||||
@@ -82,7 +83,6 @@ interface ToolPartProps {
|
||||
onToggle: (toolId: string) => void;
|
||||
isMobile: boolean;
|
||||
alwaysShowActions?: boolean;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
animateTailText?: boolean;
|
||||
}
|
||||
@@ -607,11 +607,15 @@ const getToolOutputText = (
|
||||
part: ToolPartType,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string => {
|
||||
// Cap oversized payloads before JSON.parse / syntax highlighting / DOM work
|
||||
// so a single huge tool output can't trigger a V8 Zone-allocation OOM that
|
||||
// hard-crashes the renderer (issue #2265).
|
||||
const capped = capToolOutputText(output);
|
||||
if (part.tool === 'bash') {
|
||||
return output;
|
||||
return capped;
|
||||
}
|
||||
|
||||
return formatEditOutput(output, part.tool, metadata);
|
||||
return formatEditOutput(capped, part.tool, metadata);
|
||||
};
|
||||
|
||||
const StreamingPlainTextOutput: React.FC<{ output: string }> = ({ output }) => {
|
||||
@@ -1409,7 +1413,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
<div className="space-y-2">
|
||||
{parsedQA.map((qa, index) => (
|
||||
<div key={index} className="space-y-0.5">
|
||||
<div className="typography-micro text-muted-foreground">{qa.question}</div>
|
||||
<QuestionMarkdown content={qa.question} size="micro" className="text-muted-foreground" />
|
||||
<div className="typography-meta text-foreground whitespace-pre-wrap">{qa.answer}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1446,7 +1450,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
{q.header ? (
|
||||
<div className="typography-micro text-muted-foreground">{coerceToText(q.header)}</div>
|
||||
) : null}
|
||||
<div className="typography-meta text-foreground">{coerceToText(q.question)}</div>
|
||||
<QuestionMarkdown content={coerceToText(q.question)} size="meta" className="text-foreground" />
|
||||
{Array.isArray(q.options) && q.options.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{q.options.map((opt) => (
|
||||
@@ -1548,7 +1552,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
output,
|
||||
{
|
||||
className: part.tool === 'bash' ? 'p-1 rounded-none' : 'p-1',
|
||||
maxHeightClass: isStreamingBash ? 'h-[46vh]' : part.tool === 'bash' ? 'max-h-[46vh]' : undefined,
|
||||
maxHeightClass: part.tool === 'bash' ? 'max-h-[46vh]' : undefined,
|
||||
followKey: isStreamingBash ? outputString : undefined,
|
||||
}
|
||||
);
|
||||
@@ -1684,7 +1688,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isMobile,
|
||||
onContentChange,
|
||||
onShowPopup,
|
||||
animateTailText = true,
|
||||
}) => {
|
||||
@@ -1754,10 +1757,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
});
|
||||
}, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]);
|
||||
|
||||
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
|
||||
|
||||
const onContentChangeRef = React.useRef(onContentChange);
|
||||
onContentChangeRef.current = onContentChange;
|
||||
const expandedContentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
@@ -1772,11 +1771,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
|
||||
element.style.height = isExpanded ? 'auto' : '0px';
|
||||
element.style.overflow = isExpanded ? 'visible' : 'hidden';
|
||||
|
||||
if (shouldNotifyStructuralChange) {
|
||||
onContentChangeRef.current?.('structural');
|
||||
}
|
||||
}, [isExpanded, isTaskTool, shouldNotifyStructuralChange]);
|
||||
}, [isExpanded, isTaskTool]);
|
||||
|
||||
const partMetadata = (part as unknown as { metadata?: unknown }).metadata;
|
||||
const time = stateWithData.time;
|
||||
@@ -1934,26 +1929,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
}
|
||||
return metadataTaskSummaryEntries;
|
||||
}, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
|
||||
const taskSummaryRenderSignature = React.useMemo(() => {
|
||||
return taskSummaryEntries.map(getTaskSummaryEntryRenderSignature).join('\u0000');
|
||||
}, [taskSummaryEntries]);
|
||||
const lastTaskSummaryRenderSignatureRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTaskTool) {
|
||||
lastTaskSummaryRenderSignatureRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = lastTaskSummaryRenderSignatureRef.current;
|
||||
lastTaskSummaryRenderSignatureRef.current = taskSummaryRenderSignature;
|
||||
if (previous === null || previous === taskSummaryRenderSignature || taskSummaryEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
onContentChangeRef.current?.('structural');
|
||||
}, [isTaskTool, taskSummaryEntries.length, taskSummaryRenderSignature]);
|
||||
|
||||
const diffStats = React.useMemo(() => {
|
||||
return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')
|
||||
? parseDiffStats(metadata)
|
||||
@@ -1996,6 +1971,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
return null;
|
||||
}, [descriptionPath, normalizedPartTool, stateWithData, input]);
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
const mobileActions = useMobileAppActions();
|
||||
|
||||
const openApplyPatchFile = (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!runtime?.editor) {
|
||||
@@ -2068,6 +2044,61 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
handleMainClick(event);
|
||||
};
|
||||
|
||||
// Quick-open target for the file-link icon in the tool header. Resolves the
|
||||
// primary file path (and, for diff tools, the first changed line + diff) so
|
||||
// the user can open the file in the side panel (web/desktop) or editor
|
||||
// (VS Code) without expanding the tool card. Reuses the same path helpers as
|
||||
// handleMainClick above; the difference is the web fallback — handleMainClick
|
||||
// only opens when runtime.editor is available, this icon also falls back to
|
||||
// useUIStore.openContextFile{AtLine} so the file opens in the right pane.
|
||||
const quickOpenTarget = React.useMemo<{ absolutePath: string; line?: number; toolDiff?: string; toolName: string } | null>(() => {
|
||||
if (isTaskTool) return null;
|
||||
const toolName = normalizedPartTool || part.tool;
|
||||
const filePath = getPrimaryToolPath(toolName, input, metadata);
|
||||
if (typeof filePath !== 'string') return null;
|
||||
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
|
||||
let line: number | undefined;
|
||||
let toolDiff: string | undefined;
|
||||
if (toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch') {
|
||||
line = getFirstChangedLineFromMetadata(toolName, metadata, filePath);
|
||||
toolDiff = getPrimaryDiffFromMetadata(toolName, metadata, filePath);
|
||||
}
|
||||
return { absolutePath, line, toolDiff, toolName };
|
||||
}, [isTaskTool, normalizedPartTool, part.tool, input, metadata, currentDirectory]);
|
||||
|
||||
const openQuickTarget = () => {
|
||||
if (!quickOpenTarget) return;
|
||||
const { absolutePath, line, toolDiff, toolName } = quickOpenTarget;
|
||||
if (runtime?.editor) {
|
||||
if (runtime.runtime.isVSCode && toolDiff && (toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch')) {
|
||||
const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`;
|
||||
void runtime.editor.openDiff('', absolutePath, label, { line, patch: toolDiff });
|
||||
return;
|
||||
}
|
||||
runtime.editor.openFile(absolutePath, line);
|
||||
return;
|
||||
}
|
||||
const uiStore = useUIStore.getState();
|
||||
if (typeof line === 'number' && Number.isFinite(line)) {
|
||||
uiStore.openContextFileAtLine(currentDirectory, absolutePath, Math.max(1, Math.trunc(line)), 1);
|
||||
} else {
|
||||
uiStore.openContextFile(currentDirectory, absolutePath);
|
||||
}
|
||||
mobileActions?.openFiles();
|
||||
};
|
||||
|
||||
const handleQuickOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
openQuickTarget();
|
||||
};
|
||||
|
||||
const handleQuickOpenKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openQuickTarget();
|
||||
};
|
||||
|
||||
const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
|
||||
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
|
||||
const shouldRenderTaskSummary = useDeferredExpandedContent(isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || !!taskSessionId));
|
||||
@@ -2161,7 +2192,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<MinDurationShineText
|
||||
active={Boolean(isActive && !isError)}
|
||||
minDurationMs={300}
|
||||
@@ -2171,6 +2202,22 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
>
|
||||
{displayName}
|
||||
</MinDurationShineText>
|
||||
{quickOpenTarget ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickOpen}
|
||||
onKeyDown={handleQuickOpenKeyDown}
|
||||
className={cn(
|
||||
'flex-shrink-0 inline-flex h-4 w-4 items-center justify-center rounded transition-opacity hover:bg-[var(--surface-hover)]',
|
||||
'opacity-0 group-hover/tool:opacity-60 hover:opacity-100 focus-visible:opacity-100',
|
||||
)}
|
||||
style={{ color: 'var(--tools-icon)' }}
|
||||
title={t('chat.toolPart.openFile')}
|
||||
aria-label={t('chat.toolPart.openFile')}
|
||||
>
|
||||
<Icon name="external-link" className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{normalizedPartTool === 'bash' && typeof effectiveTimeStart === 'number' ? (
|
||||
<span className={cn('flex-shrink-0 tabular-nums text-muted-foreground/80', TOOL_ROW_DESCRIPTION_CLASS)}>
|
||||
@@ -2351,7 +2398,6 @@ export default React.memo(ToolPart, (prev, next) => {
|
||||
&& prev.isExpanded === next.isExpanded
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.alwaysShowActions === next.alwaysShowActions
|
||||
&& prev.onContentChange === next.onContentChange
|
||||
&& prev.onShowPopup === next.onShowPopup
|
||||
&& prev.animateTailText === next.animateTailText;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ContextPartPayload } from '@/lib/messages/contextParts';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* A context item attached to a user message: an inline code comment, a
|
||||
* terminal selection, a browser annotation, or GitHub PR context.
|
||||
*
|
||||
* The quoted material renders as a messenger-style reply: a source caption and
|
||||
* the quote behind a plain left bar, in muted text, clamped to a few lines
|
||||
* (click toggles the full quote). The user's comment follows below as regular
|
||||
* message text, so the pair reads as "a reply to this quote" instead of a
|
||||
* boxed widget inside the bubble.
|
||||
*/
|
||||
|
||||
const ContextCard: React.FC<{
|
||||
icon: IconName;
|
||||
summary: string;
|
||||
/** Full untruncated context, shown on hover. */
|
||||
title?: string;
|
||||
body: string;
|
||||
text: string;
|
||||
/** Render the quote in the code font (code, terminal output, CI logs). */
|
||||
mono?: boolean;
|
||||
/**
|
||||
* Message-level collapse: with collapsible messages on, the whole user
|
||||
* message (text parts and cards alike) shares one expanded state, so a
|
||||
* collapsed card is a two-line preview and a click asks the message to
|
||||
* expand instead of toggling anything of its own.
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
onExpand?: () => void;
|
||||
}> = ({ icon, summary, title, body, text, mono, collapsed, onExpand }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const hasBody = body.trim().length > 0;
|
||||
const hasText = text.trim().length > 0;
|
||||
|
||||
if (collapsed) {
|
||||
// One line per attachment: the source caption, and the user's comment
|
||||
// after it when there is one ("Quoted from an earlier message: thanks,
|
||||
// that settles it"). Attachments without a comment (terminal output
|
||||
// and the like) collapse to the caption alone.
|
||||
const comment = text.trim();
|
||||
return (
|
||||
<div
|
||||
className="my-1 flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 border-l-2 border-[var(--interactive-border)] pl-3 text-xs text-[var(--surface-mutedForeground)]"
|
||||
onClick={onExpand}
|
||||
title={title}
|
||||
>
|
||||
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{comment.length > 0 ? `${summary}: ` : summary}
|
||||
{comment.length > 0 ? (
|
||||
<span className="text-sm text-[var(--surface-foreground)]">{comment}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-1.5 min-w-0 max-w-full">
|
||||
<div
|
||||
className={cn('min-w-0 border-l-2 border-[var(--interactive-border)] pl-3', hasBody && 'cursor-pointer')}
|
||||
onClick={hasBody ? () => setExpanded((value) => !value) : undefined}
|
||||
title={title}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--surface-mutedForeground)]">
|
||||
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{summary}</span>
|
||||
</div>
|
||||
{hasBody ? (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 whitespace-pre-wrap break-words text-[var(--surface-mutedForeground)]',
|
||||
mono ? 'font-mono text-xs leading-5' : 'text-sm',
|
||||
!expanded && 'line-clamp-4'
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{hasText ? (
|
||||
<div className="mt-1.5 whitespace-pre-wrap break-words font-sans text-sm text-[var(--surface-foreground)]">{text}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const basename = (path: string): string => {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] ?? path;
|
||||
};
|
||||
|
||||
const UserContextPart: React.FC<{
|
||||
payload: ContextPartPayload;
|
||||
/** Message-level collapse state, shared with the text parts. */
|
||||
collapsed?: boolean;
|
||||
onExpand?: () => void;
|
||||
}> = ({ payload, collapsed, onExpand }) => {
|
||||
const { t } = useI18n();
|
||||
const shared = { collapsed, onExpand };
|
||||
|
||||
switch (payload.kind) {
|
||||
case 'code-comment': {
|
||||
const file = basename(payload.fileLabel);
|
||||
const summary = payload.startLine === payload.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file, line: payload.startLine })
|
||||
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine });
|
||||
const fullTitle = payload.startLine === payload.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file: payload.fileLabel, line: payload.startLine })
|
||||
: t('chat.message.context.codeComment', { file: payload.fileLabel, start: payload.startLine, end: payload.endLine });
|
||||
return <ContextCard icon="chat-1" summary={summary} title={fullTitle} body={payload.code} text={payload.text} mono {...shared} />;
|
||||
}
|
||||
case 'terminal':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="terminal"
|
||||
summary={t('chat.message.terminalContext', {
|
||||
terminal: payload.terminalLabel,
|
||||
start: payload.startLine,
|
||||
end: payload.endLine,
|
||||
})}
|
||||
body={payload.output}
|
||||
text=""
|
||||
mono
|
||||
{...shared}
|
||||
/>
|
||||
);
|
||||
case 'browser-annotation':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="global"
|
||||
summary={t('chat.message.context.browserAnnotation', { page: payload.pageUrl })}
|
||||
title={payload.pageUrl}
|
||||
body={payload.prompt}
|
||||
text={payload.text}
|
||||
{...shared}
|
||||
/>
|
||||
);
|
||||
case 'pr-comment':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="git-pull-request"
|
||||
summary={t('chat.message.context.prComment', { label: payload.label })}
|
||||
body={payload.body}
|
||||
text={payload.text}
|
||||
{...shared}
|
||||
/>
|
||||
);
|
||||
case 'pr-check':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="close-circle"
|
||||
summary={t('chat.message.context.prCheck', { label: payload.label })}
|
||||
body={payload.output}
|
||||
text={payload.text}
|
||||
mono
|
||||
{...shared}
|
||||
/>
|
||||
);
|
||||
case 'file-quote': {
|
||||
const file = basename(payload.fileLabel);
|
||||
const summary = payload.startLine != null && payload.endLine != null
|
||||
? (payload.startLine === payload.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file, line: payload.startLine })
|
||||
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine }))
|
||||
: t('chat.message.context.fileQuote', { file });
|
||||
return <ContextCard icon="chat-1" summary={summary} title={payload.fileLabel} body={payload.quote} text={payload.text} {...shared} />;
|
||||
}
|
||||
case 'chat-quote':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="chat-1"
|
||||
summary={t('chat.message.context.chatQuote')}
|
||||
body={payload.quote}
|
||||
text={payload.text}
|
||||
{...shared}
|
||||
/>
|
||||
);
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
// Rendered as link attachments by normalizeUserDisplayParts.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default React.memo(UserContextPart);
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from '@/lib/messages/inlineMessageLinks';
|
||||
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
|
||||
import { extractTerminalContexts } from '@/lib/messages/terminalContext';
|
||||
import { readContextPart } from '@/lib/messages/contextParts';
|
||||
import UserContextPart from './UserContextPart';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -23,13 +25,26 @@ type UserTextPartProps = {
|
||||
messageId: string;
|
||||
isMobile: boolean;
|
||||
agentMention?: AgentMentionInfo;
|
||||
/**
|
||||
* Message-level collapse: when provided, all parts of the user message
|
||||
* share one expanded state owned by the message body, expanding any part
|
||||
* expands the whole message, and the message body renders the single
|
||||
* collapse control. When absent the part collapses on its own (legacy
|
||||
* single-part behavior).
|
||||
*/
|
||||
messageExpanded?: boolean;
|
||||
onExpandMessage?: () => void;
|
||||
};
|
||||
|
||||
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention, messageExpanded, onExpandMessage }) => {
|
||||
// Structured context (inline comments, terminal selections, annotations,
|
||||
// PR context) renders as a dedicated block instead of raw prompt text.
|
||||
const contextPayload = React.useMemo(() => readContextPart(part), [part]);
|
||||
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text;
|
||||
const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||
@@ -45,7 +60,9 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const { t } = useI18n();
|
||||
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
|
||||
const isCollapsed = collapsibleUserMessages && !isExpanded;
|
||||
const isControlled = messageExpanded !== undefined;
|
||||
const effectiveExpanded = messageExpanded ?? isExpanded;
|
||||
const isCollapsed = collapsibleUserMessages && !effectiveExpanded;
|
||||
const textRef = React.useRef<HTMLDivElement>(null);
|
||||
const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
|
||||
|
||||
@@ -72,20 +89,47 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
React.useEffect(() => {
|
||||
const el = textRef.current;
|
||||
if (!el) return;
|
||||
if (!collapsibleUserMessages || effectiveExpanded) return;
|
||||
|
||||
const checkTruncation = () => {
|
||||
if (collapsibleUserMessages && !isExpanded) {
|
||||
setIsTruncated(el.scrollHeight > el.clientHeight);
|
||||
}
|
||||
setIsTruncated(el.scrollHeight > el.clientHeight);
|
||||
};
|
||||
|
||||
checkTruncation();
|
||||
// A just-sent message mounts while its turn is still settling, so the
|
||||
// synchronous read can land before the clamp has its final geometry.
|
||||
// One deferred re-read covers that without waiting for an observer.
|
||||
const initialFrame = window.requestAnimationFrame(checkTruncation);
|
||||
|
||||
// `el` is the clamped box: once line-clamp pins it to two lines its own
|
||||
// size stops changing, so observing it alone freezes the first
|
||||
// measurement. Markdown settles after mount (highlighting, late layout),
|
||||
// and a message measured while still short would never regain the
|
||||
// expand affordance. The children keep their natural height under the
|
||||
// clamp, so they are what reports content growth.
|
||||
const resizeObserver = new ResizeObserver(checkTruncation);
|
||||
resizeObserver.observe(el);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [collapsibleUserMessages, textContent, isExpanded]);
|
||||
const observeChildren = () => {
|
||||
for (const child of Array.from(el.children)) {
|
||||
resizeObserver.observe(child);
|
||||
}
|
||||
};
|
||||
observeChildren();
|
||||
|
||||
// The renderer swaps subtrees as it settles; re-observe the new children.
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
observeChildren();
|
||||
checkTruncation();
|
||||
});
|
||||
mutationObserver.observe(el, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(initialFrame);
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [collapsibleUserMessages, textContent, effectiveExpanded]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!collapsibleUserMessages) {
|
||||
@@ -115,10 +159,18 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
return;
|
||||
}
|
||||
|
||||
if (collapsibleUserMessages && !isExpanded && isTruncated) {
|
||||
setIsExpanded(true);
|
||||
// Measure at click time instead of trusting the observed flag: whether
|
||||
// the text is clipped right now is what decides if expanding does
|
||||
// anything, and the flag can still be catching up on a fresh message.
|
||||
if (collapsibleUserMessages && !effectiveExpanded && element.scrollHeight > element.clientHeight) {
|
||||
setIsTruncated(true);
|
||||
if (isControlled) {
|
||||
onExpandMessage?.();
|
||||
} else {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}
|
||||
}, [collapsibleUserMessages, hasActiveSelectionInElement, isExpanded, isTruncated, openSkill]);
|
||||
}, [collapsibleUserMessages, effectiveExpanded, hasActiveSelectionInElement, isControlled, onExpandMessage, openSkill]);
|
||||
|
||||
const handleCollapse = React.useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -193,13 +245,23 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
});
|
||||
}, [agentMention, openSkill, skillByName, textContent]);
|
||||
|
||||
if (contextPayload) {
|
||||
return (
|
||||
<UserContextPart
|
||||
payload={contextPayload}
|
||||
collapsed={isCollapsed}
|
||||
onExpand={isControlled ? onExpandMessage : () => setIsExpanded(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" key={part.id || `${messageId}-user-text`}>
|
||||
{collapsibleUserMessages && isExpanded && (
|
||||
{collapsibleUserMessages && !isControlled && isExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCollapse}
|
||||
@@ -212,10 +274,10 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
<div
|
||||
className={cn(
|
||||
"break-words font-sans typography-markdown-body",
|
||||
isExpanded && "pb-3",
|
||||
!isControlled && isExpanded && "pb-3",
|
||||
normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap',
|
||||
isCollapsed && "line-clamp-2",
|
||||
collapsibleUserMessages && isTruncated && !isExpanded && "cursor-pointer"
|
||||
collapsibleUserMessages && isTruncated && !effectiveExpanded && "cursor-pointer"
|
||||
)}
|
||||
ref={textRef}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -54,7 +54,8 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
// Tokenize the whole block in one worker call; rows index into the result.
|
||||
const fullText = React.useMemo(() => lines.map((line) => line.text).join('\n'), [lines]);
|
||||
const highlighted = useWorkerHighlightedLines(fullText, language);
|
||||
const highlightResult = useWorkerHighlightedLines(fullText, language);
|
||||
const highlighted = highlightResult.lines;
|
||||
|
||||
const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD;
|
||||
|
||||
|
||||
@@ -229,15 +229,18 @@ export function WorkingPlaceholder({
|
||||
|
||||
return (
|
||||
<div
|
||||
// Full muted-foreground, matching the scroll-to-bottom pill's status
|
||||
// text: the row and the pill hand off to each other in the same spot
|
||||
// and must read as one element changing chrome.
|
||||
className={
|
||||
'flex h-full items-center text-muted-foreground pl-0.5'
|
||||
'flex h-full items-center text-muted-foreground'
|
||||
}
|
||||
role="status"
|
||||
aria-live={displayedPermission ? 'assertive' : 'polite'}
|
||||
aria-label={label}
|
||||
data-waiting={displayedPermission ? 'true' : undefined}
|
||||
>
|
||||
<span className="typography-ui-header">
|
||||
<span className="text-sm">
|
||||
{hasProviderLogo && providerLogoSrc ? (
|
||||
<img
|
||||
src={providerLogoSrc}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { commitStreamedText } from '../../lib/streamTextCommit';
|
||||
|
||||
export const resolveAssistantDisplayText = (input: {
|
||||
textContent: string;
|
||||
throttledTextContent: string;
|
||||
isStreaming: boolean;
|
||||
}): string => {
|
||||
return input.isStreaming ? input.throttledTextContent : input.textContent;
|
||||
// While streaming, reveal whole blocks only: rendering stops at the last
|
||||
// complete line so a shown paragraph never mutates in place. The held
|
||||
// tail lands with the next line break (or the finalize pass).
|
||||
return input.isStreaming
|
||||
? commitStreamedText(input.throttledTextContent)
|
||||
: input.textContent;
|
||||
};
|
||||
|
||||
export const shouldRenderAssistantText = (input: {
|
||||
|
||||
@@ -56,6 +56,12 @@ export const getToolIcon = (toolName: string) => {
|
||||
if (tool === 'openchamber') {
|
||||
return <Icon name="openchamber" 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} />;
|
||||
}
|
||||
if (tool === 'openchamber_web') {
|
||||
return <Icon name="global" className={iconClass} />;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user