Merge remote-tracking branch 'openchamber/main' into requests-in-flight
# Conflicts: # packages/ui/src/components/ui/MemoryDebugPanel.tsx
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.13.2",
|
||||
"version": "1.15.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/main.tsx",
|
||||
@@ -11,7 +11,13 @@
|
||||
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.4.1",
|
||||
"@capacitor/keyboard": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.1.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
@@ -36,14 +42,12 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "^1.17.7",
|
||||
"@pierre/diffs": "1.3.0-beta.4",
|
||||
"@opencode-ai/sdk": "1.17.18",
|
||||
"@pierre/diffs": "1.3.0-beta.6",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@tanstack/react-virtual": "3.14.5",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"@zumer/snapdom": "^2.12.0",
|
||||
"beautiful-mermaid": "^1.1.3",
|
||||
@@ -59,7 +63,7 @@
|
||||
"heic2any": "^0.0.4",
|
||||
"html-to-image": "^1.11.13",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"katex": "^0.16.21",
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^17.0.3",
|
||||
"morphdom": "^2.7.7",
|
||||
"motion": "^12.23.24",
|
||||
|
||||
+29
-24
@@ -35,16 +35,16 @@ import { markSessionViewed } from '@/sync/notification-store';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { resumeAutoReviewRun } from '@/lib/reviewFlow';
|
||||
import { SyncProvider } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||
import { AboutDialog } from '@/components/ui/AboutDialog';
|
||||
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { VoiceProvider } from '@/components/voice';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
@@ -56,8 +56,8 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import { SyncAppEffects } from '@/apps/AppEffects';
|
||||
import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset';
|
||||
import { useAppFontEffects } from '@/apps/useAppFontEffects';
|
||||
import { resetStreamingState } from '@/sync/streaming';
|
||||
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
|
||||
import { markStartupTrace, startupTraceEnabled } from '@/lib/startupTrace';
|
||||
|
||||
@@ -274,28 +274,35 @@ function App({ apis }: AppProps) {
|
||||
|
||||
React.useEffect(() => {
|
||||
return subscribeRuntimeEndpointChanged((detail) => {
|
||||
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
||||
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
||||
disposeTerminalInputTransport();
|
||||
opencodeClient.reconnectToRuntimeBaseUrl();
|
||||
useConfigStore.setState({
|
||||
providers: [],
|
||||
agents: [],
|
||||
isConnected: false,
|
||||
isInitialized: false,
|
||||
connectionPhase: 'connecting',
|
||||
lastDisconnectReason: null,
|
||||
});
|
||||
useProjectsStore.getState().resetForRuntimeSwitch();
|
||||
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
resetStreamingState();
|
||||
resetAppForRuntimeEndpointChange(detail);
|
||||
setRuntimeEndpointEpoch((epoch) => epoch + 1);
|
||||
setInitRetryExhausted(false);
|
||||
setInitRetryEpoch((epoch) => epoch + 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const autoReviewResumeSignature = useAutoReviewStore((state) => {
|
||||
const runtimeKey = getRuntimeKey();
|
||||
return Object.values(state.runsByOriginalSessionID)
|
||||
.filter((run) => run.status === 'running' && run.runtimeKey === runtimeKey)
|
||||
.map((run) => `${run.originalSessionID}:${run.phase}:${run.lastForwardedMessageID ?? ''}:${run.expectedAssistantParentID ?? ''}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (embeddedSessionChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const runs = Object.values(useAutoReviewStore.getState().runsByOriginalSessionID)
|
||||
.filter((run) => run.status === 'running' && run.runtimeKey === runtimeKey);
|
||||
for (const run of runs) {
|
||||
resumeAutoReviewRun(run.originalSessionID);
|
||||
}
|
||||
}, [autoReviewResumeSignature, embeddedSessionChat, runtimeEndpointEpoch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
|
||||
return () => {
|
||||
@@ -928,8 +935,8 @@ function App({ apis }: AppProps) {
|
||||
}
|
||||
|
||||
// Always mount the full provider tree to avoid remounts when isInitialized
|
||||
// flips from false → true. FireworksProvider and VoiceProvider are lightweight
|
||||
// shells; their heavy children are only activated when actually needed.
|
||||
// flips from false → true. FireworksProvider is a lightweight shell; its
|
||||
// heavy children are only activated when actually needed.
|
||||
const isBootShell = !isInitialized && !isDesktopRuntime;
|
||||
|
||||
return (
|
||||
@@ -937,7 +944,6 @@ function App({ apis }: AppProps) {
|
||||
<SyncProvider key={runtimeEndpointEpoch} sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
||||
<RuntimeAPIProvider apis={apis}>
|
||||
<FireworksProvider>
|
||||
<VoiceProvider>
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
||||
<div className={isDesktopRuntime ? 'h-full text-foreground bg-transparent' : 'h-full text-foreground bg-background'}>
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
||||
@@ -955,7 +961,6 @@ function App({ apis }: AppProps) {
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</VoiceProvider>
|
||||
</FireworksProvider>
|
||||
</RuntimeAPIProvider>
|
||||
</SyncProvider>
|
||||
|
||||
@@ -22,13 +22,16 @@ const SyncOptimisticBridge: React.FC = () => {
|
||||
const sync = useSync();
|
||||
const addRef = React.useRef(sync.optimistic.add);
|
||||
const removeRef = React.useRef(sync.optimistic.remove);
|
||||
const confirmRef = React.useRef(sync.optimistic.confirm);
|
||||
addRef.current = sync.optimistic.add;
|
||||
removeRef.current = sync.optimistic.remove;
|
||||
confirmRef.current = sync.optimistic.confirm;
|
||||
|
||||
React.useEffect(() => {
|
||||
setOptimisticRefs(
|
||||
(input) => addRef.current(input),
|
||||
(input) => removeRef.current(input),
|
||||
(input) => confirmRef.current(input),
|
||||
);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useSync } from '@/sync/use-sync';
|
||||
import { SyncRuntimeEffects } from './AppEffects';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
import { useMiniChatKeyboardShortcuts } from '@/hooks/useMiniChatKeyboardShortcuts';
|
||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import { listProjectWorktrees, worktreeMapsEqual } from '@/lib/worktrees/worktreeManager';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
|
||||
@@ -194,10 +194,15 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
useSessionUIStore.setState({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: worktreesByProject,
|
||||
});
|
||||
|
||||
// Skip update if nothing changed — see worktreeMapsEqual JSDoc.
|
||||
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
|
||||
if (!worktreeMapsEqual(worktreesByProject, currentByProject)) {
|
||||
useSessionUIStore.setState({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: worktreesByProject,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void discoverWorktrees();
|
||||
|
||||
+1969
-77
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog';
|
||||
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||
|
||||
export type MobileEditableProject = {
|
||||
type MobileEditableProject = {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
|
||||
@@ -45,7 +45,7 @@ import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/pro
|
||||
import { cn } from '@/lib/utils';
|
||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { mergeSessionDirectoryMetadata, refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { mergeLiveSessionWithGlobalSession, refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useMobileSessionExpansionStore } from '@/stores/useMobileSessionExpansionStore';
|
||||
import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -60,6 +60,9 @@ import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||
type MobileSessionsSheetProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 'sheet' (default) wraps the content in the swipe-dismiss MobileSurfaceShell;
|
||||
'sidebar' renders the same content inline for the iPad persistent sidebar. */
|
||||
variant?: 'sheet' | 'sidebar';
|
||||
};
|
||||
|
||||
type ProjectMeta = {
|
||||
@@ -154,6 +157,20 @@ const pathBelongsToRoot = (path: string, root: string): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const findExactWorktreeMatch = (project: ProjectMeta, normalizedDirectory: string): WorktreeMetadata | null => (
|
||||
project.worktrees.find((worktree) => normalizePath(worktree.path) === normalizedDirectory) ?? null
|
||||
);
|
||||
|
||||
const projectMatchesExactDirectory = (project: ProjectMeta, normalizedDirectory: string): boolean => (
|
||||
normalizedDirectory === project.path || Boolean(findExactWorktreeMatch(project, normalizedDirectory))
|
||||
);
|
||||
|
||||
const findExactProjectMatch = (projects: ProjectMeta[], directory: string): ProjectMeta | null => {
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
if (!normalizedDirectory) return null;
|
||||
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();
|
||||
@@ -497,7 +514,7 @@ const SortableProjectRow: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, onOpenChange }) => {
|
||||
export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, onOpenChange, variant = 'sheet' }) => {
|
||||
const { t } = useI18n();
|
||||
const { git } = useRuntimeAPIs();
|
||||
const liveSessions = useAllLiveSessions();
|
||||
@@ -618,7 +635,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
|
||||
const merged = globalActiveSessions.map((session) => {
|
||||
const liveSession = liveById.get(session.id);
|
||||
return liveSession ? mergeSessionDirectoryMetadata(liveSession, session) : session;
|
||||
return liveSession ? mergeLiveSessionWithGlobalSession(liveSession, session) : session;
|
||||
});
|
||||
const seenIds = new Set(merged.map((session) => session.id));
|
||||
for (const session of liveSessions) {
|
||||
@@ -662,12 +679,10 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
for (const session of sessions) {
|
||||
const directory = getSessionDirectory(session);
|
||||
if (!directory) continue;
|
||||
const node = nodes.find((entry) => {
|
||||
if (pathBelongsToRoot(directory, entry.project.path)) return true;
|
||||
return entry.project.worktrees.some((worktree) => pathBelongsToRoot(directory, worktree.path));
|
||||
});
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
const node = nodes.find((entry) => projectMatchesExactDirectory(entry.project, normalizedDirectory));
|
||||
if (!node) continue;
|
||||
const matchedWorktree = node.project.worktrees.find((entry) => pathBelongsToRoot(directory, entry.path));
|
||||
const matchedWorktree = findExactWorktreeMatch(node.project, normalizedDirectory);
|
||||
const bucket = matchedWorktree
|
||||
? ensureBucket(node, matchedWorktree.path, matchedWorktree)
|
||||
: ensureBucket(node, node.project.path, null);
|
||||
@@ -677,7 +692,9 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
for (const node of nodes) {
|
||||
for (const bucket of node.buckets) {
|
||||
bucket.sessions.sort((a, b) => getSessionTimestamp(b) - getSessionTimestamp(a));
|
||||
node.totalSessions += bucket.sessions.length;
|
||||
for (const session of bucket.sessions) {
|
||||
if (!getParentId(session)) node.totalSessions += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,10 +833,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
// Switching session switches the working directory (handled by
|
||||
// setCurrentSession) — also move the active project so the rest of the app
|
||||
// and the active highlight follow the selected session, not just the draft.
|
||||
const project = projectsMeta.find((entry) => {
|
||||
if (pathBelongsToRoot(directory ?? '', entry.path)) return true;
|
||||
return entry.worktrees.some((worktree) => pathBelongsToRoot(directory ?? '', worktree.path));
|
||||
});
|
||||
const project = findExactProjectMatch(projectsMeta, directory ?? '');
|
||||
if (project) setActiveProjectIdOnly(project.id);
|
||||
void setCurrentSession(session.id, directory);
|
||||
onOpenChange(false);
|
||||
@@ -878,12 +892,9 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
const buildSessionContextLabel = React.useCallback(
|
||||
(session: Session): string => {
|
||||
const directory = getSessionDirectory(session);
|
||||
const project = projectsMeta.find((entry) => {
|
||||
if (pathBelongsToRoot(directory, entry.path)) return true;
|
||||
return entry.worktrees.some((worktree) => pathBelongsToRoot(directory, worktree.path));
|
||||
});
|
||||
const project = findExactProjectMatch(projectsMeta, directory);
|
||||
if (!project) return getProjectLabel(directory) || directory;
|
||||
const matchedWorktree = project.worktrees.find((entry) => pathBelongsToRoot(directory, entry.path));
|
||||
const matchedWorktree = findExactWorktreeMatch(project, normalizePath(directory));
|
||||
if (matchedWorktree?.branch) return `${project.label} · ${matchedWorktree.branch}`;
|
||||
return project.label;
|
||||
},
|
||||
@@ -915,10 +926,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
return sessions
|
||||
.filter((session) => {
|
||||
const directory = getSessionDirectory(session);
|
||||
const project = projectsMeta.find((entry) => {
|
||||
if (pathBelongsToRoot(directory, entry.path)) return true;
|
||||
return entry.worktrees.some((worktree) => pathBelongsToRoot(directory, worktree.path));
|
||||
});
|
||||
const project = findExactProjectMatch(projectsMeta, directory);
|
||||
return sessionMatchesQuery(session, project?.label ?? '', normalizedQuery);
|
||||
})
|
||||
.sort((a, b) => getSessionTimestamp(b) - getSessionTimestamp(a));
|
||||
@@ -931,9 +939,9 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
.map((project) => ({
|
||||
...project,
|
||||
sessionCount: sessions.filter((session) => {
|
||||
const directory = getSessionDirectory(session);
|
||||
if (pathBelongsToRoot(directory, project.path)) return true;
|
||||
return project.worktrees.some((worktree) => pathBelongsToRoot(directory, worktree.path));
|
||||
if (getParentId(session)) return false;
|
||||
const directory = normalizePath(getSessionDirectory(session));
|
||||
return projectMatchesExactDirectory(project, directory);
|
||||
}).length,
|
||||
}));
|
||||
}, [normalizedQuery, projectsMeta, sessions]);
|
||||
@@ -994,14 +1002,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<MobileSurfaceShell
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
ariaLabel={t('mobile.sessions.sheet.title')}
|
||||
title={t('mobile.sessions.sheet.title')}
|
||||
trailing={trailingActions}
|
||||
>
|
||||
const surfaceContent = (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className={cn('shrink-0 px-4 pb-2 pt-1', editingOrder && 'hidden')}>
|
||||
<div className="relative">
|
||||
@@ -1289,6 +1290,34 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
||||
onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === 'sidebar') {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center justify-between gap-2 border-b border-border/30 px-4">
|
||||
<h2 className="truncate typography-ui-label font-semibold text-foreground">
|
||||
{t('mobile.sessions.sheet.title')}
|
||||
</h2>
|
||||
{trailingActions ? (
|
||||
<div className="flex shrink-0 items-center gap-2">{trailingActions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{surfaceContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileSurfaceShell
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
ariaLabel={t('mobile.sessions.sheet.title')}
|
||||
title={t('mobile.sessions.sheet.title')}
|
||||
trailing={trailingActions}
|
||||
>
|
||||
{surfaceContent}
|
||||
</MobileSurfaceShell>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -67,6 +67,15 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const surfaceRef = React.useRef<HTMLElement | null>(null);
|
||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
// Keep onClose in a ref so the focus/keydown effect below depends only on `open`.
|
||||
// The parent passes a fresh inline onClose on every render; if the effect depended
|
||||
// on it, each parent re-render (e.g. an SSE store update) would re-run it and
|
||||
// refocus the first element — stealing focus from whatever input the user is in
|
||||
// and collapsing the keyboard mid-edit.
|
||||
const onCloseRef = React.useRef(onClose);
|
||||
React.useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
if (typeof document !== 'undefined' && !rootRef.current) {
|
||||
rootRef.current = ensureSurfaceRoot();
|
||||
@@ -112,7 +121,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
||||
const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
@@ -145,7 +154,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
||||
previousFocusRef.current?.focus?.({ preventScroll: true });
|
||||
previousFocusRef.current = null;
|
||||
};
|
||||
}, [onClose, open]);
|
||||
}, [open]);
|
||||
|
||||
const handleDragStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (disableSwipeDismiss) return;
|
||||
@@ -208,7 +217,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 flex flex-col bg-[rgb(0_0_0_/_0.45)]',
|
||||
'oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-[rgb(0_0_0_/_0.45)]',
|
||||
// The opacity transition keeps the scrim on its own compositing layer,
|
||||
// which iOS Safari clips to the viewport — without it, a static scrim
|
||||
// bleeds the dim into the bottom toolbar overscroll zone. Quick fade so
|
||||
|
||||
@@ -107,7 +107,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<AgentManagerView />
|
||||
<Toaster />
|
||||
<Toaster position="top-center" />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</RuntimeAPIProvider>
|
||||
@@ -125,7 +125,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<VSCodeLayout />
|
||||
<Toaster />
|
||||
<Toaster position="top-center" />
|
||||
<ConfigUpdateOverlay />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Resolves once the one-time app boot work that affects layout has been applied —
|
||||
// notably persisted appearance/typography preferences (font size, spacing), which are
|
||||
// loaded asynchronously and would otherwise reflow the UI a frame after first paint.
|
||||
// The mobile splash gate (useFontsReady) awaits this so the first UI shown is final.
|
||||
|
||||
let resolveBoot: (() => void) | null = null;
|
||||
let resolved = false;
|
||||
|
||||
export const appBootReadyPromise = new Promise<void>((resolve) => {
|
||||
resolveBoot = resolve;
|
||||
});
|
||||
|
||||
export function markAppBootReady(): void {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolveBoot?.();
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import React from 'react';
|
||||
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks';
|
||||
|
||||
/**
|
||||
* Navigation layer for {@link DeepLinkIntent}s — the only place that knows how to *apply* a
|
||||
* deep link. Producers (notification taps, widget `widgetURL`, Live Activities) feed intents
|
||||
* in via {@link useDeepLinkSource}; the surfaces that can satisfy them register imperative
|
||||
* handlers via {@link useDeepLinkHandlers}. Session/new-session navigation goes straight to
|
||||
* the session store (always available), so those resolve even before the shell has mounted.
|
||||
*
|
||||
* Intents that arrive before the app is ready (cold launch from a tap/widget) or before their
|
||||
* handler is registered are stashed in a module-level holder that survives the connect flow
|
||||
* and SyncProvider remount, then applied as soon as the app becomes ready / the handler
|
||||
* appears. Only the most recent intent is kept (newest wins) — a burst of taps shouldn't queue.
|
||||
*/
|
||||
|
||||
export interface DeepLinkHandlers {
|
||||
/** Open the sessions sheet, optionally pre-filtered (filter support is best-effort for now). */
|
||||
openSessions?: (filter?: SessionsFilter) => void;
|
||||
/** Open a non-session surface (files / mcp / instances / update). */
|
||||
openView?: (target: ViewTarget) => void;
|
||||
/** Open the Changes surface, optionally jumping straight to a file diff. */
|
||||
openChanges?: (options?: { path?: string; staged?: boolean }) => void;
|
||||
/** Open Settings, optionally at a specific section. */
|
||||
openSettings?: (section?: string) => void;
|
||||
}
|
||||
|
||||
let handlers: DeepLinkHandlers = {};
|
||||
let ready = false;
|
||||
let pending: DeepLinkIntent | null = null;
|
||||
|
||||
const execute = (intent: DeepLinkIntent): boolean => {
|
||||
switch (intent.type) {
|
||||
case 'session':
|
||||
void useSessionUIStore.getState().setCurrentSession(intent.sessionId, intent.directory ?? null);
|
||||
return true;
|
||||
|
||||
case 'new-session': {
|
||||
const store = useSessionUIStore.getState();
|
||||
store.openNewSessionDraft();
|
||||
if (intent.directory || intent.projectId) {
|
||||
store.setNewSessionDraftTarget({
|
||||
directoryOverride: intent.directory ?? null,
|
||||
projectId: intent.projectId ?? null,
|
||||
selectedProjectId: intent.projectId ?? null,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'sessions':
|
||||
if (!handlers.openSessions) return false;
|
||||
handlers.openSessions(intent.filter);
|
||||
return true;
|
||||
|
||||
case 'status':
|
||||
// The session status panel is store-backed (useUIStore.mobileSessionPanelOpen),
|
||||
// so it opens without a shell handler — like session/new-session.
|
||||
useUIStore.getState().setMobileSessionPanelOpen(true);
|
||||
return true;
|
||||
|
||||
case 'view':
|
||||
if (!handlers.openView) return false;
|
||||
handlers.openView(intent.target);
|
||||
return true;
|
||||
|
||||
case 'changes':
|
||||
if (!handlers.openChanges) return false;
|
||||
handlers.openChanges({ path: intent.path, staged: intent.staged });
|
||||
return true;
|
||||
|
||||
case 'settings':
|
||||
if (!handlers.openSettings) return false;
|
||||
handlers.openSettings(intent.section);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const flush = (): void => {
|
||||
if (!ready || !pending) return;
|
||||
const intent = pending;
|
||||
// Drop the stash before executing; if the handler isn't registered yet, execute() returns
|
||||
// false and we re-stash so a later registerDeepLinkHandlers() flush can retry it.
|
||||
pending = null;
|
||||
if (!execute(intent)) {
|
||||
pending = intent;
|
||||
}
|
||||
};
|
||||
|
||||
/** Apply an intent now if possible, otherwise stash it until the app is ready / a handler appears. */
|
||||
export const applyDeepLinkIntent = (intent: DeepLinkIntent): void => {
|
||||
pending = intent;
|
||||
flush();
|
||||
};
|
||||
|
||||
/** Convenience: parse a raw `openchamber://…` URL and apply it. No-op for unrecognised URLs. */
|
||||
export const applyDeepLinkUrl = (raw: string | null | undefined): void => {
|
||||
const intent = parseDeepLink(raw);
|
||||
if (intent) {
|
||||
applyDeepLinkIntent(intent);
|
||||
}
|
||||
};
|
||||
|
||||
const setReady = (value: boolean): void => {
|
||||
ready = value;
|
||||
flush();
|
||||
};
|
||||
|
||||
/**
|
||||
* Register the surfaces that can satisfy shell-scoped intents (sessions/settings/views/changes).
|
||||
* Call from the component that owns those panels; the handlers are torn down on unmount.
|
||||
* Registering also flushes any pending intent that was waiting for these handlers.
|
||||
*/
|
||||
export const useDeepLinkHandlers = (next: DeepLinkHandlers): void => {
|
||||
React.useEffect(() => {
|
||||
handlers = next;
|
||||
flush();
|
||||
return () => {
|
||||
if (handlers === next) {
|
||||
handlers = {};
|
||||
}
|
||||
};
|
||||
}, [next]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Single native entry point for deep links. Subscribes to both the custom URL scheme
|
||||
* (`App.appUrlOpen` — widgets, Live Activities, external links) and notification taps
|
||||
* (`pushNotificationActionPerformed`), normalising each into a {@link DeepLinkIntent}.
|
||||
* Both listeners are registered UNCONDITIONALLY so a cold-launch tap/open isn't lost while
|
||||
* the app is still connecting; intents stash until `ready` (connected + initialized).
|
||||
*/
|
||||
export const useDeepLinkSource = (options: { ready: boolean }): void => {
|
||||
const { ready: isReady } = options;
|
||||
|
||||
React.useEffect(() => {
|
||||
setReady(isReady);
|
||||
}, [isReady]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isCapacitorApp()) return;
|
||||
let disposed = false;
|
||||
const cleanup: Array<() => void> = [];
|
||||
|
||||
void import('@capacitor/app')
|
||||
.then(async ({ App }) => {
|
||||
if (disposed) return;
|
||||
const handle = await App.addListener('appUrlOpen', (event) => {
|
||||
applyDeepLinkUrl(event?.url);
|
||||
});
|
||||
if (disposed) {
|
||||
void handle.remove();
|
||||
return;
|
||||
}
|
||||
cleanup.push(() => void handle.remove());
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
void import('@capacitor/push-notifications')
|
||||
.then(async ({ PushNotifications }) => {
|
||||
if (disposed) return;
|
||||
const handle = await PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
|
||||
const data = action?.notification?.data as Record<string, unknown> | undefined;
|
||||
// Prefer an explicit deep link in the payload (richest); fall back to a bare
|
||||
// sessionId for backwards compatibility with existing push senders.
|
||||
const url = typeof data?.url === 'string' ? data.url : typeof data?.deeplink === 'string' ? data.deeplink : undefined;
|
||||
if (url) {
|
||||
applyDeepLinkUrl(url);
|
||||
return;
|
||||
}
|
||||
const sessionId = typeof data?.sessionId === 'string' ? data.sessionId : undefined;
|
||||
if (sessionId) {
|
||||
applyDeepLinkIntent({ type: 'session', sessionId });
|
||||
}
|
||||
});
|
||||
if (disposed) {
|
||||
void handle.remove();
|
||||
return;
|
||||
}
|
||||
cleanup.push(() => void handle.remove());
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup.forEach((remove) => remove());
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
// Re-export so producers (notifications, future widgets) have one import for the whole vocabulary.
|
||||
export { buildDeepLink, parseDeepLink };
|
||||
export type { DeepLinkIntent, SessionsFilter, ViewTarget };
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* OpenChamber deep-link vocabulary — the single source of truth for the `openchamber://`
|
||||
* URL scheme used across every native entry point: notification taps, home-screen / lock-
|
||||
* screen widgets, and (later) Live Activities. Anything that wants to drive navigation
|
||||
* builds a URL with {@link buildDeepLink} and anything that receives one parses it with
|
||||
* {@link parseDeepLink} into a typed {@link DeepLinkIntent}; the navigation layer
|
||||
* (deepLinkNavigation) is the only place that knows how to *apply* an intent.
|
||||
*
|
||||
* Keep this file pure (no React, no stores, no Capacitor) so it can be imported from any
|
||||
* context — including, eventually, a tiny encoder shared with the native widget/extension.
|
||||
*/
|
||||
|
||||
export const DEEP_LINK_SCHEME = 'openchamber';
|
||||
|
||||
export type SessionsFilter = 'all' | 'attention' | 'recent';
|
||||
export type ViewTarget = 'files' | 'mcp' | 'instances' | 'update';
|
||||
|
||||
/**
|
||||
* Every navigable destination the app exposes to the outside world. New widget/notification
|
||||
* ideas should add a variant here first, then teach deepLinkNavigation how to apply it —
|
||||
* that keeps the "blocks" composable without leaking ad-hoc URL parsing into features.
|
||||
*/
|
||||
export type DeepLinkIntent =
|
||||
| { type: 'session'; sessionId: string; directory?: string }
|
||||
| { type: 'new-session'; directory?: string; projectId?: string; agent?: string; model?: string }
|
||||
| { type: 'sessions'; filter?: SessionsFilter }
|
||||
| { type: 'status' }
|
||||
| { type: 'settings'; section?: string }
|
||||
| { type: 'changes'; path?: string; staged?: boolean }
|
||||
| { type: 'view'; target: ViewTarget };
|
||||
|
||||
const trimSlashes = (value: string): string => value.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
const segmentsOf = (url: URL): string[] => {
|
||||
// Custom-scheme URLs put the first route token in `host` (openchamber://session/<id>),
|
||||
// but be tolerant of authority-less forms (openchamber:/session/<id>) where it lands in
|
||||
// the pathname instead.
|
||||
const pathSegments = trimSlashes(url.pathname).split('/').filter(Boolean);
|
||||
if (url.host) {
|
||||
return [url.host, ...pathSegments];
|
||||
}
|
||||
return pathSegments;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a raw `openchamber://…` string into a typed intent, or `null` if it isn't a
|
||||
* recognised OpenChamber deep link. Tolerant by design: unknown routes return `null`
|
||||
* rather than throwing, so callers can fall back without a try/catch.
|
||||
*/
|
||||
export function parseDeepLink(raw: string | null | undefined): DeepLinkIntent | null {
|
||||
if (typeof raw !== 'string' || raw.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol !== `${DEEP_LINK_SCHEME}:`) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = segmentsOf(url);
|
||||
const route = (segments[0] ?? '').toLowerCase();
|
||||
const rest = segments.slice(1);
|
||||
const query = url.searchParams;
|
||||
|
||||
switch (route) {
|
||||
case 'session': {
|
||||
const sessionId = rest[0] || query.get('id') || '';
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
return { type: 'session', sessionId, directory: query.get('dir') ?? undefined };
|
||||
}
|
||||
|
||||
case 'new':
|
||||
case 'new-session':
|
||||
return {
|
||||
type: 'new-session',
|
||||
directory: query.get('dir') ?? undefined,
|
||||
projectId: query.get('project') ?? undefined,
|
||||
agent: query.get('agent') ?? undefined,
|
||||
model: query.get('model') ?? undefined,
|
||||
};
|
||||
|
||||
case 'sessions': {
|
||||
const filter = query.get('filter');
|
||||
return {
|
||||
type: 'sessions',
|
||||
filter: filter === 'attention' || filter === 'recent' || filter === 'all' ? filter : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case 'status':
|
||||
return { type: 'status' };
|
||||
|
||||
case 'settings':
|
||||
return { type: 'settings', section: rest[0] || query.get('section') || undefined };
|
||||
|
||||
case 'changes':
|
||||
return {
|
||||
type: 'changes',
|
||||
path: rest.join('/') || query.get('path') || undefined,
|
||||
staged: query.get('staged') === 'true',
|
||||
};
|
||||
|
||||
case 'view': {
|
||||
const target = (rest[0] || '').toLowerCase();
|
||||
// `changes` has its own richer intent (diff path); route the bare view token to it.
|
||||
if (target === 'changes') {
|
||||
return { type: 'changes' };
|
||||
}
|
||||
if (target === 'files' || target === 'mcp' || target === 'instances' || target === 'update') {
|
||||
return { type: 'view', target };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a canonical `openchamber://…` URL for an intent. Used by anything that needs to hand
|
||||
* a deep link to iOS — notification payloads, `widgetURL(...)`, Live Activity tap targets —
|
||||
* so every producer emits the exact shape {@link parseDeepLink} understands.
|
||||
*/
|
||||
export function buildDeepLink(intent: DeepLinkIntent): string {
|
||||
const base = `${DEEP_LINK_SCHEME}://`;
|
||||
const withQuery = (path: string, params: Record<string, string | undefined>): string => {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
search.set(key, value);
|
||||
}
|
||||
}
|
||||
const query = search.toString();
|
||||
return query ? `${base}${path}?${query}` : `${base}${path}`;
|
||||
};
|
||||
|
||||
switch (intent.type) {
|
||||
case 'session':
|
||||
return withQuery(`session/${encodeURIComponent(intent.sessionId)}`, { dir: intent.directory });
|
||||
case 'new-session':
|
||||
return withQuery('new', {
|
||||
dir: intent.directory,
|
||||
project: intent.projectId,
|
||||
agent: intent.agent,
|
||||
model: intent.model,
|
||||
});
|
||||
case 'sessions':
|
||||
return withQuery('sessions', { filter: intent.filter });
|
||||
case 'status':
|
||||
return `${base}status`;
|
||||
case 'settings':
|
||||
return intent.section ? `${base}settings/${encodeURIComponent(intent.section)}` : `${base}settings`;
|
||||
case 'changes':
|
||||
return withQuery(intent.path ? `changes/${intent.path}` : 'changes', {
|
||||
staged: intent.staged ? 'true' : undefined,
|
||||
});
|
||||
case 'view':
|
||||
return `${base}view/${intent.target}`;
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,6 @@ export const DedicatedMobileAppProvider: React.FC<{
|
||||
<DedicatedMobileAppContext.Provider value={actions}>{children}</DedicatedMobileAppContext.Provider>
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns true when the surrounding tree is the dedicated MobileApp root
|
||||
* (Capacitor or hosted /mobile.html), as opposed to the desktop responsive
|
||||
* mobile path. Use this to suppress UI that exists only to bridge the
|
||||
* desktop sidebar/layout into mobile, since the dedicated mobile root has
|
||||
* its own native-feeling navigation and no sidebars to bridge into.
|
||||
*/
|
||||
export const useIsDedicatedMobileApp = (): boolean => React.useContext(DedicatedMobileAppContext) !== null;
|
||||
|
||||
/**
|
||||
* Returns the dedicated mobile app's surface-opening actions, or null when
|
||||
* not inside the dedicated mobile root. Components living in shared chat /
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { loadMobileConnections, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
const createLocalStorageStub = () => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { store.set(key, value); },
|
||||
removeItem: (key: string) => { store.delete(key); },
|
||||
};
|
||||
};
|
||||
|
||||
const installTestWindow = () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
location: { protocol: 'https:' },
|
||||
localStorage: createLocalStorageStub(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const restoreGlobals = () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'openchamber.mobile.connections.v1';
|
||||
|
||||
const testRelay: MobileRelayConfig = {
|
||||
relayUrl: 'wss://relay.example/tunnel',
|
||||
serverId: 'srv_test123',
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' },
|
||||
};
|
||||
|
||||
describe('mobile connection storage', () => {
|
||||
test('entries persisted before candidates migrate to a single direct candidate', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([
|
||||
{ id: 'a', label: 'Home', url: 'http://192.168.1.10:2606', lastUsedAt: 10, clientToken: 'tok-a' },
|
||||
{ id: 'b', label: 'Work', url: 'http://work.example', lastUsedAt: 5 },
|
||||
]));
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections).toHaveLength(2);
|
||||
const home = connections.find((c) => c.id === 'a')!;
|
||||
expect(home.candidates).toEqual([{ kind: 'direct', url: 'http://192.168.1.10:2606' }]);
|
||||
expect(home.clientToken).toBe('tok-a');
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a relay device round-trips its candidate + token', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
|
||||
await upsertMobileConnection({
|
||||
label: 'My Desktop',
|
||||
candidates: [{ kind: 'relay', relay: testRelay }],
|
||||
clientToken: 'oc_client_secret',
|
||||
});
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections).toHaveLength(1);
|
||||
const saved = connections[0]!;
|
||||
expect(saved.candidates).toEqual([{ kind: 'relay', relay: testRelay }]);
|
||||
// Web surface: token stays inline like direct connections.
|
||||
expect(saved.clientToken).toBe('oc_client_secret');
|
||||
|
||||
// Persisted metadata carries only the three transport fields — no grant/token.
|
||||
const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]') as Array<Record<string, unknown>>;
|
||||
const rawCandidate = (raw[0]?.candidates as Array<Record<string, unknown>>)[0];
|
||||
expect(rawCandidate.kind).toBe('relay');
|
||||
expect(Object.keys(rawCandidate.relay as object).sort()).toEqual(['hostEncPubJwk', 'relayUrl', 'serverId']);
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a multi-transport device persists all candidates in order (LAN then relay)', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
await upsertMobileConnection({
|
||||
label: 'Both',
|
||||
candidates: [{ kind: 'direct', url: 'http://192.168.1.5:2606' }, { kind: 'relay', relay: testRelay }],
|
||||
clientToken: 'tok',
|
||||
});
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections[0]?.candidates.map((c) => c.kind)).toEqual(['direct', 'relay']);
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('a legacy relay entry with malformed transport config is dropped, direct entries survive', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([
|
||||
{ id: 'bad', label: 'Broken', lastUsedAt: 20, mode: 'relay', relay: { relayUrl: 'wss://relay.example' } },
|
||||
{ id: 'ok', label: 'Home', url: 'http://192.168.1.10:2606', lastUsedAt: 10 },
|
||||
]));
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections).toHaveLength(1);
|
||||
expect(connections[0]?.id).toBe('ok');
|
||||
expect(connections[0]?.candidates[0]?.kind).toBe('direct');
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('relay and direct devices dedupe independently by candidate identity', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
await upsertMobileConnection({ label: 'Direct', candidates: [{ kind: 'direct', url: 'http://host.example' }] });
|
||||
await upsertMobileConnection({ label: 'Relay', candidates: [{ kind: 'relay', relay: testRelay }] });
|
||||
await upsertMobileConnection({ label: 'Relay renamed', candidates: [{ kind: 'relay', relay: testRelay }] });
|
||||
|
||||
const connections = await loadMobileConnections();
|
||||
expect(connections).toHaveLength(2);
|
||||
const relayEntries = connections.filter((c) => c.candidates.some((x) => x.kind === 'relay'));
|
||||
expect(relayEntries).toHaveLength(1);
|
||||
expect(relayEntries[0]?.label).toBe('Relay renamed');
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMobileConnectionSession', () => {
|
||||
test('accepts a reachable authenticated runtime', async () => {
|
||||
const fetchMock = mock(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/health')) return Response.json({ ok: true });
|
||||
if (url.endsWith('/auth/session')) return Response.json({ authenticated: true, scope: 'client' });
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
try {
|
||||
installTestWindow();
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'token' });
|
||||
expect(result).toBe(true);
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects unreachable runtimes', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
globalThis.fetch = mock(async () => new Response(null, { status: 503 })) as typeof fetch;
|
||||
|
||||
const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'token' });
|
||||
expect(result).toBe(false);
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects invalid or unauthenticated sessions', async () => {
|
||||
const fetchMock = mock(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/health')) return Response.json({ ok: true });
|
||||
return Response.json({ authenticated: false }, { status: 401 });
|
||||
});
|
||||
try {
|
||||
installTestWindow();
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'expired' });
|
||||
expect(result).toBe(false);
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { encodePairingConnectionPayload, buildPairingConnectionPayload } from '@/lib/connectionPayload';
|
||||
|
||||
import { parseConnectionPayload } from './mobileQrScan';
|
||||
|
||||
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
|
||||
|
||||
describe('parseConnectionPayload', () => {
|
||||
test('parses bare http(s) URLs', () => {
|
||||
expect(parseConnectionPayload('https://oc.example')).toEqual({ url: 'https://oc.example' });
|
||||
expect(parseConnectionPayload(' http://192.168.1.10:2606 ')).toEqual({ url: 'http://192.168.1.10:2606' });
|
||||
});
|
||||
|
||||
test('parses a v2 pairing link with direct + relay candidates', () => {
|
||||
const url = encodePairingConnectionPayload(buildPairingConnectionPayload({
|
||||
pairingId: 'pair_abc',
|
||||
secret: 'one-time',
|
||||
label: 'My Desktop',
|
||||
candidates: [
|
||||
{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 },
|
||||
{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_1', hostEncPubJwk, priority: 30 },
|
||||
],
|
||||
}));
|
||||
const payload = parseConnectionPayload(url);
|
||||
if (!payload || !('pairing' in payload)) throw new Error('expected a pairing payload');
|
||||
expect(payload.pairing.pairingId).toBe('pair_abc');
|
||||
expect(payload.pairing.secret).toBe('one-time');
|
||||
expect(payload.pairing.candidates.map((c) => c.type)).toEqual(['lan', 'relay']);
|
||||
});
|
||||
|
||||
test('rejects non-connection and legacy/relay-offer payloads', () => {
|
||||
expect(parseConnectionPayload('')).toBeNull();
|
||||
expect(parseConnectionPayload('hello world')).toBeNull();
|
||||
expect(parseConnectionPayload('openchamber://connect')).toBeNull();
|
||||
expect(parseConnectionPayload('openchamber://session/abc')).toBeNull();
|
||||
// Legacy v1 direct links are no longer accepted.
|
||||
expect(parseConnectionPayload('openchamber://connect?v=1&server=http%3A%2F%2F192.168.1.10%3A2606&token=tok')).toBeNull();
|
||||
// Legacy relay-offer format (mode=relay + fragment) is no longer accepted.
|
||||
expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay#offer=eyJ2IjoxfQ')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// Connection payload parsing + native QR scanning for the dedicated mobile app.
|
||||
//
|
||||
// Pairing v2 links (openchamber://connect?v=2&p=<base64url>) carry a one-time
|
||||
// secret and a list of transport candidates (lan / tunnel / relay); they are
|
||||
// redeemed server-side over whichever candidate connects first. We also accept a
|
||||
// bare http(s) URL so a QR encoding only the server address works.
|
||||
//
|
||||
// QR scanning is delegated to a Capacitor barcode-scanner plugin if the native
|
||||
// shell registered one (`window.Capacitor.Plugins.BarcodeScanner`). We resolve it
|
||||
// at runtime instead of importing the package so the web build stays dependency-free
|
||||
// and the browser-hosted mobile UI degrades to `unsupported` cleanly.
|
||||
|
||||
import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload';
|
||||
|
||||
export type MobileConnectionPayload = {
|
||||
url: string;
|
||||
clientToken?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type MobilePairingPayload = {
|
||||
pairing: PairingConnectionPayload;
|
||||
};
|
||||
|
||||
export type QrScanResult =
|
||||
| ({ status: 'ok' } & MobileConnectionPayload)
|
||||
| ({ status: 'pairing' } & MobilePairingPayload)
|
||||
| { status: 'cancelled' }
|
||||
| { status: 'unsupported' }
|
||||
| { status: 'permission-denied' }
|
||||
| { status: 'invalid' }
|
||||
| { status: 'failed' };
|
||||
|
||||
type ScannedBarcode = { rawValue?: string; displayValue?: string };
|
||||
|
||||
type ModuleInstallProgress = { state?: number };
|
||||
type ListenerHandle = { remove: () => void };
|
||||
|
||||
type BarcodeScannerPlugin = {
|
||||
requestPermissions?: () => Promise<{ camera?: string } | undefined>;
|
||||
scan?: (options?: { formats?: string[] }) => Promise<{ barcodes?: ScannedBarcode[] } | undefined>;
|
||||
// Android-only: the Google code scanner used by scan() needs the ML Kit barcode module,
|
||||
// which Play Services must download once before the first scan. Absent on iOS.
|
||||
isGoogleBarcodeScannerModuleAvailable?: () => Promise<{ available?: boolean } | undefined>;
|
||||
installGoogleBarcodeScannerModule?: () => Promise<void>;
|
||||
addListener?: (
|
||||
event: 'googleBarcodeScannerModuleInstallProgress',
|
||||
cb: (info: ModuleInstallProgress) => void,
|
||||
) => Promise<ListenerHandle>;
|
||||
};
|
||||
|
||||
// Google's ModuleInstallProgress states: 4 = COMPLETED, 3 = CANCELED, 5 = FAILED.
|
||||
const MODULE_STATE_COMPLETED = 4;
|
||||
const MODULE_STATE_CANCELED = 3;
|
||||
const MODULE_STATE_FAILED = 5;
|
||||
const MODULE_INSTALL_TIMEOUT_MS = 90_000;
|
||||
|
||||
// Ensure the Android Google barcode module is downloaded before scanning. No-op on platforms
|
||||
// where these methods don't exist (iOS) or when it's already available. Resolves once the module
|
||||
// is usable; rejects if the install is canceled, fails, or times out.
|
||||
const ensureScannerModule = async (plugin: BarcodeScannerPlugin): Promise<void> => {
|
||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||
if (
|
||||
capacitor?.getPlatform?.() !== 'android' ||
|
||||
!plugin.isGoogleBarcodeScannerModuleAvailable ||
|
||||
!plugin.installGoogleBarcodeScannerModule
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const status = await plugin.isGoogleBarcodeScannerModuleAvailable().catch(() => undefined);
|
||||
if (status?.available) return;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let handle: ListenerHandle | undefined;
|
||||
const finish = (fn: () => void) => {
|
||||
window.clearTimeout(timer);
|
||||
handle?.remove();
|
||||
fn();
|
||||
};
|
||||
const timer = window.setTimeout(
|
||||
() => finish(() => reject(new Error('module install timed out'))),
|
||||
MODULE_INSTALL_TIMEOUT_MS,
|
||||
);
|
||||
// addListener may return a handle synchronously OR a Promise<handle> depending on the
|
||||
// Capacitor proxy — normalize with Promise.resolve so a non-thenable handle doesn't throw
|
||||
// and abort the install call below.
|
||||
Promise.resolve(
|
||||
plugin.addListener?.('googleBarcodeScannerModuleInstallProgress', (info) => {
|
||||
if (info?.state === MODULE_STATE_COMPLETED) finish(resolve);
|
||||
else if (info?.state === MODULE_STATE_CANCELED || info?.state === MODULE_STATE_FAILED) {
|
||||
finish(() => reject(new Error('module install failed')));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.then((h) => {
|
||||
handle = h as ListenerHandle | undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
Promise.resolve(plugin.installGoogleBarcodeScannerModule?.()).catch((error) =>
|
||||
finish(() => reject(error instanceof Error ? error : new Error('module install failed'))),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getScannerPlugin = (): BarcodeScannerPlugin | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const capacitor = (window as typeof window & {
|
||||
Capacitor?: { Plugins?: Record<string, unknown> };
|
||||
}).Capacitor;
|
||||
const plugin = capacitor?.Plugins?.BarcodeScanner as BarcodeScannerPlugin | undefined;
|
||||
return plugin && typeof plugin.scan === 'function' ? plugin : null;
|
||||
};
|
||||
|
||||
export const parseConnectionPayload = (raw: string): MobileConnectionPayload | MobilePairingPayload | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^openchamber:\/\//i.test(trimmed)) {
|
||||
const pairing = parsePairingConnectionPayload(trimmed);
|
||||
return pairing ? { pairing } : null;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(trimmed)) return { url: trimmed };
|
||||
return null;
|
||||
};
|
||||
|
||||
// The Google code scanner can briefly still throw "module not available" in the moments right
|
||||
// after its install completes. Detect that specific error so we can re-ensure + retry rather
|
||||
// than surfacing a failure the user would have to manually tap through.
|
||||
const isModuleUnavailableError = (error: unknown): boolean => {
|
||||
const message =
|
||||
typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: unknown }).message ?? '')
|
||||
: String(error ?? '');
|
||||
return /module/i.test(message) && /not\s*available|unavailable/i.test(message);
|
||||
};
|
||||
|
||||
export const isQrScanSupported = (): boolean => getScannerPlugin() !== null;
|
||||
|
||||
export const scanConnectionQr = async (): Promise<QrScanResult> => {
|
||||
const plugin = getScannerPlugin();
|
||||
if (!plugin?.scan) return { status: 'unsupported' };
|
||||
|
||||
try {
|
||||
if (plugin.requestPermissions) {
|
||||
const permission = await plugin.requestPermissions();
|
||||
const camera = permission?.camera;
|
||||
if (camera && camera !== 'granted' && camera !== 'limited') {
|
||||
return { status: 'permission-denied' };
|
||||
}
|
||||
}
|
||||
|
||||
// First scan on Android downloads the Google barcode module (the button stays in its
|
||||
// scanning state for the whole wait). The module can still report "not available" for a
|
||||
// moment right after install, so re-ensure + retry within this same call instead of erroring
|
||||
// out — the user shouldn't have to guess to tap again.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await ensureScannerModule(plugin);
|
||||
const result = await plugin.scan({ formats: ['QR_CODE'] });
|
||||
const barcode = result?.barcodes?.[0];
|
||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||
if (!raw) return { status: 'cancelled' };
|
||||
|
||||
const payload = parseConnectionPayload(raw);
|
||||
if (!payload) return { status: 'invalid' };
|
||||
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
||||
return { status: 'ok', ...payload };
|
||||
} catch (error) {
|
||||
if (!isModuleUnavailableError(error) || attempt === 2) return { status: 'failed' };
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 600));
|
||||
}
|
||||
}
|
||||
return { status: 'failed' };
|
||||
} catch {
|
||||
return { status: 'failed' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
|
||||
/**
|
||||
* Builds the lightweight session overview the native iOS widgets render (home medium,
|
||||
* lock-screen, Control Center). The widget process can't see the WebView, so the native
|
||||
* shell pulls this snapshot via `window.__OPENCHAMBER_WIDGET_SNAPSHOT__()` on
|
||||
* background/activate, writes it to the shared App Group, and reloads the widget timelines
|
||||
* (see SceneDelegate.writeWidgetSnapshot). Mirrors the sidebar's attention logic so the
|
||||
* widget's "needs attention" mark matches the in-app unread dot exactly:
|
||||
* needsAttention = unseenCount > 0 && (!isSubtask || notifyOnSubtasks)
|
||||
*/
|
||||
|
||||
export interface MobileWidgetSession {
|
||||
id: string;
|
||||
title: string;
|
||||
/** True when the session needs attention (unread + honouring the subtask setting). */
|
||||
unread: boolean;
|
||||
/** Project label for the session's directory (matched project name, else folder name). */
|
||||
project: string;
|
||||
}
|
||||
|
||||
export interface MobileWidgetSnapshot {
|
||||
/** Count of sessions needing attention — same signal that drives the app-icon badge. */
|
||||
attentionCount: number;
|
||||
/** Most-recently-updated top-level sessions, newest first (capped for the medium widget). */
|
||||
recentSessions: MobileWidgetSession[];
|
||||
}
|
||||
|
||||
const RECENT_LIMIT = 6;
|
||||
|
||||
const parentIdOf = (session: Session): string | null =>
|
||||
(session as Session & { parentID?: string | null }).parentID ?? null;
|
||||
|
||||
const basename = (path: string): string => {
|
||||
const trimmed = path.replace(/\/+$/, '');
|
||||
return trimmed.slice(trimmed.lastIndexOf('/') + 1) || trimmed;
|
||||
};
|
||||
|
||||
const normalizeProjectPath = (path: string): string =>
|
||||
path.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
|
||||
/** Project label for a session directory: longest matching project's name, else the folder name. */
|
||||
const projectLabelForDirectory = (directory: string | null, projects: ProjectEntry[]): string => {
|
||||
if (!directory) return '';
|
||||
let best: ProjectEntry | null = null;
|
||||
let bestLen = -1;
|
||||
for (const project of projects) {
|
||||
const projectPath = normalizeProjectPath(project.path);
|
||||
if (directory === projectPath || directory.startsWith(`${projectPath}/`)) {
|
||||
if (projectPath.length > bestLen) {
|
||||
best = project;
|
||||
bestLen = projectPath.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
return best.label?.trim() || basename(best.path);
|
||||
}
|
||||
return basename(directory);
|
||||
};
|
||||
|
||||
export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
|
||||
const sessions = useGlobalSessionsStore.getState().activeSessions;
|
||||
const unseenBySession = useNotificationStore.getState().index.session.unseenCount;
|
||||
const notifyOnSubtasks = useUIStore.getState().notifyOnSubtasks;
|
||||
const projects = useProjectsStore.getState().projects;
|
||||
|
||||
let attentionCount = 0;
|
||||
const topLevel: Array<{ id: string; title: string; updated: number; unread: boolean; project: string }> = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
const isSubtask = parentIdOf(session) !== null;
|
||||
const unseenCount = unseenBySession[session.id] ?? 0;
|
||||
const needsAttention = unseenCount > 0 && (!isSubtask || notifyOnSubtasks);
|
||||
if (needsAttention) {
|
||||
attentionCount += 1;
|
||||
}
|
||||
if (!isSubtask) {
|
||||
topLevel.push({
|
||||
id: session.id,
|
||||
title: session.title ?? '',
|
||||
updated: session.time?.updated ?? session.time?.created ?? 0,
|
||||
unread: needsAttention,
|
||||
project: projectLabelForDirectory(resolveGlobalSessionDirectory(session), projects),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
topLevel.sort((a, b) => b.updated - a.updated);
|
||||
const recentSessions = topLevel
|
||||
.slice(0, RECENT_LIMIT)
|
||||
.map(({ id, title, unread, project }) => ({ id, title, unread, project }));
|
||||
|
||||
return { attentionCount, recentSessions };
|
||||
};
|
||||
|
||||
const SNAPSHOT_GLOBAL_KEY = '__OPENCHAMBER_WIDGET_SNAPSHOT__';
|
||||
|
||||
/**
|
||||
* Exposes the snapshot builder on `window` so the native shell can read it synchronously via
|
||||
* `evaluateJavaScript`. Returns a JSON string (the bridge wants a primitive result) or `null`
|
||||
* if building fails, so the native side can skip writing on error rather than clobber a good
|
||||
* snapshot. Safe to call in any runtime; only the native iOS shell ever invokes it.
|
||||
*/
|
||||
export const installMobileWidgetSnapshotBridge = (): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
(window as typeof window & { [SNAPSHOT_GLOBAL_KEY]?: () => string | null })[SNAPSHOT_GLOBAL_KEY] = () => {
|
||||
try {
|
||||
return JSON.stringify(buildMobileWidgetSnapshot());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -3,17 +3,21 @@ import { createRoot } from 'react-dom/client';
|
||||
import '@/styles/fonts';
|
||||
import '@/index.css';
|
||||
import '@/lib/debug';
|
||||
import { SessionAuthGate } from '@/components/auth/SessionAuthGate';
|
||||
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
|
||||
import { ThemeProvider } from '@/components/providers/ThemeProvider';
|
||||
import { ThemeSystemProvider } from '@/contexts/ThemeSystemContext';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
|
||||
import { getDeviceInfo } from '@/lib/device';
|
||||
import { markAppBootReady } from './appBootReady';
|
||||
import { installMobileWidgetSnapshotBridge } from './mobileWidgetSnapshot';
|
||||
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
||||
import { initializeLocale, I18nProvider } from '@/lib/i18n';
|
||||
import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence';
|
||||
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
|
||||
import { startTypographyWatcher } from '@/lib/typographyWatcher';
|
||||
import { preloadMarkdownRenderer } from '@/components/chat/markdownRendererLoader';
|
||||
import { SessionAuthGate } from '@/components/auth/SessionAuthGate';
|
||||
import { MobileApp } from './MobileApp';
|
||||
|
||||
const initializeSharedPreferences = () => {
|
||||
@@ -32,26 +36,56 @@ const initializeSharedPreferences = () => {
|
||||
startTypographyWatcher();
|
||||
}).catch((err) => {
|
||||
console.error('[mobile-main] appearance init failed:', err);
|
||||
}).finally(() => {
|
||||
// Persisted typography/appearance is now applied — release the splash gate so the
|
||||
// first UI paint is already at its final sizes.
|
||||
markAppBootReady();
|
||||
});
|
||||
};
|
||||
|
||||
export function renderMobileApp(apis: RuntimeAPIs) {
|
||||
preloadMarkdownRenderer();
|
||||
initializeSharedPreferences();
|
||||
|
||||
// Expose the widget snapshot builder so the native shell can read the session overview
|
||||
// (attention count + recent sessions) and feed the home/lock-screen/Control Center widgets.
|
||||
installMobileWidgetSnapshotBridge();
|
||||
|
||||
// Apply the device classes (`device-mobile`, `mobile-pointer`) to <html> BEFORE the
|
||||
// first React paint. They gate the mobile typography rules in mobile.css (larger
|
||||
// --text-* sizes); applied late from a hook effect, they bumped text size a frame
|
||||
// after mount and shifted the layout (connect / scan / saved-connection labels).
|
||||
getDeviceInfo();
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
// The native Capacitor app delivers notifications via APNs only (background, server-side
|
||||
// focus-gated). Disable the in-app notification dispatch on native with a no-op
|
||||
// notifications API: scheduling local notifications can't tell foreground from background
|
||||
// in a WKWebView and leaked while the app was open. (The Web Notifications API the web
|
||||
// runtime uses also doesn't display inside a WKWebView.)
|
||||
const capacitor = (window as typeof window & { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
|
||||
const isNativeShell = capacitor?.isNativePlatform?.() === true || window.location.protocol === 'capacitor:';
|
||||
const resolvedApis = isNativeShell
|
||||
? { ...apis, notifications: { notifyAgentCompletion: async () => false, canNotify: () => false } }
|
||||
: apis;
|
||||
|
||||
// Auth gating differs by shell: the native Capacitor app authenticates via
|
||||
// its own instance-connect flow (MobileConnectionWelcome asks for the
|
||||
// password per instance), while the plain mobile BROWSER against a
|
||||
// --ui-password server must keep the classic SessionAuthGate unlock page.
|
||||
const app = <MobileApp apis={resolvedApis} />;
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<I18nProvider>
|
||||
<ThemeSystemProvider>
|
||||
<ThemeProvider>
|
||||
<DiffWorkerProvider>
|
||||
<SessionAuthGate>
|
||||
<MobileApp apis={apis} />
|
||||
</SessionAuthGate>
|
||||
{isNativeShell ? app : <SessionAuthGate>{app}</SessionAuthGate>}
|
||||
</DiffWorkerProvider>
|
||||
</ThemeProvider>
|
||||
</ThemeSystemProvider>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeEndpointChangedDetail } from '@/lib/runtime-switch';
|
||||
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { resetStreamingState } from '@/sync/streaming';
|
||||
|
||||
// Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK
|
||||
// to the new transport WITHOUT tearing down connection/session state or remounting
|
||||
// the sync layer. `reconnectToRuntimeBaseUrl` swaps in a fresh SDK client; the
|
||||
// caller then forces a re-render so SyncProvider receives it as a new `sdk` prop,
|
||||
// which re-runs its event-pipeline + bootstrap effects (keyed on `sdk`) to
|
||||
// reconnect over the new transport IN PLACE. Message-pagination refs, the open
|
||||
// session, and the whole view are preserved — no reconnecting screen, no flash,
|
||||
// no bounce back to the draft.
|
||||
export const reconnectAppForTransportSwitch = (): void => {
|
||||
disposeTerminalInputTransport();
|
||||
opencodeClient.reconnectToRuntimeBaseUrl();
|
||||
resetStreamingState();
|
||||
};
|
||||
|
||||
export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedDetail): void => {
|
||||
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
||||
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
|
||||
if (detail.previousRuntimeKey) {
|
||||
useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey);
|
||||
}
|
||||
disposeTerminalInputTransport();
|
||||
opencodeClient.reconnectToRuntimeBaseUrl();
|
||||
useConfigStore.setState({
|
||||
providers: [],
|
||||
agents: [],
|
||||
isConnected: false,
|
||||
isInitialized: false,
|
||||
connectionPhase: 'connecting',
|
||||
lastDisconnectReason: null,
|
||||
});
|
||||
useProjectsStore.getState().resetForRuntimeSwitch();
|
||||
// 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();
|
||||
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
resetStreamingState();
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
/**
|
||||
* Native-feeling edge swipe to switch sessions in the mobile chat: start a horizontal swipe
|
||||
* from the very left/right edge and drag toward the centre to step through sessions.
|
||||
*
|
||||
* - Left edge → centre = previous session (the more-recent one in the list)
|
||||
* - Right edge → centre = next session (the older one)
|
||||
*
|
||||
* Navigation walks the same ranked list the rest of the mobile UI uses: top-level sessions
|
||||
* (no subtasks) across all projects, newest-first by `time.updated`. The order is computed at
|
||||
* gesture time from the store (not subscribed) so it's always fresh and never re-attaches.
|
||||
*
|
||||
* Only `touchstart`/`touchend` are observed (both passive), so this never interferes with
|
||||
* vertical chat scrolling or the horizontal scroll inside code blocks — it just reads where the
|
||||
* gesture began and ended. The edge zone keeps it clear of in-content horizontal scroll, which
|
||||
* lives away from the screen edges.
|
||||
*/
|
||||
|
||||
const EDGE_ZONE = 32; // px from a side where the swipe must begin
|
||||
const MIN_DISTANCE = 64; // px of horizontal travel required to commit a switch
|
||||
const MAX_OFF_AXIS_RATIO = 0.7; // |dy| must stay below |dx| * this (keep it horizontal)
|
||||
|
||||
const parentIdOf = (session: Session): string | null =>
|
||||
(session as Session & { parentID?: string | null }).parentID ?? null;
|
||||
|
||||
const updatedAt = (session: Session): number => session.time?.updated ?? session.time?.created ?? 0;
|
||||
|
||||
/** Top-level sessions across all projects, newest-first — the list the swipe walks. */
|
||||
const orderedTopLevelSessions = (): Session[] =>
|
||||
useGlobalSessionsStore
|
||||
.getState()
|
||||
.activeSessions.filter((session) => parentIdOf(session) === null)
|
||||
.slice()
|
||||
.sort((a, b) => updatedAt(b) - updatedAt(a));
|
||||
|
||||
/**
|
||||
* Switch to the session `step` positions away from the current one (clamped — no wrap).
|
||||
* Returns true if a switch actually happened.
|
||||
*/
|
||||
const switchByStep = (step: number): boolean => {
|
||||
const ordered = orderedTopLevelSessions();
|
||||
if (ordered.length < 2) return false;
|
||||
|
||||
const currentId = useSessionUIStore.getState().currentSessionId;
|
||||
const index = ordered.findIndex((session) => session.id === currentId);
|
||||
if (index < 0) return false;
|
||||
|
||||
const targetIndex = index + step;
|
||||
if (targetIndex < 0 || targetIndex >= ordered.length) return false;
|
||||
|
||||
const target = ordered[targetIndex];
|
||||
useSessionUIStore.getState().setCurrentSession(target.id, resolveGlobalSessionDirectory(target));
|
||||
return true;
|
||||
};
|
||||
|
||||
export interface EdgeSwipeSessionSwitchOptions {
|
||||
/** Called after a successful switch, with the travel direction, so the caller can animate. */
|
||||
onSwitch?: (direction: 'prev' | 'next') => void;
|
||||
}
|
||||
|
||||
export const useEdgeSwipeSessionSwitch = (
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
options?: EdgeSwipeSessionSwitchOptions,
|
||||
): void => {
|
||||
// Keep onSwitch in a ref so a changing callback identity doesn't re-attach the listeners.
|
||||
const onSwitchRef = React.useRef(options?.onSwitch);
|
||||
onSwitchRef.current = options?.onSwitch;
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
let tracking = false;
|
||||
let fromLeftEdge = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 1) {
|
||||
tracking = false;
|
||||
return;
|
||||
}
|
||||
const touch = event.touches[0];
|
||||
const width = element.clientWidth;
|
||||
const nearLeft = touch.clientX <= EDGE_ZONE;
|
||||
const nearRight = touch.clientX >= width - EDGE_ZONE;
|
||||
tracking = nearLeft || nearRight;
|
||||
fromLeftEdge = nearLeft;
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
};
|
||||
|
||||
const onTouchEnd = (event: TouchEvent) => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const dx = touch.clientX - startX;
|
||||
const dy = touch.clientY - startY;
|
||||
if (Math.abs(dx) < MIN_DISTANCE) return;
|
||||
if (Math.abs(dy) > Math.abs(dx) * MAX_OFF_AXIS_RATIO) return;
|
||||
// Must travel toward the centre: left edge → rightward, right edge → leftward.
|
||||
if (fromLeftEdge && dx <= 0) return;
|
||||
if (!fromLeftEdge && dx >= 0) return;
|
||||
|
||||
const step = fromLeftEdge ? -1 : 1;
|
||||
if (switchByStep(step)) {
|
||||
onSwitchRef.current?.(step < 0 ? 'prev' : 'next');
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
element.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
element.removeEventListener('touchstart', onTouchStart);
|
||||
element.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
}, [ref]);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useFontPreferences } from '@/hooks/useFontPreferences';
|
||||
import { loadUiFont } from '@/lib/fontLoader';
|
||||
import { appBootReadyPromise } from './appBootReady';
|
||||
|
||||
/**
|
||||
* Resolves to `true` once the first UI paint can be final — i.e. the selected UI web
|
||||
* font has loaded AND one-time appearance/typography boot work has been applied (or a
|
||||
* safety timeout elapses, so a slow/offline CDN can never block the app forever).
|
||||
*
|
||||
* Without this, the app paints immediately in the fallback font / default typography and
|
||||
* then reflows once the real font and persisted appearance prefs arrive — a visible flash
|
||||
* and micro layout shift. Hold a logo splash until this is `true` so the first UI the user
|
||||
* sees is already at its final font and sizes.
|
||||
*/
|
||||
export function useFontsReady(timeoutMs = 2500): boolean {
|
||||
const { uiFont } = useFontPreferences();
|
||||
const [ready, setReady] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const markReady = () => {
|
||||
if (!cancelled) setReady(true);
|
||||
};
|
||||
|
||||
// Wait one paint after everything settles so the applied styles are committed before
|
||||
// we reveal the UI (avoids revealing on the same frame a size/font change lands).
|
||||
const settleThenReady = () => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(markReady));
|
||||
};
|
||||
|
||||
const ready = Promise.all([
|
||||
loadUiFont(uiFont).catch(() => undefined),
|
||||
document.fonts?.ready?.then(() => undefined).catch(() => undefined) ?? Promise.resolve(),
|
||||
appBootReadyPromise.catch(() => undefined),
|
||||
]).then(() => undefined);
|
||||
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, timeoutMs);
|
||||
});
|
||||
|
||||
void Promise.race([ready, timeout]).then(settleThenReady);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [uiFont, timeoutMs]);
|
||||
|
||||
return ready;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { getClientPlatform } from '@/lib/platform';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
/**
|
||||
* Registers the native iOS APNs device token with the connected server so the app can
|
||||
* receive remote push even when suspended/closed. Delivery goes through the central relay
|
||||
* (server posts generic text → relay signs+sends) — see
|
||||
* `packages/web/server/lib/notifications/APNS.md`.
|
||||
*
|
||||
* Lazy-imports `@capacitor/push-notifications` (only present in the Capacitor shell),
|
||||
* mirroring the other `@capacitor/*` integrations in MobileApp. On `registration` the
|
||||
* device token is sent to the server via `apis.push.registerApnsToken`; tapping a push
|
||||
* deep-links to its session. Pass `enabled = isNativeMobileApp && isConnected`; the hook
|
||||
* additionally gates on the `nativeNotificationsEnabled` setting and re-registers when
|
||||
* the connection (and thus the active server endpoint) changes.
|
||||
*/
|
||||
// Native push: iOS uses APNs, Android uses FCM. Both are set up natively (google-services.json +
|
||||
// the Google Services Gradle plugin on Android), so @capacitor/push-notifications' register()
|
||||
// returns the right token per platform. The token is sent to the server tagged with its platform
|
||||
// so the relay routes it to APNs vs FCM.
|
||||
const isNativePushPlatform = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||
const platform = capacitor?.getPlatform?.();
|
||||
return platform === 'ios' || platform === 'android';
|
||||
};
|
||||
|
||||
export const useNativePushRegistration = (options: { enabled: boolean }): void => {
|
||||
const { enabled } = options;
|
||||
const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled);
|
||||
const lastTokenRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !nativeNotificationsEnabled || !isNativePushPlatform()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const cleanup: Array<() => void> = [];
|
||||
|
||||
void import('@capacitor/push-notifications')
|
||||
.then(async ({ PushNotifications }) => {
|
||||
if (disposed) return;
|
||||
|
||||
let permission = await PushNotifications.checkPermissions().catch(() => null);
|
||||
if (permission?.receive !== 'granted') {
|
||||
permission = await PushNotifications.requestPermissions().catch(() => null);
|
||||
}
|
||||
if (permission?.receive !== 'granted') {
|
||||
return;
|
||||
}
|
||||
|
||||
const registrationHandle = await PushNotifications.addListener('registration', (token) => {
|
||||
lastTokenRef.current = token.value;
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
void apis?.push?.registerApnsToken?.({ token: token.value, platform: getClientPlatform() });
|
||||
});
|
||||
|
||||
const registrationErrorHandle = await PushNotifications.addListener('registrationError', (error) => {
|
||||
console.warn('[Push] APNs registration error:', error);
|
||||
});
|
||||
|
||||
// Note: notification-tap handling lives in the deep-link layer (`useDeepLinkSource`
|
||||
// in deepLinkNavigation), registered unconditionally so cold-launch taps aren't lost
|
||||
// while disconnected.
|
||||
|
||||
await PushNotifications.register().catch(() => undefined);
|
||||
|
||||
if (disposed) {
|
||||
void registrationHandle.remove();
|
||||
void registrationErrorHandle.remove();
|
||||
return;
|
||||
}
|
||||
cleanup.push(
|
||||
() => void registrationHandle.remove(),
|
||||
() => void registrationErrorHandle.remove(),
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup.forEach((remove) => remove());
|
||||
};
|
||||
}, [enabled, nativeNotificationsEnabled]);
|
||||
|
||||
// When notifications are turned off, drop the token from the server so it stops
|
||||
// pushing to this device. (Separate from the register effect so a transient
|
||||
// disconnect doesn't unregister.)
|
||||
React.useEffect(() => {
|
||||
if (nativeNotificationsEnabled) return;
|
||||
const token = lastTokenRef.current;
|
||||
if (!token) return;
|
||||
lastTokenRef.current = null;
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
void apis?.push?.unregisterApnsToken?.({ token });
|
||||
}, [nativeNotificationsEnabled]);
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
type ComponentFn<P extends Record<string, unknown> = Record<string, unknown>> = (props: P) => unknown;
|
||||
|
||||
type HookRecord = {
|
||||
values: unknown[];
|
||||
deps: Array<unknown[] | undefined>;
|
||||
};
|
||||
|
||||
type HookEffect = () => void | (() => void);
|
||||
type HookCallback = (...args: unknown[]) => unknown;
|
||||
type JSXProps = Record<string, unknown> & { children?: unknown };
|
||||
type JSXElementType<P extends Record<string, unknown> = Record<string, unknown>> = ComponentFn<P> | string | symbol;
|
||||
|
||||
const hookRecords = new Map<unknown, HookRecord>();
|
||||
let currentRecord: HookRecord | null = null;
|
||||
let hookIndex = 0;
|
||||
let pendingEffects: Array<() => void> = [];
|
||||
|
||||
const resetHarness = () => {
|
||||
hookRecords.clear();
|
||||
currentRecord = null;
|
||||
hookIndex = 0;
|
||||
pendingEffects = [];
|
||||
};
|
||||
|
||||
const shallowEqualDeps = (left?: unknown[], right?: unknown[]): boolean => {
|
||||
if (!left || !right) return false;
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((value, index) => Object.is(value, right[index]));
|
||||
};
|
||||
|
||||
const getRecord = (component: unknown): HookRecord => {
|
||||
const existing = hookRecords.get(component);
|
||||
if (existing) return existing;
|
||||
const record: HookRecord = { values: [], deps: [] };
|
||||
hookRecords.set(component, record);
|
||||
return record;
|
||||
};
|
||||
|
||||
const getHookRecord = (): HookRecord => {
|
||||
if (!currentRecord) {
|
||||
throw new Error('Hooks can only run during a render pass');
|
||||
}
|
||||
return currentRecord;
|
||||
};
|
||||
|
||||
const renderComponent = <P extends Record<string, unknown>>(component: ComponentFn<P>, props: P): unknown => {
|
||||
const previousRecord = currentRecord;
|
||||
const previousHookIndex = hookIndex;
|
||||
currentRecord = getRecord(component);
|
||||
hookIndex = 0;
|
||||
|
||||
try {
|
||||
return component(props);
|
||||
} finally {
|
||||
currentRecord = previousRecord;
|
||||
hookIndex = previousHookIndex;
|
||||
}
|
||||
};
|
||||
|
||||
function useCallback<T extends HookCallback>(callback: T, deps?: unknown[]): T {
|
||||
const record = getHookRecord();
|
||||
const index = hookIndex++;
|
||||
const previousDeps = record.deps[index];
|
||||
if (!shallowEqualDeps(previousDeps, deps)) {
|
||||
record.values[index] = callback;
|
||||
record.deps[index] = deps;
|
||||
}
|
||||
return record.values[index] as T;
|
||||
}
|
||||
|
||||
function useEffect(effect: HookEffect, deps?: unknown[]): void {
|
||||
const record = getHookRecord();
|
||||
const index = hookIndex++;
|
||||
const previousDeps = record.deps[index];
|
||||
if (!shallowEqualDeps(previousDeps, deps)) {
|
||||
record.deps[index] = deps;
|
||||
pendingEffects.push(() => {
|
||||
effect();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function useMemo<T>(factory: () => T, deps?: unknown[]): T {
|
||||
const record = getHookRecord();
|
||||
const index = hookIndex++;
|
||||
const previousDeps = record.deps[index];
|
||||
if (!shallowEqualDeps(previousDeps, deps)) {
|
||||
record.values[index] = factory();
|
||||
record.deps[index] = deps;
|
||||
}
|
||||
return record.values[index] as T;
|
||||
}
|
||||
|
||||
function useRef<T>(initialValue: T): { current: T } {
|
||||
const record = getHookRecord();
|
||||
const index = hookIndex++;
|
||||
if (record.values[index] === undefined) {
|
||||
record.values[index] = { current: initialValue };
|
||||
}
|
||||
return record.values[index] as { current: T };
|
||||
}
|
||||
|
||||
function useState<T>(initialValue: T | (() => T)): readonly [T, (next: T | ((prev: T) => T)) => void] {
|
||||
const record = getHookRecord();
|
||||
const index = hookIndex++;
|
||||
if (record.values[index] === undefined) {
|
||||
record.values[index] = typeof initialValue === 'function'
|
||||
? (initialValue as () => T)()
|
||||
: initialValue;
|
||||
}
|
||||
|
||||
const setState = (next: T | ((prev: T) => T)) => {
|
||||
record.values[index] = typeof next === 'function'
|
||||
? (next as (prev: T) => T)(record.values[index] as T)
|
||||
: next;
|
||||
};
|
||||
|
||||
return [record.values[index] as T, setState] as const;
|
||||
}
|
||||
|
||||
function jsx<P extends Record<string, unknown>>(type: JSXElementType<P>, props: JSXProps & P): unknown {
|
||||
if (type === reactJsxRuntime.Fragment) {
|
||||
return props.children ?? null;
|
||||
}
|
||||
|
||||
if (typeof type === 'function') {
|
||||
return renderComponent(type, props as P);
|
||||
}
|
||||
|
||||
return { type, props };
|
||||
}
|
||||
|
||||
const ReactMock = {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
};
|
||||
|
||||
const reactJsxRuntime = {
|
||||
Fragment: Symbol('Fragment'),
|
||||
jsx,
|
||||
jsxs: jsx,
|
||||
jsxDEV: jsx,
|
||||
};
|
||||
|
||||
let desktopShell = false;
|
||||
let runtimeFetchRejects = true;
|
||||
|
||||
mock.module('react/jsx-runtime', () => reactJsxRuntime);
|
||||
mock.module('react/jsx-dev-runtime', () => reactJsxRuntime);
|
||||
|
||||
mock.module('react', () => ({
|
||||
__esModule: true,
|
||||
default: ReactMock,
|
||||
...ReactMock,
|
||||
}));
|
||||
|
||||
mock.module('@simplewebauthn/browser', () => ({
|
||||
browserSupportsWebAuthn: mock(() => false),
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui/button', () => ({
|
||||
Button: ({ children }: { children?: unknown }) => children ?? null,
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui/checkbox', () => ({
|
||||
Checkbox: () => null,
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui/input', () => ({
|
||||
Input: () => null,
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui', () => ({
|
||||
toast: {
|
||||
success: mock(() => undefined),
|
||||
error: mock(() => undefined),
|
||||
message: mock(() => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui/OpenChamberLogo', () => ({
|
||||
OpenChamberLogo: () => 'logo',
|
||||
}));
|
||||
|
||||
mock.module('@/components/icon/Icon', () => ({
|
||||
Icon: () => null,
|
||||
}));
|
||||
|
||||
mock.module('@/components/desktop/DesktopHostSwitcher', () => ({
|
||||
DesktopHostSwitcherInline: () => 'host-switcher',
|
||||
}));
|
||||
|
||||
mock.module('@/lib/i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/desktop', () => ({
|
||||
invokeDesktop: mock(() => Promise.resolve(null)),
|
||||
isDesktopShell: mock(() => desktopShell),
|
||||
isVSCodeRuntime: mock(() => false),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/persistence', () => ({
|
||||
initializeAppearancePreferences: mock(() => Promise.resolve()),
|
||||
syncDesktopSettings: mock(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/directoryPersistence', () => ({
|
||||
applyPersistedDirectoryPreferences: mock(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: mock(async () => {
|
||||
if (runtimeFetchRejects) {
|
||||
throw new Error('offline');
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ authenticated: false }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-auth', () => ({
|
||||
getRuntimeExtraHeadersSync: mock(() => ({})),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-switch', () => ({
|
||||
getRuntimeApiBaseUrl: mock(() => ''),
|
||||
subscribeRuntimeEndpointChanged: mock(() => () => {}),
|
||||
switchRuntimeEndpoint: mock(() => undefined),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/desktopHosts', () => ({
|
||||
desktopHostsGet: mock(() => Promise.resolve(null)),
|
||||
desktopHostsSet: mock(() => Promise.resolve()),
|
||||
getDesktopHostApiUrl: mock(() => ''),
|
||||
normalizeHostUrl: mock(() => ''),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/passkeys', () => ({
|
||||
authenticateWithPasskey: mock(() => Promise.resolve(null)),
|
||||
cancelPasskeyCeremony: mock(() => undefined),
|
||||
defaultPasskeyStatus: { enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null },
|
||||
fetchPasskeyStatus: mock(() => Promise.resolve({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null })),
|
||||
isPasskeyCeremonyAbort: mock(() => false),
|
||||
registerCurrentDevicePasskey: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
const { SessionAuthGate } = await import('./SessionAuthGate');
|
||||
|
||||
const flushEffects = async () => {
|
||||
while (pendingEffects.length > 0) {
|
||||
const effects = pendingEffects;
|
||||
pendingEffects = [];
|
||||
for (const effect of effects) {
|
||||
effect();
|
||||
}
|
||||
await Promise.resolve();
|
||||
}
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const renderGate = async () => {
|
||||
const firstPass = renderComponent(SessionAuthGate, { children: 'child' });
|
||||
await flushEffects();
|
||||
const secondPass = renderComponent(SessionAuthGate, { children: 'child' });
|
||||
await flushEffects();
|
||||
return secondPass ?? firstPass;
|
||||
};
|
||||
|
||||
const collectText = (node: unknown): string => {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return '';
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
if (Array.isArray(node)) return node.map((child) => collectText(child)).join(' ');
|
||||
if (typeof node === 'object') {
|
||||
const element = node as { props?: { children?: unknown } };
|
||||
return collectText(element.props?.children);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
describe('SessionAuthGate status-check failure behavior', () => {
|
||||
test('keeps non-desktop status-check rejection on the error screen', async () => {
|
||||
resetHarness();
|
||||
desktopShell = false;
|
||||
runtimeFetchRejects = true;
|
||||
|
||||
const tree = await renderGate();
|
||||
const text = collectText(tree);
|
||||
|
||||
expect(text).toContain('sessionAuth.error.networkTitle');
|
||||
expect(text).not.toContain('sessionAuth.locked.unlockTitle');
|
||||
});
|
||||
|
||||
test('keeps desktop-shell status-check rejection on the locked password prompt', async () => {
|
||||
resetHarness();
|
||||
desktopShell = true;
|
||||
runtimeFetchRejects = true;
|
||||
|
||||
const tree = await renderGate();
|
||||
const text = collectText(tree);
|
||||
|
||||
expect(text).toContain('sessionAuth.locked.unlockTitle');
|
||||
expect(text).not.toContain('sessionAuth.error.networkTitle');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveStatusCheckFailureState } from './sessionAuthGateState';
|
||||
|
||||
describe('resolveStatusCheckFailureState', () => {
|
||||
test('keeps the desktop-shell password login fallback intact', () => {
|
||||
expect(resolveStatusCheckFailureState({ shouldUseDesktopShellPasswordLogin: true })).toBe('locked');
|
||||
});
|
||||
|
||||
test('uses the network error screen for non-desktop status-check failures', () => {
|
||||
expect(resolveStatusCheckFailureState({})).toBe('error');
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,10 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
|
||||
import { resolveStatusCheckFailureState, type GateState } from './sessionAuthGateState';
|
||||
import {
|
||||
authenticateWithPasskey,
|
||||
cancelPasskeyCeremony,
|
||||
@@ -50,11 +52,30 @@ const shouldIssueDesktopClientToken = (): boolean => {
|
||||
return isDesktopShell();
|
||||
};
|
||||
|
||||
const isLoopbackHostname = (hostname: string): boolean => {
|
||||
const clean = hostname.replace(/^\[|\]$/g, '');
|
||||
return clean === 'localhost' || clean === '127.0.0.1' || clean === '::1';
|
||||
};
|
||||
|
||||
const isLocalDesktopRuntime = (): boolean => {
|
||||
if (!isDesktopShell()) return false;
|
||||
const apiBaseUrl = getRuntimeApiBaseUrl();
|
||||
const localOrigin = readLocalOrigin();
|
||||
return Boolean(localOrigin && sameOrigin(localOrigin, apiBaseUrl));
|
||||
if (!localOrigin) return false;
|
||||
// An empty api base means same-origin requests against the page itself —
|
||||
// which on desktop IS the embedded local server. Requiring an exact origin
|
||||
// match here used to leave local client tokens untagged (no desktop-local
|
||||
// clientKind), and the server's client-create gate then 403'd them.
|
||||
const apiBaseUrl = getRuntimeApiBaseUrl();
|
||||
const effectiveTarget = apiBaseUrl || (typeof window !== 'undefined' ? window.location.origin : '');
|
||||
if (sameOrigin(localOrigin, effectiveTarget)) return true;
|
||||
// Loopback aliases (localhost vs 127.0.0.1) still address this machine's
|
||||
// own server.
|
||||
try {
|
||||
const normalized = normalizeHostUrl(effectiveTarget);
|
||||
return Boolean(normalized && isLoopbackHostname(new URL(normalized).hostname));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const desktopClientAuthMetadata = (): { clientKind?: string; dedupeKey?: string } => {
|
||||
@@ -129,20 +150,30 @@ const shouldUseDesktopShellPasswordLogin = (): boolean => {
|
||||
return isDesktopShell() && !isLocalDesktopRuntime();
|
||||
};
|
||||
|
||||
const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise<string> => {
|
||||
type DesktopPasswordLoginResult = {
|
||||
token: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise<DesktopPasswordLoginResult | null> => {
|
||||
if (!isDesktopShell() || typeof window === 'undefined') {
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
const response = await invokeDesktop('desktop_remote_password_login', {
|
||||
url: getRuntimeApiBaseUrl(),
|
||||
password,
|
||||
trustDevice,
|
||||
requestHeaders: getRuntimeExtraHeadersSync(),
|
||||
}).catch(() => null);
|
||||
if (!response || typeof response !== 'object') {
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
const token = (response as { token?: unknown }).token;
|
||||
return typeof token === 'string' ? token.trim() : '';
|
||||
const status = (response as { status?: unknown }).status;
|
||||
return {
|
||||
token: typeof token === 'string' ? token.trim() : '',
|
||||
...(typeof status === 'number' ? { status } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string): Promise<void> => {
|
||||
@@ -180,8 +211,13 @@ const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string
|
||||
const applyDesktopClientToken = async (clientToken: string): Promise<void> => {
|
||||
if (!clientToken) return;
|
||||
const apiBaseUrl = getRuntimeApiBaseUrl();
|
||||
const requestHeaders = getRuntimeExtraHeadersSync();
|
||||
await persistDesktopClientToken(apiBaseUrl, clientToken);
|
||||
switchRuntimeEndpoint({ apiBaseUrl, clientToken });
|
||||
switchRuntimeEndpoint({
|
||||
apiBaseUrl,
|
||||
clientToken,
|
||||
requestHeaders: Object.keys(requestHeaders).length > 0 ? requestHeaders : null,
|
||||
});
|
||||
};
|
||||
|
||||
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
@@ -257,8 +293,6 @@ interface SessionAuthGateProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited';
|
||||
|
||||
interface ErrorScreenProps {
|
||||
onRetry: () => void;
|
||||
errorType?: 'network' | 'rate-limit';
|
||||
@@ -266,7 +300,9 @@ interface ErrorScreenProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
||||
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const skipAuth = vscodeRuntime;
|
||||
@@ -387,7 +423,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
setIsTunnelLocked(false);
|
||||
} catch (error) {
|
||||
console.warn('Failed to check session status:', error);
|
||||
if (shouldUseDesktopShellPasswordLogin()) {
|
||||
if (resolveStatusCheckFailureState({ shouldUseDesktopShellPasswordLogin: shouldUseDesktopShellPasswordLogin() }) === 'locked') {
|
||||
setState('locked');
|
||||
setRetryAfter(undefined);
|
||||
setIsTunnelLocked(false);
|
||||
@@ -486,15 +522,43 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
if (shouldUseDesktopShellPasswordLogin()) {
|
||||
const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice);
|
||||
if (shellLogin?.token) {
|
||||
setPassword('');
|
||||
setIsTunnelLocked(false);
|
||||
await applyDesktopClientToken(shellLogin.token);
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
if (shellLogin?.status === 401) {
|
||||
setErrorMessage(t('sessionAuth.error.incorrectPassword'));
|
||||
setIsTunnelLocked(false);
|
||||
setState('locked');
|
||||
return;
|
||||
}
|
||||
if (shellLogin?.status === 429) {
|
||||
setRetryAfter(undefined);
|
||||
setIsTunnelLocked(false);
|
||||
setState('rate-limited');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await submitPassword(password, trustDevice);
|
||||
if (response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null;
|
||||
const shouldUseClientToken = shouldIssueDesktopClientToken();
|
||||
const clientToken = shouldUseClientToken
|
||||
? (typeof payload?.clientToken === 'string' && payload.clientToken.trim()
|
||||
let clientToken = '';
|
||||
if (shouldUseClientToken) {
|
||||
clientToken = typeof payload?.clientToken === 'string' && payload.clientToken.trim()
|
||||
? payload.clientToken.trim()
|
||||
: await issueDesktopClientTokenViaShell(password, trustDevice) || await issueDesktopClientToken())
|
||||
: '';
|
||||
: '';
|
||||
if (!clientToken) {
|
||||
const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice);
|
||||
clientToken = shellLogin?.token || await issueDesktopClientToken();
|
||||
}
|
||||
}
|
||||
setPassword('');
|
||||
setIsTunnelLocked(false);
|
||||
if (clientToken) {
|
||||
@@ -541,16 +605,28 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
setState('error');
|
||||
} catch (error) {
|
||||
console.warn('Failed to submit UI password:', error);
|
||||
const clientToken = shouldUseDesktopShellPasswordLogin()
|
||||
const shellLogin = shouldUseDesktopShellPasswordLogin()
|
||||
? await issueDesktopClientTokenViaShell(password, trustDevice)
|
||||
: '';
|
||||
if (clientToken) {
|
||||
: null;
|
||||
if (shellLogin?.token) {
|
||||
setPassword('');
|
||||
setIsTunnelLocked(false);
|
||||
await applyDesktopClientToken(clientToken);
|
||||
await applyDesktopClientToken(shellLogin.token);
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
if (shellLogin?.status === 401) {
|
||||
setErrorMessage(t('sessionAuth.error.incorrectPassword'));
|
||||
setIsTunnelLocked(false);
|
||||
setState('locked');
|
||||
return;
|
||||
}
|
||||
if (shellLogin?.status === 429) {
|
||||
setRetryAfter(undefined);
|
||||
setIsTunnelLocked(false);
|
||||
setState('rate-limited');
|
||||
return;
|
||||
}
|
||||
setErrorMessage(t('sessionAuth.error.networkRetry'));
|
||||
setIsTunnelLocked(false);
|
||||
setState('error');
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited';
|
||||
|
||||
export const resolveStatusCheckFailureState = (options: {
|
||||
shouldUseDesktopShellPasswordLogin?: boolean;
|
||||
}): Exclude<GateState, 'pending' | 'authenticated' | 'rate-limited'> => {
|
||||
if (options.shouldUseDesktopShellPasswordLogin) {
|
||||
return 'locked';
|
||||
}
|
||||
|
||||
return 'error';
|
||||
};
|
||||
@@ -1,256 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface AgentInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
mode?: string | null;
|
||||
scope?: string;
|
||||
isBuiltIn?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentMentionAutocompleteHandle {
|
||||
handleKeyDown: (key: string) => void;
|
||||
}
|
||||
|
||||
type AutocompleteTab = 'commands' | 'agents' | 'files';
|
||||
|
||||
const isMentionableAgentMode = (mode?: string | null): boolean => {
|
||||
if (!mode) return false;
|
||||
return mode !== 'primary';
|
||||
};
|
||||
|
||||
interface AgentMentionAutocompleteProps {
|
||||
searchQuery: string;
|
||||
onAgentSelect: (agentName: string) => void;
|
||||
onClose: () => void;
|
||||
showTabs?: boolean;
|
||||
activeTab?: AutocompleteTab;
|
||||
onTabSelect?: (tab: AutocompleteTab) => void;
|
||||
}
|
||||
|
||||
export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocompleteHandle, AgentMentionAutocompleteProps>(({
|
||||
searchQuery,
|
||||
onAgentSelect,
|
||||
onClose,
|
||||
showTabs,
|
||||
activeTab = 'agents',
|
||||
onTabSelect,
|
||||
}, ref) => {
|
||||
const { t } = useI18n();
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const selectedIndexRef = React.useRef(0);
|
||||
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const ignoreTabClickRef = React.useRef(false);
|
||||
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||
const configAgentsCount = useConfigStore((state) => state.agents.length);
|
||||
const agentsWithMetadata = useAgentsStore((state) => state.agents);
|
||||
const loadAgents = useAgentsStore((state) => state.loadAgents);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (agentsWithMetadata.length === 0 && configAgentsCount === 0) {
|
||||
void loadAgents();
|
||||
}
|
||||
}, [loadAgents, agentsWithMetadata.length, configAgentsCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const visibleAgents = getVisibleAgents();
|
||||
const filtered = visibleAgents
|
||||
.filter((agent) => isMentionableAgentMode(agent.mode))
|
||||
.map((agent) => {
|
||||
const metadata = agentsWithMetadata.find(a => a.name === agent.name) as (AgentWithExtras & { scope?: string }) | undefined;
|
||||
return {
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
mode: agent.mode ?? undefined,
|
||||
scope: metadata?.scope,
|
||||
isBuiltIn: metadata ? isAgentBuiltIn(metadata) : false,
|
||||
};
|
||||
});
|
||||
|
||||
const normalizedQuery = searchQuery.trim();
|
||||
const matches = normalizedQuery.length
|
||||
? filtered.filter((agent) => fuzzyMatch(agent.name, normalizedQuery))
|
||||
: filtered;
|
||||
|
||||
matches.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
setAgents(matches);
|
||||
setSelectedIndex(0);
|
||||
}, [getVisibleAgents, searchQuery, agentsWithMetadata]);
|
||||
|
||||
React.useEffect(() => {
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
}, [selectedIndex]);
|
||||
|
||||
React.useEffect(() => {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
}, [selectedIndex]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!containerRef.current.contains(target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
handleKeyDown: (key: string) => {
|
||||
if (key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agents.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
setSelectedIndex((prev) => (prev + 1) % agents.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowUp') {
|
||||
setSelectedIndex((prev) => (prev - 1 + agents.length) % agents.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'Enter' || key === 'Tab') {
|
||||
const safeIndex = ((selectedIndexRef.current % agents.length) + agents.length) % agents.length;
|
||||
const agent = agents[safeIndex];
|
||||
if (agent) {
|
||||
onAgentSelect(agent.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
}), [agents, onAgentSelect, onClose]);
|
||||
|
||||
const renderAgent = (agent: AgentInfo, index: number) => {
|
||||
const isSystem = agent.isBuiltIn;
|
||||
const isProject = agent.scope === 'project';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={agent.name}
|
||||
ref={(el) => {
|
||||
itemRefs.current[index] = el;
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
||||
index === selectedIndex && 'bg-interactive-selection'
|
||||
)}
|
||||
onClick={() => onAgentSelect(agent.name)}
|
||||
onMouseMove={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">#{agent.name}</span>
|
||||
{isSystem ? (
|
||||
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
|
||||
{t('chat.agentMentionAutocomplete.badge.system')}
|
||||
</span>
|
||||
) : agent.scope ? (
|
||||
<span className={cn(
|
||||
"text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0",
|
||||
isProject
|
||||
? "bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)]"
|
||||
: "bg-[var(--status-success-background)] text-[var(--status-success)] border-[var(--status-success-border)]"
|
||||
)}>
|
||||
{agent.scope}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{agent.description && (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||
{agent.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const tabs = React.useMemo(() => ([
|
||||
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
|
||||
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
|
||||
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
|
||||
]), [t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||
>
|
||||
{showTabs ? (
|
||||
<div className="px-2 pt-2 pb-1 border-b border-border/60">
|
||||
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
|
||||
activeTab === tab.id
|
||||
? 'bg-interactive-selection text-interactive-selection-foreground shadow-none'
|
||||
: 'text-muted-foreground hover:bg-interactive-hover/50'
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== 'touch') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ignoreTabClickRef.current = true;
|
||||
onTabSelect?.(tab.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (ignoreTabClickRef.current) {
|
||||
ignoreTabClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
onTabSelect?.(tab.id);
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
||||
{agents.length ? (
|
||||
<div>
|
||||
{agents.map((agent, index) => renderAgent(agent, index))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||
{t('chat.agentMentionAutocomplete.empty')}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||
{t('chat.autocomplete.keyboardHint')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AgentMentionAutocomplete.displayName = 'AgentMentionAutocomplete';
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { BusyDots } from '@/components/chat/message/parts/BusyDots';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
export const AutoReviewBanner = memo(() => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const run = useAutoReviewStore(React.useCallback((state) => {
|
||||
if (!currentSessionId) return null;
|
||||
const run = state.runsByOriginalSessionID[currentSessionId] ?? null;
|
||||
return run?.runtimeKey === getRuntimeKey() ? run : null;
|
||||
}, [currentSessionId]));
|
||||
const stopRun = useAutoReviewStore((state) => state.stopRun);
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
|
||||
if (!currentSessionId || !run || run.status !== 'running') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusLabel = run.phase === 'waiting_for_reviewer'
|
||||
? t('chat.autoReview.status.waitingForReviewer')
|
||||
: t('chat.autoReview.status.waitingForImplementer');
|
||||
|
||||
const handleOpenReviewSession = () => {
|
||||
openContextPanelTab(run.directory, {
|
||||
mode: 'chat',
|
||||
dedupeKey: `session:${run.reviewSessionID}`,
|
||||
label: t('chat.autoReview.reviewSessionLabel'),
|
||||
readOnly: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-2 w-full px-1">
|
||||
<div className="rounded-xl border border-border/60 bg-[var(--surface-elevated)] text-[var(--surface-elevated-foreground)] shadow-sm overflow-hidden">
|
||||
<div className="flex w-full items-center gap-2 px-3 py-2 text-left">
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="typography-ui-label font-medium text-foreground">
|
||||
{t('chat.autoReview.title')}
|
||||
<BusyDots />
|
||||
</span>
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{statusLabel}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={handleOpenReviewSession}
|
||||
>
|
||||
{t('chat.autoReview.actions.open')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => stopRun(currentSessionId)}
|
||||
>
|
||||
{t('chat.autoReview.actions.stop')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AutoReviewBanner.displayName = 'AutoReviewBanner';
|
||||
@@ -14,6 +14,7 @@ import MessageList, { type MessageListHandle } from './MessageList';
|
||||
import { PermissionCard } from './PermissionCard';
|
||||
import { QuestionCard } from './QuestionCard';
|
||||
import { StatusRowContainer } from './StatusRowContainer';
|
||||
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
@@ -46,6 +47,7 @@ import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-pre
|
||||
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||
import { usePlanDetection } from '@/hooks/usePlanDetection';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
|
||||
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
@@ -157,6 +159,8 @@ type ChatViewportProps = {
|
||||
sessionQuestions: QuestionRequest[];
|
||||
sessionPermissions: PermissionRequest[];
|
||||
isProgrammaticFollowActive: boolean;
|
||||
showLoadOlderButton: boolean;
|
||||
onLoadOlder: () => void;
|
||||
};
|
||||
|
||||
const ChatViewport = React.memo(({
|
||||
@@ -181,7 +185,10 @@ const ChatViewport = React.memo(({
|
||||
sessionQuestions,
|
||||
sessionPermissions,
|
||||
isProgrammaticFollowActive,
|
||||
showLoadOlderButton,
|
||||
onLoadOlder,
|
||||
}: ChatViewportProps) => {
|
||||
const { t } = useI18n();
|
||||
const focusScrollContainer = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) {
|
||||
return;
|
||||
@@ -218,6 +225,21 @@ const ChatViewport = React.memo(({
|
||||
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
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
@@ -245,6 +267,8 @@ const ChatViewport = React.memo(({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
||||
|
||||
<div className="mb-3">
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
@@ -277,7 +301,9 @@ const ChatViewport = React.memo(({
|
||||
&& prev.scrollToBottom === next.scrollToBottom
|
||||
&& prev.sessionQuestions === next.sessionQuestions
|
||||
&& prev.sessionPermissions === next.sessionPermissions
|
||||
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive;
|
||||
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
|
||||
&& prev.showLoadOlderButton === next.showLoadOlderButton
|
||||
&& prev.onLoadOlder === next.onLoadOlder;
|
||||
});
|
||||
|
||||
ChatViewport.displayName = 'ChatViewport';
|
||||
@@ -499,10 +525,17 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
// History metadata — use sync's hasMore/isLoading
|
||||
const historyMeta = React.useMemo(() => {
|
||||
if (!currentSessionId) return null;
|
||||
const prefetchHasMore = Boolean(sessionPrefetchInfo?.cursor) && sessionPrefetchInfo?.complete !== true;
|
||||
// Sync's meta is authoritative once a fetch has confirmed the history
|
||||
// is fully loaded — a stale prefetch-cache entry (cursor recorded at
|
||||
// the initial page) must not keep the "load older" affordance alive
|
||||
// after the user has already reached the top.
|
||||
const syncComplete = sync.isComplete(currentSessionId);
|
||||
const prefetchHasMore = !syncComplete
|
||||
&& Boolean(sessionPrefetchInfo?.cursor)
|
||||
&& sessionPrefetchInfo?.complete !== true;
|
||||
return {
|
||||
limit: sessionMessages.length,
|
||||
complete: !(sync.hasMore(currentSessionId) || prefetchHasMore),
|
||||
complete: syncComplete || !(sync.hasMore(currentSessionId) || prefetchHasMore),
|
||||
loading: sync.isLoading(currentSessionId),
|
||||
};
|
||||
}, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]);
|
||||
@@ -512,7 +545,9 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
const draftOpen = Boolean(newSessionDraft?.open);
|
||||
const initError = useGlobalSyncStore((s) => s.error);
|
||||
const isDesktopExpandedInput = isExpandedInput && !isMobile;
|
||||
// Despite the historical name, this now covers mobile too: the mobile
|
||||
// composer enters the same fullscreen-input mode via its drag handle.
|
||||
const isDesktopExpandedInput = isExpandedInput;
|
||||
const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat';
|
||||
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
||||
const draftProjectLabel = React.useMemo(() => {
|
||||
@@ -568,6 +603,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
notifyContentChange: handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
goToBottom,
|
||||
scrollToBottomOnSend,
|
||||
releaseAutoFollow,
|
||||
restoreSnapshot,
|
||||
isPinned,
|
||||
@@ -598,6 +634,14 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
const resumeToLatestInstant = React.useCallback(() => {
|
||||
goToBottom('instant');
|
||||
}, [goToBottom]);
|
||||
// Mobile loads older history via an explicit top button instead of a
|
||||
// scroll-position trigger (see handleHistoryScroll in the controller).
|
||||
const showLoadOlderButton = isMobileSurfaceRuntime()
|
||||
&& timelineController.historySignals.canLoadEarlier;
|
||||
const timelineLoadEarlier = timelineController.loadEarlier;
|
||||
const handleLoadOlderClick = React.useCallback(() => {
|
||||
void timelineLoadEarlier({ userInitiated: true });
|
||||
}, [timelineLoadEarlier]);
|
||||
|
||||
React.useEffect(() => {
|
||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||
@@ -765,9 +809,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
|
||||
if (!currentSessionId && draftOpen) {
|
||||
return (
|
||||
<div className="relative flex h-full flex-col bg-background transform-gpu">
|
||||
// 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 className="relative flex h-full flex-col bg-background">
|
||||
{useCompactDraftLayout && !isDesktopExpandedInput ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
||||
{renderDraftTitle(
|
||||
draftProjectLabel
|
||||
@@ -778,7 +825,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
</h1>
|
||||
<DraftPresetChips
|
||||
onSubmit={(text) => useInputStore.getState().requestPresetSubmit(text)}
|
||||
className="mt-8 max-w-md"
|
||||
className="oc-draft-starters mt-8 max-w-md"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -792,7 +839,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
|
||||
)}
|
||||
>
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -852,7 +899,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -860,7 +907,9 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
|
||||
if (sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
return (
|
||||
<div className="relative flex flex-col h-full bg-background transform-gpu">
|
||||
// No transform here either — same fixed-positioning constraint as the
|
||||
// draft branch above.
|
||||
<div className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -885,7 +934,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -917,6 +966,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
sessionQuestions={sessionQuestions}
|
||||
sessionPermissions={sessionPermissions}
|
||||
isProgrammaticFollowActive={isFollowingProgrammatically}
|
||||
showLoadOlderButton={showLoadOlderButton}
|
||||
onLoadOlder={handleLoadOlderClick}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -933,7 +984,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
onClick={navigation.resumeToLatest}
|
||||
/>
|
||||
)}
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
|
||||
<TimelineDialog
|
||||
@@ -942,6 +993,9 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
onScrollToMessage={timelineController.scrollToMessage}
|
||||
onScrollByTurnOffset={navigation.scrollByTurnOffset}
|
||||
onResumeToLatest={resumeToLatestInstant}
|
||||
canLoadEarlier={timelineController.historySignals.canLoadEarlier}
|
||||
isLoadingEarlier={timelineController.isLoadingOlder}
|
||||
onLoadEarlier={handleLoadOlderClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
|
||||
type CommandSource = 'openchamber' | 'opencode' | 'skill';
|
||||
|
||||
@@ -49,7 +50,7 @@ const NEUTRAL_BADGE_CLASS = cn(
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
searchQuery: string;
|
||||
onCommandSelect: (command: CommandInfo, options?: { dismissKeyboard?: boolean }) => void;
|
||||
onCommandSelect: (command: CommandInfo) => void;
|
||||
onClose: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
@@ -81,6 +82,7 @@ export const CommandAutocomplete = React.forwardRef<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 ignoreClickRef = React.useRef(false);
|
||||
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
|
||||
const pointerMovedRef = React.useRef(false);
|
||||
@@ -346,9 +348,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||
style={style}
|
||||
style={mobileMaxHeight !== undefined ? { ...style, maxHeight: mobileMaxHeight } : style}
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
||||
<ScrollableOverlay preventOverscroll outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Icon name="refresh" className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
@@ -363,9 +365,15 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
key={command.id}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
className={cn(
|
||||
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
|
||||
"flex gap-2 px-3 py-2 cursor-pointer rounded-lg",
|
||||
isMobile ? "items-center" : "items-start",
|
||||
index === selectedIndex && "bg-interactive-selection"
|
||||
)}
|
||||
// Block the focus transfer the tap would perform: the textarea
|
||||
// must stay focused so selecting a command doesn't dismiss the
|
||||
// soft keyboard (the blur raced the keyboard-hide trigger and
|
||||
// won against the deferred refocus).
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== 'touch') {
|
||||
return;
|
||||
@@ -396,7 +404,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ignoreClickRef.current = true;
|
||||
onCommandSelect(command, { dismissKeyboard: true });
|
||||
onCommandSelect(command);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
pointerStartRef.current = null;
|
||||
@@ -414,7 +422,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
setSelectedIndex(index);
|
||||
}}
|
||||
>
|
||||
<div className="mt-0.5">
|
||||
<div className={cn(!isMobile && "mt-0.5")}>
|
||||
{getCommandIcon(command)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -448,7 +456,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{command.description && (
|
||||
{command.description && !isMobile && (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||
{command.description}
|
||||
</div>
|
||||
@@ -465,9 +473,11 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||
{t('chat.autocomplete.keyboardHint')}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||
{t('chat.autocomplete.keyboardHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
|
||||
export const FileAttachmentButton = memo(() => {
|
||||
const FileAttachmentButton = memo(() => {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
|
||||
@@ -471,7 +471,7 @@ export const ActiveEditorFileSuggestion = memo(() => {
|
||||
? `${selection.startLine}`
|
||||
: `${selection.startLine}-${selection.endLine}`
|
||||
}
|
||||
const selectionLabel = selection ? `${fileName}:${selectionRange}` : ''
|
||||
const selectionLabel = selection ? `${relativePath}:${selectionRange}` : ''
|
||||
const isSelectionAttached = !!selectionLabel && attachedFiles.some(
|
||||
(f) => f.source === 'vscode' && f.vscodeSource === 'selection' && f.filename === selectionLabel && f.vscodePath === filePath
|
||||
)
|
||||
@@ -912,7 +912,7 @@ interface ImageGalleryProps {
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}
|
||||
|
||||
export const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryProps) => {
|
||||
const ImageGallery = memo(({ urls, caption, onShowPopup }: ImageGalleryProps) => {
|
||||
if (urls.length === 0) return null;
|
||||
|
||||
const getGridCols = () => {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
|
||||
type FileInfo = ProjectFileSearchHit;
|
||||
type AgentInfo = {
|
||||
@@ -77,6 +79,8 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
const labelRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
|
||||
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 normalizedSearchQuery = (searchQuery ?? '').trim();
|
||||
const recentFiles = React.useMemo(() => {
|
||||
if (!projectRoot || !projectTabs) {
|
||||
@@ -442,9 +446,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[640px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||
style={style}
|
||||
style={mobileMaxHeight !== undefined ? { ...style, maxHeight: mobileMaxHeight } : style}
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||
<ScrollableOverlay preventOverscroll outerClassName="flex-1 min-h-0" className="px-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Icon name="refresh" className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
@@ -466,7 +470,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold truncate">@{agent.name}</div>
|
||||
{agent.description ? (
|
||||
{agent.description && !isMobile ? (
|
||||
<div className="typography-meta text-muted-foreground truncate">{agent.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -622,9 +626,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||
{t('chat.autocomplete.keyboardHint')}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||
{t('chat.autocomplete.keyboardHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,32 +1,50 @@
|
||||
import React from 'react';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { loadMarkdownRendererModule } from './markdownRendererLoader';
|
||||
|
||||
// Thin lazy wrapper around the MarkdownRenderer implementation.
|
||||
// The full implementation (marked + Shiki highlighting + KaTeX + morphdom
|
||||
// DOM morphing, plus beautiful-mermaid) is loaded on demand, keeping the
|
||||
// initial bundle lean.
|
||||
|
||||
export type { MarkdownVariant } from './MarkdownRendererImpl';
|
||||
|
||||
|
||||
const MarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
||||
import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer }))
|
||||
loadMarkdownRendererModule().then((m) => ({ default: m.MarkdownRenderer }))
|
||||
);
|
||||
|
||||
const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
||||
import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer }))
|
||||
loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer }))
|
||||
);
|
||||
|
||||
const fallback = <div className="break-words w-full min-w-0" />;
|
||||
|
||||
const fallbackContentClassName = (variant: unknown): string => {
|
||||
if (variant === 'tool') return 'markdown-content markdown-tool';
|
||||
if (variant === 'reasoning') return 'markdown-content markdown-reasoning';
|
||||
return 'markdown-content leading-relaxed';
|
||||
};
|
||||
|
||||
const MobileMarkdownFallback = (props: { content?: unknown; className?: unknown; variant?: unknown }) => {
|
||||
if (!isMobileSurfaceRuntime() || typeof props.content !== 'string' || props.content.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('break-words w-full min-w-0 whitespace-pre-wrap', fallbackContentClassName(props.variant), typeof props.className === 'string' ? props.className : undefined)}>
|
||||
{props.content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownRendererLazy>> = (props) => (
|
||||
<React.Suspense fallback={fallback}>
|
||||
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
|
||||
<MarkdownRendererLazy {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy>> = (props) => (
|
||||
<React.Suspense fallback={fallback}>
|
||||
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
|
||||
<SimpleMarkdownRendererLazy {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { parseFileReference, type ParsedFileReference } from './fileReferenceParser';
|
||||
|
||||
const parse = (value: string): ParsedFileReference | null => parseFileReference(value);
|
||||
|
||||
describe('parseFileReference', () => {
|
||||
test('returns null for empty or whitespace input', () => {
|
||||
expect(parse('')).toBeNull();
|
||||
expect(parse(' ')).toBeNull();
|
||||
});
|
||||
|
||||
test('parses bare path', () => {
|
||||
expect(parse('src/foo.ts')).toEqual({ path: 'src/foo.ts' });
|
||||
});
|
||||
|
||||
test('parses path with single line', () => {
|
||||
expect(parse('src/foo.ts:42')).toEqual({ path: 'src/foo.ts', line: 42 });
|
||||
});
|
||||
|
||||
test('parses path with line and column', () => {
|
||||
expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
|
||||
});
|
||||
|
||||
test('parses path with line range', () => {
|
||||
expect(parse('src/foo.ts:42-58')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
endLine: 58,
|
||||
});
|
||||
});
|
||||
|
||||
test('parses path with single-line range (start equals end)', () => {
|
||||
expect(parse('src/foo.ts:10-10')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 10,
|
||||
endLine: 10,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects range with end before start', () => {
|
||||
expect(parse('src/foo.ts:20-10')).toBeNull();
|
||||
});
|
||||
|
||||
test('falls back to path-only when range endpoint is non-numeric', () => {
|
||||
// `src/foo.ts:10-abc` and `src/foo.ts:abc-20` are malformed; the
|
||||
// line info is discarded and only the path is returned (the trailing
|
||||
// `:`-suffix is stripped).
|
||||
expect(parse('src/foo.ts:10-abc')).toEqual({ path: 'src/foo.ts' });
|
||||
expect(parse('src/foo.ts:abc-20')).toEqual({ path: 'src/foo.ts' });
|
||||
});
|
||||
|
||||
test('strips backtick and quote wrapping from range forms', () => {
|
||||
expect(parse('`src/foo.ts:10-20`')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 10,
|
||||
endLine: 20,
|
||||
});
|
||||
expect(parse('"src/foo.ts:1-3"')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 1,
|
||||
endLine: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('parses absolute Windows path with line range', () => {
|
||||
expect(parse('C:/repo/src/foo.ts:5-9')).toEqual({
|
||||
path: 'C:/repo/src/foo.ts',
|
||||
line: 5,
|
||||
endLine: 9,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves line:col form (does not interpret as range)', () => {
|
||||
expect(parse('src/foo.ts:42:8')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
column: 8,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves hash form', () => {
|
||||
expect(parse('src/foo.ts#L42C8')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
column: 8,
|
||||
});
|
||||
expect(parse('src/foo.ts#L42')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
});
|
||||
});
|
||||
|
||||
test('range form takes precedence over line-only when suffix matches digits-dash-digits', () => {
|
||||
const result = parse('src/foo.ts:42-58');
|
||||
expect(result).toEqual({ path: 'src/foo.ts', line: 42, endLine: 58 });
|
||||
});
|
||||
});
|
||||
@@ -16,17 +16,30 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import type { EditorAPI } from '@/lib/api/types';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
||||
import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
||||
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
|
||||
import {
|
||||
attachMarkdownInteractions,
|
||||
applyMarkdownCodeBlockWrapState,
|
||||
decorateMarkdown,
|
||||
scheduleMarkdownCodeLineNumberSync,
|
||||
syncMarkdownCodeLineNumbers,
|
||||
type DecorateContext,
|
||||
type DecorateLabels,
|
||||
type MermaidControlOptions,
|
||||
type MermaidRender,
|
||||
} from './markdown/decorate';
|
||||
import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMermaidViewers } from './markdown/mermaidViewer';
|
||||
import {
|
||||
BLOCK_PATH_TOKEN_RE,
|
||||
isAbsoluteReferencePath,
|
||||
normalizeReferencePath,
|
||||
parseFileReference,
|
||||
type ParsedFileReference,
|
||||
} from './fileReferenceParser';
|
||||
|
||||
const useCurrentMermaidTheme = () => {
|
||||
const themeSystem = useOptionalThemeSystem();
|
||||
@@ -92,27 +105,12 @@ const useExternalLinkInteractions = ({
|
||||
}, [containerRef, enabled]);
|
||||
};
|
||||
|
||||
type MermaidControlOptions = {
|
||||
download: boolean;
|
||||
copy: boolean;
|
||||
fullscreen: boolean;
|
||||
panZoom: boolean;
|
||||
};
|
||||
|
||||
const extractMermaidBlocks = (markdown: string): string[] => {
|
||||
if (!markdown.includes('mermaid')) return [];
|
||||
const blocks: string[] = [];
|
||||
const regex = /(?:^|\r?\n)(`{3,}|~{3,})mermaid[^\n\r]*\r?\n([\s\S]*?)\r?\n\1(?=\r?\n|$)/gi;
|
||||
let match: RegExpExecArray | null = regex.exec(markdown);
|
||||
|
||||
while (match) {
|
||||
const block = (match[2] ?? '').replace(/\s+$/, '');
|
||||
blocks.push(block);
|
||||
match = regex.exec(markdown);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = {
|
||||
download: true,
|
||||
copy: true,
|
||||
showPanZoomControls: true,
|
||||
};
|
||||
const DEFAULT_MERMAID_FULLSCREEN_ENABLED = true;
|
||||
|
||||
const stripLeadingFrontmatter = (markdown: string): string => {
|
||||
const frontmatterMatch = markdown.match(
|
||||
@@ -142,21 +140,13 @@ interface MarkdownRendererProps {
|
||||
enableFileReferences?: boolean;
|
||||
}
|
||||
|
||||
const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
|
||||
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
|
||||
const BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token';
|
||||
const BLOCK_PATH_TOKEN_SELECTOR = `[${BLOCK_PATH_TOKEN_ATTR}]`;
|
||||
const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
|
||||
// Matches `path[:line[:col]]` inside shell/grep-style output. Requires a file
|
||||
// extension (1-8 alphanumerics) so plain words don't qualify; the path itself
|
||||
// must contain at least one extension-bearing segment.
|
||||
//
|
||||
// Known limitation: backslash-separated Windows paths (e.g.
|
||||
// `C:\Users\test\file.ts:12`) are not matched because the path character class
|
||||
// does not include `\`. Compiler output inside fenced code blocks predominantly
|
||||
// uses forward slashes, so this is a niche gap. The inline-code pipeline is not
|
||||
// affected — it reads full text content rather than matching with a regex.
|
||||
const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+){0,2}/g;
|
||||
// Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style
|
||||
// output. The regex is defined in `./fileReferenceParser`; the inline-code
|
||||
// pipeline reads full text content rather than using this regex.
|
||||
const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
|
||||
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
|
||||
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
|
||||
@@ -176,12 +166,6 @@ const getFileReferenceLinkLimit = (): number => (
|
||||
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
|
||||
);
|
||||
|
||||
type ParsedFileReference = {
|
||||
path: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
};
|
||||
|
||||
const KNOWN_FILE_BASENAMES = new Set([
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
@@ -191,126 +175,19 @@ const KNOWN_FILE_BASENAMES = new Set([
|
||||
'.gitignore',
|
||||
'.npmrc',
|
||||
]);
|
||||
const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
|
||||
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('|');
|
||||
|
||||
const normalizePath = (value: string): string => {
|
||||
return normalizeFilePath(value);
|
||||
return normalizeReferencePath(value);
|
||||
};
|
||||
|
||||
const isAbsolutePath = (value: string): boolean => {
|
||||
return isAbsoluteFilePath(value);
|
||||
return isAbsoluteReferencePath(value);
|
||||
};
|
||||
|
||||
const toAbsolutePath = (basePath: string, targetPath: string): string => {
|
||||
return toAbsoluteFilePath(basePath, targetPath);
|
||||
};
|
||||
|
||||
const trimPathCandidate = (value: string): string => {
|
||||
let next = (value || '').trim();
|
||||
if (!next) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) {
|
||||
next = next.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
next = next.replace(/[.,;!?]+$/g, '');
|
||||
|
||||
if (next.endsWith(')') && !next.includes('(')) {
|
||||
next = next.slice(0, -1);
|
||||
}
|
||||
if (next.endsWith(']') && !next.includes('[')) {
|
||||
next = next.slice(0, -1);
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const stripTrailingReference = (value: string): string => {
|
||||
let next = trimPathCandidate(value);
|
||||
if (!next) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const semicolonIndex = next.indexOf(';');
|
||||
if (semicolonIndex >= 0) {
|
||||
next = next.slice(0, semicolonIndex);
|
||||
}
|
||||
|
||||
next = next.replace(/#.*$/, '');
|
||||
|
||||
const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/);
|
||||
if (extensionSuffixMatch) {
|
||||
next = extensionSuffixMatch[1] ?? next;
|
||||
}
|
||||
|
||||
const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0
|
||||
? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i'))
|
||||
: null;
|
||||
if (basenameSuffixMatch) {
|
||||
next = basenameSuffixMatch[1] ?? next;
|
||||
}
|
||||
|
||||
return trimPathCandidate(next);
|
||||
};
|
||||
|
||||
const parseFileReference = (value: string): ParsedFileReference | null => {
|
||||
const trimmed = trimPathCandidate(value);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const semicolonIndex = trimmed.indexOf(';');
|
||||
const withoutSemicolonSuffix = semicolonIndex >= 0
|
||||
? trimPathCandidate(trimmed.slice(0, semicolonIndex))
|
||||
: trimmed;
|
||||
if (!withoutSemicolonSuffix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i);
|
||||
if (hashMatch) {
|
||||
const path = stripTrailingReference(hashMatch[1] ?? '');
|
||||
const line = Number.parseInt(hashMatch[2] ?? '', 10);
|
||||
const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined;
|
||||
if (!path || !Number.isFinite(line)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
line,
|
||||
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const colonMatch = withoutSemicolonSuffix.match(/^(.*):(\d+)(?::(\d+))?$/);
|
||||
if (colonMatch) {
|
||||
const path = stripTrailingReference(colonMatch[1] ?? '');
|
||||
const line = Number.parseInt(colonMatch[2] ?? '', 10);
|
||||
const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined;
|
||||
if (!path || !Number.isFinite(line)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
line,
|
||||
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const pathOnly = stripTrailingReference(withoutSemicolonSuffix);
|
||||
if (!pathOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: pathOnly };
|
||||
};
|
||||
|
||||
const hasFileExtension = (path: string): boolean => {
|
||||
const base = path.split('/').filter(Boolean).pop() ?? '';
|
||||
if (!base || base.endsWith('.')) {
|
||||
@@ -570,6 +447,11 @@ const useFileReferenceInteractions = ({
|
||||
}
|
||||
let cancelled = false;
|
||||
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
|
||||
// On mobile surfaces, file-reference highlighting is disabled entirely — not
|
||||
// just visually. The annotation pass is what issues the filesystem `stat`
|
||||
// probes (fileReferenceExists → /api/fs/stat), so skipping it here guarantees
|
||||
// no probe requests are ever sent from a mobile runtime.
|
||||
const fileReferencesEnabled = enabled && !isMobileSurfaceRuntime();
|
||||
|
||||
const clearFileLinkAttributes = (candidate: HTMLElement) => {
|
||||
candidate.removeAttribute('data-openchamber-file-link');
|
||||
@@ -592,7 +474,7 @@ const useFileReferenceInteractions = ({
|
||||
unwrapBlockCodePathTokens(container);
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
if (!fileReferencesEnabled) {
|
||||
clearAnnotatedFileLinks();
|
||||
return;
|
||||
}
|
||||
@@ -616,7 +498,7 @@ const useFileReferenceInteractions = ({
|
||||
};
|
||||
|
||||
const annotateFileLinks = () => {
|
||||
if (enabled) {
|
||||
if (fileReferencesEnabled) {
|
||||
wrapBlockCodePathTokens(container);
|
||||
}
|
||||
const candidates = container.querySelectorAll<HTMLElement>(
|
||||
@@ -770,14 +652,16 @@ const useFileReferenceInteractions = ({
|
||||
|
||||
const useMermaidInlineInteractions = ({
|
||||
containerRef,
|
||||
mermaidBlocks,
|
||||
onShowPopup,
|
||||
allowWheelZoom,
|
||||
enableFullscreen,
|
||||
enablePanZoom,
|
||||
allowMermaidWheelEvents,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
mermaidBlocks: string[];
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
allowWheelZoom?: boolean;
|
||||
enableFullscreen?: boolean;
|
||||
enablePanZoom?: boolean;
|
||||
allowMermaidWheelEvents?: boolean;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -786,7 +670,7 @@ const useMermaidInlineInteractions = ({
|
||||
}
|
||||
|
||||
const handleMermaidClick = (event: MouseEvent) => {
|
||||
if (!onShowPopup) {
|
||||
if (!enableFullscreen || !onShowPopup) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -804,13 +688,18 @@ const useMermaidInlineInteractions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const renderedBlocks = Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR));
|
||||
const blockIndex = renderedBlocks.indexOf(block);
|
||||
if (block instanceof HTMLElement && block.hasAttribute('data-mermaid-suppress-click')) {
|
||||
block.removeAttribute('data-mermaid-suppress-click');
|
||||
return;
|
||||
}
|
||||
|
||||
const renderedBlocks = Array.from(container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR));
|
||||
const blockIndex = renderedBlocks.indexOf(block as HTMLElement);
|
||||
if (blockIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = mermaidBlocks[blockIndex];
|
||||
const source = block instanceof HTMLElement ? block.getAttribute('data-md-source') : null;
|
||||
if (!source || source.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -833,7 +722,7 @@ const useMermaidInlineInteractions = ({
|
||||
};
|
||||
|
||||
const handleInlineWheel = (event: WheelEvent) => {
|
||||
if (allowWheelZoom) {
|
||||
if (allowMermaidWheelEvents || ((event.ctrlKey || event.metaKey) && enablePanZoom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -858,7 +747,7 @@ const useMermaidInlineInteractions = ({
|
||||
container.removeEventListener('click', handleMermaidClick);
|
||||
container.removeEventListener('wheel', handleInlineWheel, true);
|
||||
};
|
||||
}, [allowWheelZoom, containerRef, mermaidBlocks, onShowPopup]);
|
||||
}, [allowMermaidWheelEvents, containerRef, enableFullscreen, enablePanZoom, onShowPopup]);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -959,25 +848,38 @@ const mermaidColorsFromTheme = (theme: Theme) => ({
|
||||
surface: theme.colors.surface.muted,
|
||||
border: theme.colors.interactive.border,
|
||||
transparent: true,
|
||||
font: 'IBM Plex Sans, sans-serif',
|
||||
font: 'system-ui, sans-serif',
|
||||
});
|
||||
|
||||
const useDecorateContext = (
|
||||
currentTheme: Theme,
|
||||
deferCodeLineNumberSync: boolean,
|
||||
onPreviewLoopback?: (url: string) => void,
|
||||
mermaidControls: MermaidControlOptions = DEFAULT_MERMAID_CONTROLS,
|
||||
): DecorateContext => {
|
||||
const { t } = useI18n();
|
||||
const labels: DecorateLabels = React.useMemo(() => ({
|
||||
copy: 'Copy code',
|
||||
copied: 'Copied',
|
||||
copy: t('markdownRenderer.code.actions.copyTitle'),
|
||||
copied: t('markdownRenderer.code.actions.copiedTitle'),
|
||||
enableCodeWrap: t('markdownRenderer.code.actions.enableWrapTitle'),
|
||||
disableCodeWrap: t('markdownRenderer.code.actions.disableWrapTitle'),
|
||||
copyTable: t('markdownRenderer.table.actions.copyTitle'),
|
||||
downloadTable: t('markdownRenderer.table.actions.downloadTitle'),
|
||||
copyDiagram: t('markdownRenderer.mermaid.actions.copySourceTitle'),
|
||||
downloadDiagram: t('markdownRenderer.mermaid.actions.downloadSvgTitle'),
|
||||
zoomInDiagram: t('markdownRenderer.mermaid.actions.zoomInTitle'),
|
||||
zoomOutDiagram: t('markdownRenderer.mermaid.actions.zoomOutTitle'),
|
||||
resetDiagramView: t('markdownRenderer.mermaid.actions.resetViewTitle'),
|
||||
previewLabel: t('terminalView.preview.open'),
|
||||
previewTitle: t('terminalView.preview.openTitle'),
|
||||
}), [t]);
|
||||
|
||||
const codeBlockLineWrap = useUIStore((state) => state.codeBlockLineWrap);
|
||||
const setCodeBlockLineWrap = useUIStore((state) => state.setCodeBlockLineWrap);
|
||||
const toggleCodeBlockLineWrap = React.useCallback(() => {
|
||||
setCodeBlockLineWrap(!useUIStore.getState().codeBlockLineWrap);
|
||||
}, [setCodeBlockLineWrap]);
|
||||
|
||||
return React.useMemo<DecorateContext>(() => {
|
||||
const colors = mermaidColorsFromTheme(currentTheme);
|
||||
const mode = useUIStore.getState().mermaidRenderingMode;
|
||||
@@ -991,8 +893,8 @@ const useDecorateContext = (
|
||||
return {};
|
||||
}
|
||||
});
|
||||
return { labels, renderMermaid, onPreviewLoopback };
|
||||
}, [currentTheme, labels, onPreviewLoopback]);
|
||||
return { labels, mermaidControls, codeBlockLineWrap, deferCodeLineNumberSync, onToggleCodeBlockLineWrap: toggleCodeBlockLineWrap, renderMermaid, onPreviewLoopback };
|
||||
}, [currentTheme, labels, mermaidControls, codeBlockLineWrap, deferCodeLineNumberSync, toggleCodeBlockLineWrap, onPreviewLoopback]);
|
||||
};
|
||||
|
||||
// Runs the async render pipeline into the container and keeps a stable
|
||||
@@ -1016,6 +918,22 @@ const useMorphdomMarkdown = ({
|
||||
ensureMarkdownShikiTheme();
|
||||
}, []);
|
||||
|
||||
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
|
||||
const refreshMermaidViewers = React.useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
if (!mermaidViewerRef.current) {
|
||||
if (!shouldRefreshMermaidViewers(container)) {
|
||||
return;
|
||||
}
|
||||
mermaidViewerRef.current = createMermaidViewerRegistry(container);
|
||||
return;
|
||||
}
|
||||
mermaidViewerRef.current.refresh();
|
||||
}, [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
|
||||
@@ -1039,8 +957,16 @@ const useMorphdomMarkdown = ({
|
||||
// the structure here keeps the async morph to syntax colors only.
|
||||
decorateMarkdown(block, ctx);
|
||||
target.appendChild(block);
|
||||
if (shouldRefreshMermaidViewers(block)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
}
|
||||
}, [containerRef, text, ctx]);
|
||||
}, [containerRef, text, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
mermaidViewerRef.current?.cleanup();
|
||||
mermaidViewerRef.current = null;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -1068,23 +994,41 @@ const useMorphdomMarkdown = ({
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = block.html;
|
||||
decorateMarkdown(temp, ctx);
|
||||
const hadMermaidBlock = shouldRefreshMermaidViewers(el);
|
||||
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
|
||||
morphdom(el, temp, {
|
||||
childrenOnly: true,
|
||||
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
|
||||
});
|
||||
el.setAttribute('data-md-id', block.id);
|
||||
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) {
|
||||
existing[i]?.remove();
|
||||
const removed = existing[i];
|
||||
if (removed && shouldRefreshMermaidViewers(removed)) {
|
||||
removedMermaidBlock = true;
|
||||
}
|
||||
removed?.remove();
|
||||
}
|
||||
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
|
||||
if (!ctx.deferCodeLineNumberSync) {
|
||||
scheduleMarkdownCodeLineNumberSync(target);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [containerRef, text, streaming, cacheKey, ctx]);
|
||||
}, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -1101,6 +1045,33 @@ const useMorphdomMarkdown = ({
|
||||
target.style.setProperty(key, value);
|
||||
}
|
||||
}, [containerRef, syntaxVars]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target) return;
|
||||
if (ctx.deferCodeLineNumberSync) return;
|
||||
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
|
||||
}, [containerRef, ctx.codeBlockLineWrap, ctx.deferCodeLineNumberSync, ctx.labels]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target || typeof ResizeObserver === 'undefined') return;
|
||||
let frame: number | null = null;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
syncMarkdownCodeLineNumbers(target);
|
||||
});
|
||||
});
|
||||
observer.observe(target);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [containerRef]);
|
||||
};
|
||||
|
||||
const markdownContentClassName = (variant: MarkdownVariant): string =>
|
||||
@@ -1137,8 +1108,12 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
const live = isStreaming && !disableStreamAnimation;
|
||||
const pacedText = usePacedText(content, live);
|
||||
|
||||
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
|
||||
useMermaidInlineInteractions({ containerRef, mermaidBlocks, onShowPopup });
|
||||
useMermaidInlineInteractions({
|
||||
containerRef,
|
||||
onShowPopup,
|
||||
enableFullscreen: DEFAULT_MERMAID_FULLSCREEN_ENABLED,
|
||||
enablePanZoom: DEFAULT_MERMAID_CONTROLS.showPanZoomControls,
|
||||
});
|
||||
useFileReferenceInteractions({
|
||||
containerRef,
|
||||
effectiveDirectory,
|
||||
@@ -1149,7 +1124,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
useExternalLinkInteractions({ containerRef });
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, effectiveDirectory ? handlePreviewLoopback : undefined);
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
|
||||
useMorphdomMarkdown({ containerRef, text: pacedText, streaming: live, cacheKey, syntaxVars, ctx });
|
||||
@@ -1193,7 +1168,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
stripFrontmatter?: boolean;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
mermaidControls?: MermaidControlOptions;
|
||||
allowMermaidWheelZoom?: boolean;
|
||||
allowMermaidWheelEvents?: boolean;
|
||||
enableFileReferences?: boolean;
|
||||
}> = ({
|
||||
content,
|
||||
@@ -1202,7 +1177,8 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
disableLinkSafety,
|
||||
stripFrontmatter = false,
|
||||
onShowPopup,
|
||||
allowMermaidWheelZoom = false,
|
||||
mermaidControls = DEFAULT_MERMAID_CONTROLS,
|
||||
allowMermaidWheelEvents = false,
|
||||
enableFileReferences = true,
|
||||
}) => {
|
||||
const { editor, runtime } = useRuntimeAPIs();
|
||||
@@ -1215,12 +1191,12 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
[content, stripFrontmatter],
|
||||
);
|
||||
|
||||
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]);
|
||||
useMermaidInlineInteractions({
|
||||
containerRef,
|
||||
mermaidBlocks,
|
||||
onShowPopup,
|
||||
allowWheelZoom: allowMermaidWheelZoom,
|
||||
enableFullscreen: DEFAULT_MERMAID_FULLSCREEN_ENABLED,
|
||||
enablePanZoom: mermaidControls.showPanZoomControls,
|
||||
allowMermaidWheelEvents,
|
||||
});
|
||||
useFileReferenceInteractions({
|
||||
containerRef,
|
||||
@@ -1232,7 +1208,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme);
|
||||
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
|
||||
|
||||
useMorphdomMarkdown({
|
||||
containerRef,
|
||||
@@ -1251,12 +1227,18 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
};
|
||||
|
||||
export const SimpleMarkdownRenderer = React.memo(SimpleMarkdownRendererImpl, (prev, next) => {
|
||||
const prevMermaidControls = prev.mermaidControls ?? DEFAULT_MERMAID_CONTROLS;
|
||||
const nextMermaidControls = next.mermaidControls ?? DEFAULT_MERMAID_CONTROLS;
|
||||
|
||||
return prev.content === next.content
|
||||
&& prev.variant === next.variant
|
||||
&& prev.className === next.className
|
||||
&& prev.disableLinkSafety === next.disableLinkSafety
|
||||
&& prev.stripFrontmatter === next.stripFrontmatter
|
||||
&& prev.onShowPopup === next.onShowPopup
|
||||
&& prev.allowMermaidWheelZoom === next.allowMermaidWheelZoom
|
||||
&& prevMermaidControls.download === nextMermaidControls.download
|
||||
&& prevMermaidControls.copy === nextMermaidControls.copy
|
||||
&& prevMermaidControls.showPanZoomControls === nextMermaidControls.showPanZoomControls
|
||||
&& prev.allowMermaidWheelEvents === next.allowMermaidWheelEvents
|
||||
&& prev.enableFileReferences === next.enableFileReferences;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { Virtualizer, type CacheSnapshot, type VirtualizerHandle } from 'virtua';
|
||||
import { elementScroll, useVirtualizer as useTanstackVirtualizer, type ReactVirtualizer, type VirtualItem } from '@tanstack/react-virtual';
|
||||
|
||||
import ChatMessage from './ChatMessage';
|
||||
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
|
||||
@@ -18,26 +18,14 @@ import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
import type { StreamPhase } from './message/types';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionParts } from '@/sync/sync-context';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
|
||||
|
||||
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
|
||||
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
|
||||
const EMPTY_UNGROUPED_MESSAGE_IDS = new Set<string>();
|
||||
const MESSAGE_LIST_BUFFER_SIZE = 900;
|
||||
const TIMELINE_CACHE_LIMIT = 16;
|
||||
|
||||
const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => {
|
||||
if (!entry) {
|
||||
return 160;
|
||||
}
|
||||
|
||||
if (entry.kind === 'turn') {
|
||||
return 180 + Math.min(entry.turn.assistantMessages.length, 4) * 100;
|
||||
}
|
||||
|
||||
return 140;
|
||||
};
|
||||
|
||||
const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean => {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
@@ -45,28 +33,83 @@ const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undef
|
||||
return a.every((key, index) => key === b[index]);
|
||||
};
|
||||
|
||||
const timelineCache = new Map<string, { keys: readonly string[]; cache: CacheSnapshot }>();
|
||||
// --- History virtualization (@tanstack/react-virtual) ----------------------
|
||||
// The history list virtualizes with @tanstack/react-virtual on all surfaces:
|
||||
// its core has bottom anchoring (anchorTo: 'end'), key-stable prepend
|
||||
// preservation, and native iOS touch/momentum deferral for scroll
|
||||
// adjustments — the failure modes that historically forced virtua off on
|
||||
// mobile and required manual prepend compensation on desktop.
|
||||
type TanstackVirtualizerInstance = ReactVirtualizer<HTMLDivElement, HTMLDivElement>;
|
||||
type HistoryEngine = 'none' | 'tanstack';
|
||||
|
||||
const readTimelineCache = (sessionKey: string, keys: readonly string[]): CacheSnapshot | undefined => {
|
||||
const entry = timelineCache.get(sessionKey);
|
||||
const TANSTACK_ESTIMATED_ENTRY_SIZE = 320;
|
||||
const TANSTACK_OVERSCAN = 8;
|
||||
// Touch flings cover more distance between paints than desktop wheels; a
|
||||
// larger window keeps fast mobile scrolling over mounted rows.
|
||||
const TANSTACK_MOBILE_OVERSCAN = 16;
|
||||
const resolveTanstackOverscan = (): number => (
|
||||
isMobileSurfaceRuntime() ? TANSTACK_MOBILE_OVERSCAN : TANSTACK_OVERSCAN
|
||||
);
|
||||
// Post-prepend anchor hold: measurements of freshly
|
||||
// prepended rows settle over multiple frames, so a single restore can be
|
||||
// invalidated by the next measurement pass. Re-assert the anchor until it
|
||||
// holds still for STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES.
|
||||
const ANCHOR_HOLD_STABLE_FRAMES = 30;
|
||||
const ANCHOR_HOLD_MAX_FRAMES = 180;
|
||||
// Adaptive estimate bounds: only trust the session average once a few rows
|
||||
// are measured, and keep it inside sane turn-height bounds.
|
||||
const TANSTACK_ESTIMATE_MIN_SAMPLES = 5;
|
||||
const TANSTACK_ESTIMATE_MIN = 120;
|
||||
const TANSTACK_ESTIMATE_MAX = 1200;
|
||||
// "At bottom" tolerance for resize-adjustment decisions.
|
||||
const TANSTACK_AT_END_THRESHOLD_PX = 80;
|
||||
|
||||
// Quiet-window prepend on mobile: while a touch drag or momentum scroll is
|
||||
// active, iOS owns the scroll position and ANY geometry change above the
|
||||
// viewport races against the native animation — a race that compensation
|
||||
// logic can only lose sometimes. So freshly loaded older history is held
|
||||
// (data already fetched, store already updated) and inserted into the
|
||||
// rendered list only once the gesture goes quiet. Safety valves: flush when
|
||||
// the user gets close to the top (a blank top is worse than a small hop) or
|
||||
// after MAX_HOLD_MS.
|
||||
const HISTORY_PREPEND_QUIET_MS = 160;
|
||||
const HISTORY_PREPEND_MAX_HOLD_MS = 1500;
|
||||
const HISTORY_PREPEND_NEAR_TOP_VIEWPORTS = 1.5;
|
||||
const HISTORY_PREPEND_MONITOR_INTERVAL_MS = 90;
|
||||
|
||||
// A commit is a deferable prepend when older entries were inserted strictly
|
||||
// above the known content: the previous first key still exists deeper in the
|
||||
// list and the tail is unchanged. Anything else renders immediately.
|
||||
const isPrependAboveCommit = (previous: RenderEntry[], next: RenderEntry[]): boolean => {
|
||||
if (previous.length === 0 || next.length <= previous.length) return false;
|
||||
if (previous[previous.length - 1]?.key !== next[next.length - 1]?.key) return false;
|
||||
const previousFirstKey = previous[0]?.key;
|
||||
const insertedIndex = next.findIndex((entry) => entry.key === previousFirstKey);
|
||||
return insertedIndex > 0;
|
||||
};
|
||||
|
||||
const tanstackTimelineCache = new Map<string, { keys: readonly string[]; items: VirtualItem[] }>();
|
||||
|
||||
const readTanstackTimelineCache = (sessionKey: string, keys: readonly string[]): VirtualItem[] | undefined => {
|
||||
const entry = tanstackTimelineCache.get(sessionKey);
|
||||
if (!entry) return undefined;
|
||||
if (sameKeys(entry.keys, keys)) return entry.cache;
|
||||
timelineCache.delete(sessionKey);
|
||||
if (sameKeys(entry.keys, keys)) return entry.items;
|
||||
tanstackTimelineCache.delete(sessionKey);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const writeTimelineCache = (
|
||||
const writeTanstackTimelineCache = (
|
||||
sessionKey: string,
|
||||
keys: readonly string[],
|
||||
handle: VirtualizerHandle | null | undefined,
|
||||
virtualizer: TanstackVirtualizerInstance | null | undefined,
|
||||
): void => {
|
||||
if (!handle || keys.length === 0) return;
|
||||
timelineCache.delete(sessionKey);
|
||||
timelineCache.set(sessionKey, { keys: keys.slice(), cache: handle.cache });
|
||||
while (timelineCache.size > TIMELINE_CACHE_LIMIT) {
|
||||
const oldest = timelineCache.keys().next().value;
|
||||
if (!virtualizer || keys.length === 0) return;
|
||||
tanstackTimelineCache.delete(sessionKey);
|
||||
tanstackTimelineCache.set(sessionKey, { keys: keys.slice(), items: virtualizer.takeSnapshot() });
|
||||
while (tanstackTimelineCache.size > TIMELINE_CACHE_LIMIT) {
|
||||
const oldest = tanstackTimelineCache.keys().next().value;
|
||||
if (typeof oldest !== 'string') break;
|
||||
timelineCache.delete(oldest);
|
||||
tanstackTimelineCache.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -158,6 +201,21 @@ const getMessageParentId = (message: ChatMessageEntry): string | null => {
|
||||
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
|
||||
};
|
||||
|
||||
const isInsideStuckSticky = (node: HTMLElement, container: HTMLElement, containerTop: number): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
let current: HTMLElement | null = node;
|
||||
while (current && current !== container) {
|
||||
const computed = window.getComputedStyle(current);
|
||||
if (computed.position === 'sticky' && current.getBoundingClientRect().top <= containerTop + 1) {
|
||||
return true;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
|
||||
if (!message) return false;
|
||||
if (resolveMessageRole(message) !== 'user') return false;
|
||||
@@ -376,6 +434,8 @@ export interface MessageListHandle {
|
||||
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
|
||||
captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
|
||||
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
|
||||
holdViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => void;
|
||||
isHistoryVirtualized: () => boolean;
|
||||
scrollToBottom: () => void;
|
||||
}
|
||||
|
||||
@@ -692,6 +752,7 @@ const TurnBlock = React.memo(({
|
||||
activityOwnerMessageId,
|
||||
isFirstAssistantInTurn: isFirstAssistant,
|
||||
isLastAssistantInTurn: isLastAssistant,
|
||||
isLatestTurn: isLastTurn,
|
||||
isWorking: isLastTurn && sessionIsWorking && (
|
||||
chatRenderMode === 'sorted'
|
||||
? hasAnchoredActivitySegment
|
||||
@@ -917,13 +978,11 @@ MessageListEntry.displayName = 'MessageListEntry';
|
||||
// Inner component that renders staged turn entries.
|
||||
type StaticHistoryListProps = {
|
||||
entries: RenderEntry[];
|
||||
shouldVirtualize: boolean;
|
||||
engine: HistoryEngine;
|
||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
virtualizerRef: React.Ref<VirtualizerHandle>;
|
||||
registerTanstackVirtualizer?: (virtualizer: TanstackVirtualizerInstance | null) => void;
|
||||
virtualizerKey: string;
|
||||
virtualCache?: CacheSnapshot;
|
||||
shift: boolean;
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
scrollToBottom?: () => void;
|
||||
@@ -937,7 +996,162 @@ type StaticHistoryListProps = {
|
||||
reviewTransferDirection?: ReviewTransferDirection | null;
|
||||
};
|
||||
|
||||
const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
|
||||
const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, registerTanstackVirtualizer, virtualizerKey, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
|
||||
const isTanstack = engine === 'tanstack';
|
||||
|
||||
// --- Quiet-window prepend (mobile) --------------------------------------
|
||||
// Gesture tracking for the deferred-prepend decision. Refs only: reading
|
||||
// them never re-renders, and the render-phase reconcile below needs them.
|
||||
const touchActiveRef = React.useRef(false);
|
||||
const lastScrollAtRef = React.useRef(0);
|
||||
const holdSinceRef = React.useRef<number | null>(null);
|
||||
const deferPrepends = isTanstack && isMobileSurfaceRuntime();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!deferPrepends) return;
|
||||
const element = scrollRef?.current;
|
||||
if (!element) return;
|
||||
const onTouchStart = () => { touchActiveRef.current = true; };
|
||||
const onTouchEnd = () => { touchActiveRef.current = false; };
|
||||
const onScroll = () => { lastScrollAtRef.current = performance.now(); };
|
||||
element.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
element.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
element.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||
element.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
element.removeEventListener('touchstart', onTouchStart);
|
||||
element.removeEventListener('touchend', onTouchEnd);
|
||||
element.removeEventListener('touchcancel', onTouchEnd);
|
||||
element.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [deferPrepends, scrollRef]);
|
||||
|
||||
const isGestureActive = React.useCallback(() => (
|
||||
touchActiveRef.current
|
||||
|| performance.now() - lastScrollAtRef.current < HISTORY_PREPEND_QUIET_MS
|
||||
), []);
|
||||
|
||||
const isNearTop = React.useCallback(() => {
|
||||
const element = scrollRef?.current;
|
||||
if (!element) return true;
|
||||
return element.scrollTop < element.clientHeight * HISTORY_PREPEND_NEAR_TOP_VIEWPORTS;
|
||||
}, [scrollRef]);
|
||||
|
||||
const [displayEntries, setDisplayEntries] = React.useState(entries);
|
||||
// Render-phase reconcile (official derived-state pattern): adopt the new
|
||||
// entries immediately unless this commit is a pure prepend-above landing
|
||||
// in the middle of an active touch gesture — those wait for quiet.
|
||||
let renderEntries = displayEntries;
|
||||
if (entries !== displayEntries) {
|
||||
const shouldHold = deferPrepends
|
||||
&& isPrependAboveCommit(displayEntries, entries)
|
||||
&& isGestureActive()
|
||||
&& !isNearTop()
|
||||
&& (holdSinceRef.current === null
|
||||
|| performance.now() - holdSinceRef.current < HISTORY_PREPEND_MAX_HOLD_MS);
|
||||
if (shouldHold) {
|
||||
if (holdSinceRef.current === null) holdSinceRef.current = performance.now();
|
||||
} else {
|
||||
holdSinceRef.current = null;
|
||||
setDisplayEntries(entries);
|
||||
renderEntries = entries;
|
||||
}
|
||||
} else if (holdSinceRef.current !== null) {
|
||||
holdSinceRef.current = null;
|
||||
}
|
||||
|
||||
// While a prepend is held, poll for the quiet window (touch/momentum have
|
||||
// no completion event we can await) and flush by re-rendering.
|
||||
const [, forceFlushTick] = React.useReducer((tick: number) => tick + 1, 0);
|
||||
React.useEffect(() => {
|
||||
if (!deferPrepends) return;
|
||||
const timer = window.setInterval(() => {
|
||||
if (holdSinceRef.current === null) return;
|
||||
const expired = performance.now() - holdSinceRef.current >= HISTORY_PREPEND_MAX_HOLD_MS;
|
||||
if (!isGestureActive() || isNearTop() || expired) {
|
||||
forceFlushTick();
|
||||
}
|
||||
}, HISTORY_PREPEND_MONITOR_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [deferPrepends, isGestureActive, isNearTop]);
|
||||
|
||||
const entriesRef = React.useRef(renderEntries);
|
||||
entriesRef.current = renderEntries;
|
||||
// Initial-only read: measurement cache restore is a mount-time concern;
|
||||
// afterwards the live virtualizer owns measurements.
|
||||
const [initialMeasurements] = React.useState(() => (
|
||||
isTanstack
|
||||
? readTanstackTimelineCache(virtualizerKey, entries.map((entry) => entry.key))
|
||||
: undefined
|
||||
));
|
||||
|
||||
const sizeContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
// Adaptive estimate: rows this session has actually measured are a far
|
||||
// better predictor for the still-unmeasured ones than a fixed constant.
|
||||
// Smaller estimate error → smaller anchor corrections when prepended rows
|
||||
// measure in → less visible drift. The ref keeps estimateSize's identity
|
||||
// stable so updating the average never triggers a global remeasure.
|
||||
const estimatedEntrySizeRef = React.useRef(TANSTACK_ESTIMATED_ENTRY_SIZE);
|
||||
const tanstackVirtualizer = useTanstackVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: renderEntries.length,
|
||||
enabled: isTanstack,
|
||||
getScrollElement: () => scrollRef?.current ?? null,
|
||||
estimateSize: () => estimatedEntrySizeRef.current,
|
||||
overscan: resolveTanstackOverscan(),
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
// Expose the new total height before core writes an anchor
|
||||
// correction so the browser does not clamp the offset to the old
|
||||
// height.
|
||||
const sizeElement = sizeContainerRef.current;
|
||||
if (sizeElement) sizeElement.style.height = `${instance.getTotalSize()}px`;
|
||||
elementScroll(offset, options, instance);
|
||||
},
|
||||
getItemKey: (index) => entriesRef.current[index]?.key ?? `index:${index}`,
|
||||
// Bottom-anchored chat semantics: prepending older entries above the
|
||||
// viewport must not move what the user is reading, and iOS-specific
|
||||
// touch/momentum deferral for those adjustments lives in the core.
|
||||
anchorTo: 'end',
|
||||
initialOffset: () => Number.MAX_SAFE_INTEGER,
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
});
|
||||
// Only compensate scroll for rows growing ABOVE the viewport (history
|
||||
// remeasures, prepended pages). A row growing inside the viewport —
|
||||
// expanding a tool call or thinking block — must grow DOWNWARD naturally;
|
||||
// the end-anchored default made it expand upward. At the bottom,
|
||||
// app-level auto-follow owns pinning, so skip there too instead of
|
||||
// double-writing. (This is an instance field, not a constructor option.)
|
||||
tanstackVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
|
||||
if (instance.isAtEnd(TANSTACK_AT_END_THRESHOLD_PX)) return false;
|
||||
const firstVisibleIndex = instance.range?.startIndex;
|
||||
return firstVisibleIndex !== undefined && item.index < firstVisibleIndex;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTanstack) return;
|
||||
const sizes = tanstackVirtualizer.itemSizeCache;
|
||||
if (sizes.size >= TANSTACK_ESTIMATE_MIN_SAMPLES) {
|
||||
let total = 0;
|
||||
for (const size of sizes.values()) total += size;
|
||||
estimatedEntrySizeRef.current = Math.min(
|
||||
TANSTACK_ESTIMATE_MAX,
|
||||
Math.max(TANSTACK_ESTIMATE_MIN, Math.round(total / sizes.size)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTanstack) return;
|
||||
registerTanstackVirtualizer?.(tanstackVirtualizer);
|
||||
return () => {
|
||||
writeTanstackTimelineCache(
|
||||
virtualizerKey,
|
||||
entriesRef.current.map((entry) => entry.key),
|
||||
tanstackVirtualizer,
|
||||
);
|
||||
registerTanstackVirtualizer?.(null);
|
||||
};
|
||||
}, [isTanstack, registerTanstackVirtualizer, tanstackVirtualizer, virtualizerKey]);
|
||||
|
||||
const renderEntry = React.useCallback((entry: RenderEntry) => {
|
||||
return (
|
||||
<MessageListEntry
|
||||
@@ -961,10 +1175,10 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, s
|
||||
);
|
||||
}, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
|
||||
|
||||
if (!shouldVirtualize) {
|
||||
if (engine === 'none') {
|
||||
return (
|
||||
<div ref={contentRef} className="relative w-full">
|
||||
{entries.map((entry) => (
|
||||
{renderEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
data-turn-entry={entry.key}
|
||||
@@ -976,24 +1190,40 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, s
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Virtualizer
|
||||
key={virtualizerKey}
|
||||
ref={virtualizerRef}
|
||||
data={entries}
|
||||
cache={virtualCache}
|
||||
itemSize={virtualCache ? undefined : estimateHistoryEntryHeight(undefined)}
|
||||
bufferSize={MESSAGE_LIST_BUFFER_SIZE}
|
||||
shift={shift}
|
||||
scrollRef={scrollRef}
|
||||
>
|
||||
{(entry) => (
|
||||
<div key={entry.key} data-turn-entry={entry.key}>
|
||||
{renderEntry(entry)}
|
||||
if (engine === 'tanstack') {
|
||||
const virtualItems = tanstackVirtualizer.getVirtualItems();
|
||||
const startOffset = virtualItems[0]?.start ?? 0;
|
||||
// Rendered rows stay in normal flow inside a single offset wrapper (not
|
||||
// per-row absolute positioning) so per-turn sticky user headers keep
|
||||
// working against the scroll container. The offset MUST be padding, not
|
||||
// transform: a transformed ancestor becomes the sticky containing block,
|
||||
// so headers would stick to the wrapper's (arbitrary, overscan-dependent)
|
||||
// top edge mid-list and float over the previous turn. Padding only
|
||||
// changes when the virtual window shifts — not per scroll frame — so the
|
||||
// layout cost is negligible.
|
||||
return (
|
||||
<div ref={sizeContainerRef} className="relative w-full" style={{ height: tanstackVirtualizer.getTotalSize() }}>
|
||||
<div style={{ paddingTop: `${startOffset}px` }}>
|
||||
{virtualItems.map((item) => {
|
||||
const entry = renderEntries[item.index];
|
||||
if (!entry) return null;
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
data-index={item.index}
|
||||
ref={tanstackVirtualizer.measureElement}
|
||||
data-turn-entry={entry.key}
|
||||
>
|
||||
{renderEntry(entry)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Virtualizer>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
StaticHistoryList.displayName = 'StaticHistoryList';
|
||||
@@ -1066,9 +1296,8 @@ const StreamingTailContent: React.FC<{
|
||||
|
||||
StreamingTailContent.displayName = 'StreamingTailContent';
|
||||
|
||||
const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
sessionKey,
|
||||
disableStaging = false,
|
||||
messages,
|
||||
sessionIsWorking = false,
|
||||
activeStreamingMessageId = null,
|
||||
@@ -1076,7 +1305,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
retryOverlay = null,
|
||||
onMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
isLoadingOlder,
|
||||
scrollToBottom,
|
||||
scrollRef,
|
||||
directory,
|
||||
@@ -1116,20 +1344,29 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
|
||||
|
||||
const baseDisplayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.base_display_ms', () => {
|
||||
const seenIdsFromTail = new Set<string>();
|
||||
const seenIds = new Set<string>();
|
||||
const latestById = new Map<string, ChatMessageEntry>();
|
||||
const dedupedMessages: ChatMessageEntry[] = [];
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
for (const message of messages) {
|
||||
const messageId = message.info?.id;
|
||||
if (typeof messageId === 'string') latestById.set(messageId, message);
|
||||
}
|
||||
|
||||
// Preserve the first occurrence's chronological position, but use the last
|
||||
// value because prepended history can overlap with newer live store data.
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index];
|
||||
const messageId = message.info?.id;
|
||||
if (typeof messageId === 'string') {
|
||||
if (seenIdsFromTail.has(messageId)) {
|
||||
if (seenIds.has(messageId)) {
|
||||
continue;
|
||||
}
|
||||
seenIdsFromTail.add(messageId);
|
||||
seenIds.add(messageId);
|
||||
}
|
||||
dedupedMessages.push(getNormalizedMessageForDisplay(message));
|
||||
dedupedMessages.push(getNormalizedMessageForDisplay(
|
||||
typeof messageId === 'string' ? latestById.get(messageId) ?? message : message,
|
||||
));
|
||||
}
|
||||
dedupedMessages.reverse();
|
||||
|
||||
const output: ChatMessageEntry[] = [];
|
||||
const compactionCommandIds = new Set<string>();
|
||||
@@ -1164,7 +1401,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
}), [messages]);
|
||||
|
||||
const historyContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const historyVirtualizerRef = React.useRef<VirtualizerHandle | null>(null);
|
||||
const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => {
|
||||
if (scrollRef?.current) {
|
||||
return scrollRef.current;
|
||||
@@ -1266,38 +1502,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
}
|
||||
|
||||
const historyEntries = staticRenderEntries;
|
||||
// All surfaces virtualize with @tanstack/react-virtual (see the engine
|
||||
// note at the top of the file). An unvirtualized list is kept only for
|
||||
// tiny histories where windowing overhead is not worth it.
|
||||
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
const historyEntryKeys = React.useMemo(() => historyEntries.map((entry) => entry.key), [historyEntries]);
|
||||
const virtualCache = React.useMemo(
|
||||
() => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined),
|
||||
[historyEntryKeys, sessionKey, shouldVirtualizeHistory],
|
||||
);
|
||||
const virtualCacheSessionRef = React.useRef(sessionKey);
|
||||
const virtualCacheKeysRef = React.useRef(historyEntryKeys);
|
||||
const setHistoryVirtualizer = React.useCallback((handle: VirtualizerHandle | null) => {
|
||||
if (!handle) {
|
||||
writeTimelineCache(
|
||||
virtualCacheSessionRef.current,
|
||||
virtualCacheKeysRef.current,
|
||||
historyVirtualizerRef.current,
|
||||
);
|
||||
historyVirtualizerRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
historyVirtualizerRef.current = handle;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
virtualCacheSessionRef.current = sessionKey;
|
||||
virtualCacheKeysRef.current = historyEntryKeys;
|
||||
}, [historyEntryKeys, sessionKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const virtualizerForCleanup = historyVirtualizerRef.current;
|
||||
return () => {
|
||||
writeTimelineCache(virtualCacheSessionRef.current, virtualCacheKeysRef.current, virtualizerForCleanup);
|
||||
};
|
||||
const historyEngine: HistoryEngine = shouldVirtualizeHistory ? 'tanstack' : 'none';
|
||||
const tanstackVirtualizerRef = React.useRef<TanstackVirtualizerInstance | null>(null);
|
||||
const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => {
|
||||
tanstackVirtualizerRef.current = virtualizer;
|
||||
}, []);
|
||||
|
||||
const allEntries = React.useMemo(() => {
|
||||
@@ -1395,16 +1607,20 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
}, [resolveScrollContainer]);
|
||||
|
||||
const scrollHistoryIndexIntoView = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => {
|
||||
if (!shouldVirtualizeHistory || index < 0 || index >= historyEntries.length) {
|
||||
if (index < 0 || index >= historyEntries.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const virtualizer = historyVirtualizerRef.current;
|
||||
if (!shouldVirtualizeHistory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const virtualizer = tanstackVirtualizerRef.current;
|
||||
if (!virtualizer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
virtualizer.scrollToIndex(index, { align: 'start', smooth: behavior === 'smooth' });
|
||||
virtualizer.scrollToIndex(index, { align: 'start', behavior: behavior === 'smooth' ? 'smooth' : 'auto' });
|
||||
return true;
|
||||
}, [historyEntries.length, shouldVirtualizeHistory]);
|
||||
|
||||
@@ -1472,6 +1688,49 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
);
|
||||
},
|
||||
|
||||
holdViewportAnchor: (anchor) => {
|
||||
const container = resolveScrollContainer();
|
||||
if (!container || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let frames = 0;
|
||||
let stable = 0;
|
||||
let cancelled = false;
|
||||
const cancelOnUserInput = () => {
|
||||
cancelled = true;
|
||||
container.removeEventListener('touchstart', cancelOnUserInput);
|
||||
container.removeEventListener('wheel', cancelOnUserInput);
|
||||
};
|
||||
container.addEventListener('touchstart', cancelOnUserInput, { passive: true });
|
||||
container.addEventListener('wheel', cancelOnUserInput, { passive: true });
|
||||
const step = () => {
|
||||
if (cancelled) return;
|
||||
const element = findMessageElement(anchor.messageId);
|
||||
if (element) {
|
||||
const delta = element.getBoundingClientRect().top
|
||||
- container.getBoundingClientRect().top
|
||||
- anchor.offsetTop;
|
||||
if (Math.abs(delta) > 0.5) {
|
||||
container.scrollTop += delta;
|
||||
stable = 0;
|
||||
} else {
|
||||
stable += 1;
|
||||
}
|
||||
}
|
||||
frames += 1;
|
||||
if (stable >= ANCHOR_HOLD_STABLE_FRAMES || frames >= ANCHOR_HOLD_MAX_FRAMES) {
|
||||
container.removeEventListener('touchstart', cancelOnUserInput);
|
||||
container.removeEventListener('wheel', cancelOnUserInput);
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(step);
|
||||
};
|
||||
window.requestAnimationFrame(step);
|
||||
},
|
||||
|
||||
isHistoryVirtualized: () => shouldVirtualizeHistory,
|
||||
|
||||
captureViewportAnchor: () => {
|
||||
const container = resolveScrollContainer();
|
||||
if (!container) {
|
||||
@@ -1490,9 +1749,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return true;
|
||||
}
|
||||
|
||||
const computed = window.getComputedStyle(node);
|
||||
const isStuckSticky = computed.position === 'sticky' && rect.top <= containerRect.top + 1;
|
||||
return !isStuckSticky;
|
||||
return !isInsideStuckSticky(node, container, containerRect.top);
|
||||
}) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1);
|
||||
if (!firstVisible) {
|
||||
return null;
|
||||
@@ -1544,8 +1801,8 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
},
|
||||
|
||||
scrollToBottom: () => {
|
||||
if (shouldVirtualizeHistory && historyEntries.length > 0) {
|
||||
historyVirtualizerRef.current?.scrollToIndex(historyEntries.length - 1, { align: 'end' });
|
||||
if (shouldVirtualizeHistory && historyEntries.length > 0 && tanstackVirtualizerRef.current) {
|
||||
tanstackVirtualizerRef.current.scrollToEnd();
|
||||
return;
|
||||
}
|
||||
const container = resolveScrollContainer();
|
||||
@@ -1574,27 +1831,32 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
<div>
|
||||
<FadeInDisabledProvider disabled={disableFadeIn}>
|
||||
<div className="relative w-full">
|
||||
<StaticHistoryList
|
||||
entries={historyEntries}
|
||||
shouldVirtualize={shouldVirtualizeHistory}
|
||||
contentRef={historyContentRef}
|
||||
scrollRef={scrollRef}
|
||||
virtualizerRef={setHistoryVirtualizer}
|
||||
virtualizerKey={sessionKey}
|
||||
virtualCache={virtualCache}
|
||||
shift={isLoadingOlder || disableStaging}
|
||||
onMessageContentChange={stableHistoryContentChange}
|
||||
getAnimationHandlers={stableGetAnimationHandlers}
|
||||
scrollToBottom={stableScrollToBottom}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
defaultActivityExpanded={defaultActivityExpanded}
|
||||
turnUiStates={turnUiStates}
|
||||
onToggleTurnGroup={toggleTurnGroup}
|
||||
chatRenderMode={chatRenderMode}
|
||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
||||
reviewTransferDirection={reviewTransferDirection}
|
||||
/>
|
||||
{/* Virtualized history rows unmount/remount during scroll;
|
||||
re-running the reveal fade on every remount reads as
|
||||
blinking. History content is never "new", so fade-in
|
||||
is disabled there — the streaming tail keeps it. */}
|
||||
<FadeInDisabledProvider disabled={shouldVirtualizeHistory}>
|
||||
<StaticHistoryList
|
||||
key={sessionKey}
|
||||
entries={historyEntries}
|
||||
engine={historyEngine}
|
||||
contentRef={historyContentRef}
|
||||
scrollRef={scrollRef}
|
||||
registerTanstackVirtualizer={registerTanstackVirtualizer}
|
||||
virtualizerKey={sessionKey}
|
||||
onMessageContentChange={stableHistoryContentChange}
|
||||
getAnimationHandlers={stableGetAnimationHandlers}
|
||||
scrollToBottom={stableScrollToBottom}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
defaultActivityExpanded={defaultActivityExpanded}
|
||||
turnUiStates={turnUiStates}
|
||||
onToggleTurnGroup={toggleTurnGroup}
|
||||
chatRenderMode={chatRenderMode}
|
||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
||||
reviewTransferDirection={reviewTransferDirection}
|
||||
/>
|
||||
</FadeInDisabledProvider>
|
||||
{trailingStreamingEntry ? (
|
||||
<StreamingTailContent
|
||||
entry={trailingStreamingEntry}
|
||||
|
||||
@@ -31,7 +31,12 @@ export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAge
|
||||
const longPressTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isLongPressRef = React.useRef(false);
|
||||
|
||||
const handlePointerDown = () => {
|
||||
const handlePointerDown = (event: React.PointerEvent) => {
|
||||
// Same pattern as PermissionAutoAcceptButton: block the focus transfer
|
||||
// iOS performs on touch so cycling the agent keeps the keyboard open.
|
||||
if (event.pointerType === 'touch') {
|
||||
event.preventDefault();
|
||||
}
|
||||
isLongPressRef.current = false;
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
isLongPressRef.current = true;
|
||||
@@ -72,9 +77,10 @@ export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAge
|
||||
onPointerUp={handlePointerUp} // Don't use onClick - it closes mobile keyboard
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
'inline-flex min-w-0 items-center select-none',
|
||||
'rounded-lg border border-border/50 px-1.5',
|
||||
'inline-flex min-w-0 items-stretch select-none',
|
||||
'rounded-lg',
|
||||
'typography-micro font-medium',
|
||||
'focus:outline-none hover:bg-[var(--interactive-hover)]',
|
||||
'touch-none',
|
||||
@@ -88,9 +94,9 @@ export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAge
|
||||
}}
|
||||
title={agentLabel}
|
||||
>
|
||||
<span className="truncate">{agentLabel}</span>
|
||||
<span className="flex h-full w-full min-w-0 items-center">
|
||||
<span className="truncate">{agentLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileAgentButton;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { getModelDisplayName } from './mobileControlsUtils';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface MobileModelButtonProps {
|
||||
@@ -12,6 +13,7 @@ interface MobileModelButtonProps {
|
||||
export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenModel, className }) => {
|
||||
const { t } = useI18n();
|
||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
|
||||
const currentProvider = getCurrentProvider();
|
||||
const modelLabel = getModelDisplayName(currentProvider, currentModelId, t('chat.modelControls.selectModel'));
|
||||
@@ -20,9 +22,19 @@ export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenMode
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenModel}
|
||||
// Same guard as PermissionAutoAcceptButton/MobileAgentButton: block
|
||||
// the focus transfer so the tap doesn't dismiss the keyboard. With
|
||||
// interactive-widget=resizes-content (Android), the keyboard-close
|
||||
// relayout moves this button mid-tap and the click never lands.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex min-w-0 items-center justify-center',
|
||||
'rounded-lg border border-border/50 px-1.5',
|
||||
'inline-flex min-w-0 items-stretch',
|
||||
'rounded-lg',
|
||||
'typography-micro font-medium text-foreground/80',
|
||||
'focus:outline-none hover:bg-[var(--interactive-hover)]',
|
||||
className
|
||||
@@ -30,11 +42,12 @@ export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenMode
|
||||
style={{ height: '26px', maxHeight: '26px', minHeight: '26px' }}
|
||||
title={modelLabel}
|
||||
>
|
||||
<span className="min-w-0 max-w-full overflow-x-auto whitespace-nowrap scrollbar-hidden">
|
||||
{modelLabel}
|
||||
<span className="flex h-full w-full min-w-0 items-center gap-1">
|
||||
{currentProviderId ? (
|
||||
<ProviderLogo providerId={currentProviderId} className="size-4 flex-shrink-0" />
|
||||
) : null}
|
||||
<span className="truncate">{modelLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileModelButton;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useAllSessionStatuses, useAllLiveSessions } from '@/sync/sync-context';
|
||||
import { mergeSessionDirectoryMetadata, useGlobalSessionsStore, ensureGlobalSessionsLoaded, refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
|
||||
import { mergeLiveSessionWithGlobalSession, useGlobalSessionsStore, ensureGlobalSessionsLoaded, refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
@@ -37,7 +37,7 @@ function useAllProjectSessions(): Session[] {
|
||||
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
|
||||
const merged = globalActiveSessions.map((session) => {
|
||||
const liveSession = liveById.get(session.id);
|
||||
return liveSession ? mergeSessionDirectoryMetadata(liveSession, session) : session;
|
||||
return liveSession ? mergeLiveSessionWithGlobalSession(liveSession, session) : session;
|
||||
});
|
||||
const seen = new Set(merged.map((session) => session.id));
|
||||
for (const session of liveSessions) {
|
||||
|
||||
@@ -35,7 +35,7 @@ import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useModelLists } from '@/hooks/useModelLists';
|
||||
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
|
||||
import { formatEffortLabel, getCycledPrimaryAgentName, type MobileControlsPanel } from './mobileControlsUtils';
|
||||
import { formatEffortLabel, getCycledPrimaryAgentName, isPrimaryMode, type MobileControlsPanel } from './mobileControlsUtils';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
|
||||
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
|
||||
@@ -366,6 +366,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
|
||||
const toggleFavoriteModel = useUIStore((state) => state.toggleFavoriteModel);
|
||||
const reorderFavoriteModel = useUIStore((state) => state.reorderFavoriteModel);
|
||||
const providerOrder = useUIStore((state) => state.providerOrder);
|
||||
const setProviderOrder = useUIStore((state) => state.setProviderOrder);
|
||||
const isFavoriteModel = useUIStore((state) => state.isFavoriteModel);
|
||||
const addRecentModel = useUIStore((state) => state.addRecentModel);
|
||||
const addRecentAgent = useUIStore((state) => state.addRecentAgent);
|
||||
@@ -384,7 +386,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false);
|
||||
const { favoriteModelsList, recentModelsList } = useModelLists();
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { isMobile: deviceIsMobile } = useDeviceInfo();
|
||||
// The composer decides whether it renders the mobile layout from the UI
|
||||
// store (the Capacitor shell forces it true even on tablets/iPad, where
|
||||
// useDeviceInfo classifies the wide screen as non-mobile). The bottom-sheet
|
||||
// panels must follow the SAME source: with the device flag alone, tapping
|
||||
// the model/agent chip on an iPad set the panel state while the sheet
|
||||
// itself rendered null.
|
||||
const uiIsMobile = useUIStore((state) => state.isMobile);
|
||||
const isMobile = deviceIsMobile || uiIsMobile;
|
||||
const isDesktop = React.useMemo(() => isDesktopShell(), []);
|
||||
const isVSCodeRuntime = useIsVSCodeRuntime();
|
||||
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
|
||||
@@ -492,7 +502,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
}, [isAgentSelectorOpen, isCompact]);
|
||||
|
||||
const selectableDesktopAgents = React.useMemo(() => {
|
||||
return agents.filter((agent) => agent.mode !== 'subagent');
|
||||
return agents.filter((agent) => isPrimaryMode(agent.mode));
|
||||
}, [agents]);
|
||||
|
||||
const sortedAndFilteredAgents = React.useMemo(() => {
|
||||
@@ -831,9 +841,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
setAgent(latestLoadedUserChoice.agent);
|
||||
}
|
||||
|
||||
const applyResult = tryApplyModelSelection(
|
||||
const historicalVariant = latestLoadedUserChoice.variant
|
||||
&& getModelVariantOptions(latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID).includes(latestLoadedUserChoice.variant)
|
||||
? latestLoadedUserChoice.variant
|
||||
: undefined;
|
||||
const applyResult = applyModelSelectionWithVariant(
|
||||
latestLoadedUserChoice.providerID,
|
||||
latestLoadedUserChoice.modelID,
|
||||
historicalVariant,
|
||||
latestLoadedUserChoice.agent || currentAgentName || undefined,
|
||||
);
|
||||
if (applyResult !== 'applied') {
|
||||
@@ -847,7 +862,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
latestLoadedUserChoice.agent,
|
||||
latestLoadedUserChoice.providerID,
|
||||
latestLoadedUserChoice.modelID,
|
||||
latestLoadedUserChoice.variant,
|
||||
historicalVariant,
|
||||
);
|
||||
}
|
||||
saveSessionModelSelection(currentSessionId, latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID);
|
||||
@@ -861,7 +876,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
hasRenderableCurrentSessionSnapshot,
|
||||
latestLoadedUserChoice,
|
||||
setAgent,
|
||||
tryApplyModelSelection,
|
||||
applyModelSelectionWithVariant,
|
||||
getModelVariantOptions,
|
||||
saveSessionAgentSelection,
|
||||
saveAgentModelVariantForSession,
|
||||
saveSessionModelSelection,
|
||||
@@ -1144,7 +1160,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
|
||||
const resolvedSaved = savedVariant && availableVariants.includes(savedVariant)
|
||||
? savedVariant
|
||||
: undefined;
|
||||
: settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
|
||||
? settingsDefaultVariant
|
||||
: undefined;
|
||||
|
||||
setCurrentVariant(resolvedSaved);
|
||||
manualVariantSelectionRef.current = false;
|
||||
@@ -1661,7 +1679,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
isSelected && 'bg-interactive-selection/15 text-interactive-selection-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMobileModelApply(providerId, modelId, resolvedVariant)}
|
||||
@@ -1670,15 +1688,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary rounded-lg'
|
||||
)}
|
||||
>
|
||||
{showProviderLogo ? (
|
||||
<ProviderLogo providerId={providerId} className="mt-0.5 size-3.5 flex-shrink-0" />
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{showProviderLogo ? (
|
||||
<ProviderLogo providerId={providerId} className="size-3.5 flex-shrink-0" />
|
||||
) : null}
|
||||
<span className="typography-meta font-medium text-foreground truncate">
|
||||
{getModelDisplayName(model)}
|
||||
</span>
|
||||
{isSelected ? <Icon name="check" className="mt-0.5 size-4 flex-shrink-0 text-primary" /> : null}
|
||||
{isSelected ? <Icon name="check" className="size-4 flex-shrink-0 text-primary" /> : null}
|
||||
</div>
|
||||
{contextText || indicatorIcons.length > 0 ? (
|
||||
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden typography-micro text-muted-foreground">
|
||||
@@ -1712,7 +1730,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedMobileModelKey((prev) => prev === rowKey ? null : rowKey)}
|
||||
className="flex items-center gap-1 rounded-lg border border-border/40 px-2 py-1 typography-micro font-medium text-muted-foreground hover:bg-interactive-hover/50 flex-shrink-0"
|
||||
className="flex items-center gap-0.5 typography-micro font-medium text-muted-foreground hover:text-foreground flex-shrink-0"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={isExpanded ? t('chat.modelControls.hideThinkingModes') : t('chat.modelControls.showThinkingModes')}
|
||||
>
|
||||
@@ -1720,7 +1738,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
{isExpanded ? <Icon name="arrow-down-s" className="size-3.5" /> : <Icon name="arrow-right-s" className="size-3.5" />}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="flex flex-shrink-0 items-start gap-1.5">
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@@ -2360,6 +2378,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
)}
|
||||
reorderFavoriteAriaLabel={t('chat.modelControls.reorderFavoriteAria')}
|
||||
reorderFavoriteTitle={t('chat.modelControls.reorderFavoriteTitle')}
|
||||
providerOrder={providerOrder}
|
||||
onReorderProvider={setProviderOrder}
|
||||
reorderProviderTitle={t('chat.modelControls.reorderProviderTitle')}
|
||||
footerContent={(activeEntry) => {
|
||||
const activeHasThinkingVariants = activeEntry
|
||||
? getModelVariantOptions(activeEntry.providerID, activeEntry.modelID).length > 0
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
interface PermissionRequestProps {
|
||||
permission: PermissionRequestPayload;
|
||||
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
||||
}
|
||||
|
||||
export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
||||
permission,
|
||||
onResponse
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isResponding, setIsResponding] = React.useState(false);
|
||||
const [hasResponded, setHasResponded] = React.useState(false);
|
||||
const respondToPermission = sessionActions.respondToPermission;
|
||||
|
||||
const handleResponse = async (response: PermissionResponse) => {
|
||||
setIsResponding(true);
|
||||
|
||||
try {
|
||||
await respondToPermission(permission.sessionID, permission.id, response);
|
||||
setHasResponded(true);
|
||||
onResponse?.(response);
|
||||
} catch (error) {
|
||||
console.error('[PermissionRequest] Failed to respond to permission:', error);
|
||||
} finally {
|
||||
setIsResponding(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (hasResponded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = typeof permission.metadata.command === 'string'
|
||||
? permission.metadata.command
|
||||
: (permission.patterns?.[0] ?? permission.permission);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<span className="typography-ui-label font-medium text-muted-foreground">
|
||||
{t('chat.permissionRequest.required')}
|
||||
</span>
|
||||
<code className="ml-2 typography-meta bg-amber-100/50 dark:bg-amber-800/30 px-1.5 py-0.5 rounded font-mono text-amber-800 dark:text-amber-200">
|
||||
{command}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 ml-4">
|
||||
<button
|
||||
onClick={() => handleResponse('once')}
|
||||
disabled={isResponding}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
borderColor: 'var(--status-success)',
|
||||
color: 'var(--status-success)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--status-success-background)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Icon name="check" className="h-3 w-3" />
|
||||
{t('chat.permissionRequest.actions.once')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleResponse('always')}
|
||||
disabled={isResponding}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
borderColor: 'var(--status-info)',
|
||||
color: 'var(--status-info)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--status-info-background)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Icon name="time" className="h-3 w-3" />
|
||||
{t('chat.permissionRequest.actions.always')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleResponse('reject')}
|
||||
disabled={isResponding}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
borderColor: 'var(--status-error)',
|
||||
color: 'var(--status-error)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--status-error-background)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
{t('chat.permissionRequest.actions.reject')}
|
||||
</button>
|
||||
|
||||
{isResponding && (
|
||||
<div className="ml-2 flex items-center">
|
||||
<div className="animate-spin h-3 w-3 border-2 border-t-transparent rounded-full" style={{ borderColor: 'var(--loading-spinner)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,139 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface PermissionToastActionsProps {
|
||||
sessionTitle: string;
|
||||
permissionBody: string;
|
||||
disabled?: boolean;
|
||||
onOnce: () => Promise<void> | void;
|
||||
onAlways: () => Promise<void> | void;
|
||||
onDeny: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const truncateToastText = (value: string, maxLength: number): string => {
|
||||
const normalized = value.trim();
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
||||
};
|
||||
|
||||
export const PermissionToastActions: React.FC<PermissionToastActionsProps> = ({
|
||||
sessionTitle,
|
||||
permissionBody,
|
||||
disabled = false,
|
||||
onOnce,
|
||||
onAlways,
|
||||
onDeny,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isBusy, setIsBusy] = React.useState(false);
|
||||
const hasSessionTitle = sessionTitle.trim().length > 0;
|
||||
const sessionPreview = truncateToastText(sessionTitle, 64) || t('chat.permissionToast.sessionFallback');
|
||||
const permissionPreview = truncateToastText(permissionBody, 120) || t('chat.permissionToast.permissionFallback');
|
||||
|
||||
const handleAction = async (action: () => Promise<void> | void) => {
|
||||
if (isBusy || disabled) return;
|
||||
setIsBusy(true);
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1.5 min-w-0 space-y-0.5">
|
||||
<p className="typography-meta text-muted-foreground" title={sessionTitle}>
|
||||
{t('chat.permissionToast.labels.session')}{' '}
|
||||
<span className="inline-block max-w-[280px] align-bottom truncate text-foreground">
|
||||
{sessionPreview}
|
||||
</span>
|
||||
</p>
|
||||
<p className="typography-meta text-muted-foreground" title={permissionBody}>
|
||||
{t('chat.permissionToast.labels.permission')}{' '}
|
||||
<span className="inline-block max-w-[280px] align-bottom truncate">
|
||||
{permissionPreview}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => handleAction(onOnce)}
|
||||
disabled={disabled || isBusy}
|
||||
aria-label={hasSessionTitle
|
||||
? t('chat.permissionToast.actions.approveOnceAriaWithSession', { session: sessionTitle })
|
||||
: t('chat.permissionToast.actions.approveOnceAria')}
|
||||
className={cn(
|
||||
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: 'rgb(var(--status-success) / 0.1)',
|
||||
color: 'var(--status-success)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.1)';
|
||||
}}
|
||||
>
|
||||
{t('chat.permissionToast.actions.once')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAction(onAlways)}
|
||||
disabled={disabled || isBusy}
|
||||
aria-label={hasSessionTitle
|
||||
? t('chat.permissionToast.actions.approveAlwaysAriaWithSession', { session: sessionTitle })
|
||||
: t('chat.permissionToast.actions.approveAlwaysAria')}
|
||||
className={cn(
|
||||
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: 'rgb(var(--muted) / 0.5)',
|
||||
color: 'var(--muted-foreground)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.7)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
|
||||
}}
|
||||
>
|
||||
{t('chat.permissionToast.actions.always')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAction(onDeny)}
|
||||
disabled={disabled || isBusy}
|
||||
aria-label={hasSessionTitle
|
||||
? t('chat.permissionToast.actions.denyAriaWithSession', { session: sessionTitle })
|
||||
: t('chat.permissionToast.actions.denyAria')}
|
||||
className={cn(
|
||||
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: 'rgb(var(--status-error) / 0.1)',
|
||||
color: 'var(--status-error)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.1)';
|
||||
}}
|
||||
>
|
||||
{t('chat.permissionToast.actions.deny')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,26 @@
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QueuedMessageChipProps {
|
||||
message: QueuedMessage;
|
||||
@@ -16,6 +32,7 @@ interface QueuedMessageChipProps {
|
||||
const QueuedMessageChip = memo(({ message, sessionId, onEdit, onSend }: QueuedMessageChipProps) => {
|
||||
const { t } = useI18n();
|
||||
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: message.id });
|
||||
|
||||
// Get first line of message, truncated
|
||||
const firstLine = React.useMemo(() => {
|
||||
@@ -31,7 +48,21 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit, onSend }: QueuedMe
|
||||
const attachmentCount = message.attachments?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 py-1">
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
// Translate only (no scaleX/scaleY) so the lifted row keeps its size.
|
||||
style={{ transform: CSS.Translate.toString(transform), transition }}
|
||||
className={cn('flex min-w-0 items-center gap-2 py-1', isDragging && 'z-10 opacity-60')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex flex-shrink-0 cursor-grab touch-none select-none items-center justify-center text-muted-foreground hover:text-foreground active:cursor-grabbing"
|
||||
aria-label={t('chat.queuedMessage.reorderAria')}
|
||||
>
|
||||
<Icon name="draggable" className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
|
||||
{firstLine || t('chat.queuedMessage.empty')}
|
||||
{attachmentCount > 0 && (
|
||||
@@ -90,6 +121,20 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
|
||||
)
|
||||
);
|
||||
const popToInput = useMessageQueueStore((state) => state.popToInput);
|
||||
const reorderQueue = useMessageQueueStore((state) => state.reorderQueue);
|
||||
|
||||
const sensors = useSensors(
|
||||
// Desktop: drag after a small move so other clicks still register.
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
||||
// Touch: long-press to drag (tap still hits buttons, swipe scrolls).
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
|
||||
);
|
||||
|
||||
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id || !currentSessionId) return;
|
||||
reorderQueue(currentSessionId, String(active.id), String(over.id));
|
||||
}, [currentSessionId, reorderQueue]);
|
||||
|
||||
const handleEdit = React.useCallback((message: QueuedMessage) => {
|
||||
if (!currentSessionId) return;
|
||||
@@ -121,17 +166,28 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
|
||||
</span>
|
||||
<Icon name="time" className="ml-auto h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="px-3 pb-3 flex flex-col gap-1.5 max-h-[10.5rem] overflow-y-auto">
|
||||
{queuedMessages.map((message) => (
|
||||
<QueuedMessageChip
|
||||
key={message.id}
|
||||
message={message}
|
||||
sessionId={currentSessionId}
|
||||
onEdit={handleEdit}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={queuedMessages.map((m) => m.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="px-3 pb-3 flex flex-col gap-1.5 max-h-[10.5rem] overflow-y-auto">
|
||||
{queuedMessages.map((message) => (
|
||||
<QueuedMessageChip
|
||||
key={message.id}
|
||||
message={message}
|
||||
sessionId={currentSessionId}
|
||||
onEdit={handleEdit}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore';
|
||||
import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata';
|
||||
import { SessionGoalDialog } from '@/components/chat/SessionGoalDialog';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SessionGoalButtonProps {
|
||||
sessionId: string | null;
|
||||
directory?: string;
|
||||
/** Session draft is open — the goal arms for the session the draft creates. */
|
||||
draftOpen?: boolean;
|
||||
footerIconButtonClass: string;
|
||||
iconSizeClass: string;
|
||||
withTooltip?: boolean;
|
||||
}
|
||||
|
||||
// Composer target button — the goal switch. With no live goal one tap arms
|
||||
// goal mode (the next sent prompt becomes the objective; works on drafts
|
||||
// too) and a second tap disarms. While a goal is live the target stays lit
|
||||
// (info while running, success when complete, error when blocked / out of
|
||||
// budget) and tapping opens the manage dialog.
|
||||
export const SessionGoalButton: React.FC<SessionGoalButtonProps> = React.memo(({
|
||||
sessionId,
|
||||
directory,
|
||||
draftOpen = false,
|
||||
footerIconButtonClass,
|
||||
iconSizeClass,
|
||||
withTooltip = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { goal, enabled } = useSessionGoal(sessionId ?? '', directory);
|
||||
const armed = useSessionGoalArmStore((state) => state.armed);
|
||||
const setArmed = useSessionGoalArmStore((state) => state.setArmed);
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
|
||||
// The goal loop runs in the web server; the VS Code extension only renders
|
||||
// goal state. Arming a goal there would create one nothing drives, so the
|
||||
// entry point is hidden entirely.
|
||||
if (isVSCodeRuntime() || !enabled || (!sessionId && !draftOpen)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A settled goal no longer drives the loop — the button goes back to being
|
||||
// an arm switch, while still tinting with the outcome color.
|
||||
const liveGoal = goal && goal.status !== 'complete' ? goal : null;
|
||||
const isEngaged = armed || Boolean(liveGoal);
|
||||
|
||||
const colorClass = (() => {
|
||||
if (goal?.status === 'complete') return 'text-[var(--status-success)]';
|
||||
if (goal?.status === 'blocked' || goal?.status === 'budgetLimited') return 'text-[var(--status-error)]';
|
||||
if (armed || goal?.status === 'active' || goal?.status === 'paused') return 'text-[var(--status-info)]';
|
||||
return '';
|
||||
})();
|
||||
|
||||
const label = goal
|
||||
? t('chat.goal.button.manageAria')
|
||||
: (armed ? t('chat.goal.button.disarmAria') : t('chat.goal.button.armAria'));
|
||||
|
||||
// Any existing goal (live or completed) opens the manage dialog — a
|
||||
// completed goal must be removed there before a new one can be armed.
|
||||
const handleClick = () => {
|
||||
if (goal) {
|
||||
setDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
setArmed(!armed);
|
||||
};
|
||||
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(footerIconButtonClass, colorClass)}
|
||||
onClick={handleClick}
|
||||
aria-label={label}
|
||||
aria-pressed={isEngaged}
|
||||
{...(withTooltip ? {} : { title: label })}
|
||||
>
|
||||
{isEngaged || goal ? (
|
||||
<Icon name="target-fill" className={cn(iconSizeClass, 'text-current')} aria-hidden="true" />
|
||||
) : (
|
||||
<Icon name="target" className={cn(iconSizeClass, 'text-current')} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{withTooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : button}
|
||||
{sessionId ? (
|
||||
<SessionGoalDialog open={dialogOpen} onOpenChange={setDialogOpen} sessionId={sessionId} directory={directory} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
SessionGoalButton.displayName = 'SessionGoalButton';
|
||||
|
||||
interface SessionGoalObjectiveCounterProps {
|
||||
/** Current composer text length — the armed message becomes the objective. */
|
||||
length: number;
|
||||
}
|
||||
|
||||
// Tiny hot-path leaf next to the target button: while goal mode is armed the
|
||||
// typed message becomes the objective, which the server clamps to 2000
|
||||
// chars — surface that limit during typing instead of truncating silently.
|
||||
// Renders null when not armed, so normal typing shows nothing.
|
||||
export const SessionGoalObjectiveCounter: React.FC<SessionGoalObjectiveCounterProps> = React.memo(({ length }) => {
|
||||
const { t } = useI18n();
|
||||
const armed = useSessionGoalArmStore((state) => state.armed);
|
||||
|
||||
if (!armed || length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const over = length > SESSION_GOAL_OBJECTIVE_CHAR_LIMIT;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex-shrink-0 self-center typography-micro tabular-nums',
|
||||
over ? 'text-[var(--status-error)]' : 'text-muted-foreground/70',
|
||||
)}
|
||||
aria-label={t('chat.goal.counter.aria')}
|
||||
title={t('chat.goal.counter.aria')}
|
||||
>
|
||||
{length}/{SESSION_GOAL_OBJECTIVE_CHAR_LIMIT}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
SessionGoalObjectiveCounter.displayName = 'SessionGoalObjectiveCounter';
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useGoalObjectiveContent, useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import {
|
||||
formatGoalTokens,
|
||||
SESSION_GOAL_OBJECTIVE_CHAR_LIMIT,
|
||||
} from '@/lib/sessionGoalMetadata';
|
||||
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
|
||||
import { clearSessionGoal, setSessionGoal } from '@/lib/sessionGoalActions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SessionGoalDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sessionId: string;
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
// Create/manage dialog for the session goal: objective + optional token
|
||||
// budget on creation; status, usage, latest audit note and lifecycle actions
|
||||
// (pause/resume/complete/clear) once a goal exists.
|
||||
export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }: SessionGoalDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { goal } = useSessionGoal(sessionId, directory);
|
||||
const objectiveContent = useGoalObjectiveContent(sessionId, goal);
|
||||
|
||||
const [objective, setObjective] = React.useState('');
|
||||
const [budgetEnabled, setBudgetEnabled] = React.useState(false);
|
||||
const [tokenBudget, setTokenBudget] = React.useState<number>(200_000);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setObjective(goal?.objectiveFile ? (objectiveContent ?? '') : (goal?.objective ?? ''));
|
||||
setBudgetEnabled(Boolean(goal?.tokenBudget));
|
||||
setTokenBudget(goal?.tokenBudget ?? 200_000);
|
||||
// Seed the form only when the dialog opens; live goal updates while it is
|
||||
// open must not clobber the user's edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// File-backed objectives fetch async — the content usually lands right
|
||||
// after the dialog opens. Late-seed the textarea only while it is still
|
||||
// untouched so a slow fetch never clobbers the user's typing.
|
||||
React.useEffect(() => {
|
||||
if (!open || !goal?.objectiveFile || objectiveContent === null) return;
|
||||
setObjective((current) => (current === '' ? objectiveContent : current));
|
||||
}, [open, goal?.objectiveFile, objectiveContent]);
|
||||
|
||||
const run = React.useCallback(async (action: () => Promise<void>, closeAfter: boolean) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await action();
|
||||
if (closeAfter) onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.warn('[session-goal] action failed:', error);
|
||||
toast.error(t('chat.goal.toast.actionFailed'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [onOpenChange, t]);
|
||||
|
||||
const trimmedObjective = objective.trim();
|
||||
const savedObjective = goal?.objectiveFile ? (objectiveContent ?? '') : (goal?.objective ?? '');
|
||||
const objectiveChanged = trimmedObjective !== savedObjective;
|
||||
const budgetValue = budgetEnabled ? tokenBudget : null;
|
||||
const budgetChanged = budgetValue !== (goal?.tokenBudget ?? null);
|
||||
// A completed goal is read-only: remove it and arm a new one instead of
|
||||
// "saving" over the outcome (re-saving used to spawn a fresh active goal
|
||||
// that the auditor instantly re-completed — a confusing status flash).
|
||||
const isCompleted = goal?.status === 'complete';
|
||||
const canSave = !isCompleted && trimmedObjective.length > 0 && (!goal || objectiveChanged || budgetChanged);
|
||||
|
||||
const handleSave = () => run(
|
||||
() => setSessionGoal(sessionId, directory, { objective: trimmedObjective, tokenBudget: budgetValue }, goal),
|
||||
true,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{goal ? t('chat.goal.dialog.titleManage') : t('chat.goal.dialog.titleCreate')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{goal && (
|
||||
<div className="space-y-1 p-2 rounded-lg" style={{ backgroundColor: 'var(--surface-elevated)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full flex-shrink-0" style={{ backgroundColor: sessionGoalStatusColor[goal.status] }} aria-hidden="true" />
|
||||
<span className="typography-ui-label text-foreground">{t(sessionGoalStatusLabelKey[goal.status] as never)}</span>
|
||||
<span className="typography-meta text-muted-foreground tabular-nums">
|
||||
{goal.tokenBudget
|
||||
? t('chat.goal.usage.tokensWithBudget', {
|
||||
used: formatGoalTokens(goal.tokensUsed),
|
||||
budget: formatGoalTokens(goal.tokenBudget),
|
||||
})
|
||||
: t('chat.goal.usage.tokens', { used: formatGoalTokens(goal.tokensUsed) })}
|
||||
{' · '}
|
||||
{t('chat.goal.usage.turns', { turns: goal.turnsUsed })}
|
||||
</span>
|
||||
</div>
|
||||
{goal.note ? (
|
||||
<p className="typography-meta text-muted-foreground">{goal.note}</p>
|
||||
) : null}
|
||||
{/* Only failure states carry a reason worth reading; outcomes
|
||||
like "verified by audit" are noise next to the status dot. */}
|
||||
{goal.statusReason && (goal.status === 'blocked' || goal.status === 'budgetLimited') ? (
|
||||
<p className="typography-meta text-muted-foreground/70">{goal.statusReason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCompleted ? (
|
||||
<p className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words typography-meta text-muted-foreground">{objectiveContent ?? goal.objective}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="typography-ui-label text-foreground">{t('chat.goal.dialog.objectiveLabel')}</span>
|
||||
<span className="typography-micro tabular-nums text-muted-foreground/70" aria-label={t('chat.goal.counter.aria')}>
|
||||
{objective.length}/{SESSION_GOAL_OBJECTIVE_CHAR_LIMIT}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={objective}
|
||||
onChange={(event) => setObjective(event.target.value)}
|
||||
placeholder={t('chat.goal.dialog.objectivePlaceholder')}
|
||||
maxLength={SESSION_GOAL_OBJECTIVE_CHAR_LIMIT}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={budgetEnabled}
|
||||
onClick={() => setBudgetEnabled((value) => !value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setBudgetEnabled((value) => !value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={budgetEnabled}
|
||||
onChange={setBudgetEnabled}
|
||||
ariaLabel={t('chat.goal.dialog.budgetLabel')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('chat.goal.dialog.budgetLabel')}</span>
|
||||
</div>
|
||||
{budgetEnabled && (
|
||||
<NumberInput
|
||||
value={tokenBudget}
|
||||
onValueChange={(value) => setTokenBudget(typeof value === 'number' && value > 0 ? Math.floor(value) : 1000)}
|
||||
min={1000}
|
||||
max={100_000_000}
|
||||
step={50_000}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{goal && (
|
||||
<Button variant="destructive" size="sm" disabled={busy} onClick={() => run(() => clearSessionGoal(sessionId, directory), true)}>
|
||||
{t('chat.goal.action.clear')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-1 items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => onOpenChange(false)}>
|
||||
{t('chat.goal.action.cancel')}
|
||||
</Button>
|
||||
{!isCompleted && (
|
||||
<Button size="sm" disabled={busy || !canSave} onClick={handleSave}>
|
||||
{goal ? t('chat.goal.action.save') : t('chat.goal.action.start')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useSessionStatus } from '@/sync/sync-context';
|
||||
import { useGoalObjectiveContent, useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import { formatGoalTokens } from '@/lib/sessionGoalMetadata';
|
||||
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
|
||||
import { setSessionGoalStatus } from '@/lib/sessionGoalActions';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SessionGoalRowProps {
|
||||
sessionId: string | null;
|
||||
directory?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Compact goal strip near the composer: informational only — status dot,
|
||||
// objective (or the latest audit note), token usage — plus an inline
|
||||
// pause/resume action. The manage dialog opens from the composer target
|
||||
// button, not from here.
|
||||
export const SessionGoalRow: React.FC<SessionGoalRowProps> = React.memo(({ sessionId, directory, className }) => {
|
||||
const { t } = useI18n();
|
||||
const { goal, enabled } = useSessionGoal(sessionId ?? '', directory);
|
||||
const objectiveContent = useGoalObjectiveContent(sessionId ?? '', goal);
|
||||
const sessionStatus = useSessionStatus(sessionId ?? '', directory);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const handleToggleStatus = React.useCallback(async (nextStatus: 'active' | 'paused') => {
|
||||
if (!sessionId || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await setSessionGoalStatus(sessionId, directory, nextStatus);
|
||||
} catch (error) {
|
||||
console.warn('[session-goal] status change failed:', error);
|
||||
toast.error(t('chat.goal.toast.actionFailed'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [sessionId, directory, busy, t]);
|
||||
|
||||
if (!sessionId || !enabled || !goal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Accounting only lands on idle ticks — hide the counter until there is a
|
||||
// real number (or a budget worth tracking against) instead of showing "0".
|
||||
const usage = goal.tokenBudget
|
||||
? t('chat.goal.usage.tokensWithBudget', {
|
||||
used: formatGoalTokens(goal.tokensUsed),
|
||||
budget: formatGoalTokens(goal.tokenBudget),
|
||||
})
|
||||
: (goal.tokensUsed > 0 ? t('chat.goal.usage.tokens', { used: formatGoalTokens(goal.tokensUsed) }) : null);
|
||||
|
||||
const pauseResume = goal.status === 'active'
|
||||
? { icon: 'pause' as const, labelKey: 'chat.goal.action.pause' as const, next: 'paused' as const }
|
||||
: (goal.status === 'paused' || goal.status === 'blocked' || goal.status === 'budgetLimited'
|
||||
? { icon: 'play' as const, labelKey: 'chat.goal.action.resume' as const, next: 'active' as const }
|
||||
: null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-w-0 items-center gap-2 rounded-lg border px-2 py-1',
|
||||
'border-[var(--interactive-border)]',
|
||||
className,
|
||||
)}
|
||||
aria-label={t('chat.goal.row.aria')}
|
||||
title={objectiveContent ?? undefined}
|
||||
>
|
||||
<Icon name="target" className="h-3.5 w-3.5 flex-shrink-0" style={{ color: sessionGoalStatusColor[goal.status] }} aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate typography-meta text-foreground">
|
||||
{goal.note || objectiveContent || ''}
|
||||
</span>
|
||||
{goal.status === 'active' && (!sessionStatus || sessionStatus.type === 'idle') ? (
|
||||
// The agent stopped but the goal is still active: the server is
|
||||
// sitting out the quiet window and running the audit — show that
|
||||
// instead of a static "Active" that looks stuck.
|
||||
<span className="flex flex-shrink-0 items-center gap-1 typography-meta text-muted-foreground">
|
||||
<Icon name="loader-4" className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||
{t('chat.goal.status.evaluating')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-shrink-0 typography-meta text-muted-foreground">
|
||||
{t(sessionGoalStatusLabelKey[goal.status] as never)}
|
||||
</span>
|
||||
)}
|
||||
{usage ? (
|
||||
<span className="flex-shrink-0 typography-meta tabular-nums text-muted-foreground/70">
|
||||
{usage}
|
||||
</span>
|
||||
) : null}
|
||||
{pauseResume ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleToggleStatus(pauseResume.next)}
|
||||
disabled={busy}
|
||||
className="flex flex-shrink-0 cursor-pointer items-center gap-1 rounded px-1 py-0.5 typography-meta text-muted-foreground hover:bg-[var(--interactive-hover)] hover:text-foreground disabled:opacity-50"
|
||||
aria-label={t(pauseResume.labelKey)}
|
||||
>
|
||||
<Icon name={pauseResume.icon} className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{t(pauseResume.labelKey)}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SessionGoalRow.displayName = 'SessionGoalRow';
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useSessionAssistState } from '@/hooks/useSessionAssist';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SessionRecapNoteProps {
|
||||
sessionId: string;
|
||||
directory?: string;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
// Quiet one-paragraph recap of the agent's last reply, rendered right under
|
||||
// the last message (above the reserved bottom gap). Appears only after the
|
||||
// 1-minute quiet window, so the layout shift happens off-screen in practice.
|
||||
export const SessionRecapNote: React.FC<SessionRecapNoteProps> = React.memo(({ sessionId, directory, isMobile }) => {
|
||||
const { visibleRecap } = useSessionAssistState(sessionId, directory);
|
||||
const { t } = useI18n();
|
||||
|
||||
if (!visibleRecap) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-message-column">
|
||||
{/* The last assistant turn carries pb-8 — pull the recap up into that gap. */}
|
||||
<div className="-mt-6" aria-label={t('chat.recap.aria')}>
|
||||
<span className={`typography-meta text-muted-foreground/70 ${isMobile ? 'line-clamp-4' : 'line-clamp-2'}`}>
|
||||
<span className="italic text-muted-foreground/50">{t('chat.recap.label')} </span>
|
||||
{visibleRecap}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SessionRecapNote.displayName = 'SessionRecapNote';
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useSessionAssistState } from '@/hooks/useSessionAssist';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { patchSessionMetadata } from '@/sync/session-actions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SessionSuggestionChipProps {
|
||||
sessionId: string | null;
|
||||
directory?: string;
|
||||
/** The composer already has content — the suggestion must stay out of the way. */
|
||||
hidden: boolean;
|
||||
onApply: (text: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
// One small-model-suggested follow-up message, styled like the draft starter
|
||||
// chips. Tapping it fills the composer (no auto-send); the X patches the
|
||||
// suggestion out of the session metadata so it stays dismissed everywhere.
|
||||
export const SessionSuggestionChip: React.FC<SessionSuggestionChipProps> = React.memo(({ sessionId, directory, hidden, onApply, className }) => {
|
||||
const { suggestion } = useSessionAssistState(sessionId ?? '', directory);
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const [dismissing, setDismissing] = React.useState(false);
|
||||
|
||||
const handleDismiss = React.useCallback(async (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!sessionId || dismissing) return;
|
||||
setDismissing(true);
|
||||
try {
|
||||
await patchSessionMetadata(sessionId, undefined, (metadata) => {
|
||||
const namespace = isRecord(metadata.openchamber) ? metadata.openchamber : {};
|
||||
const assist = isRecord(namespace.assist) ? namespace.assist : {};
|
||||
const nextAssist = { ...assist };
|
||||
delete nextAssist.suggestion;
|
||||
return { ...metadata, openchamber: { ...namespace, assist: nextAssist } };
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to dismiss suggestion:', error);
|
||||
} finally {
|
||||
setDismissing(false);
|
||||
}
|
||||
}, [sessionId, dismissing]);
|
||||
|
||||
if (!suggestion || hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chipStyle: React.CSSProperties = {
|
||||
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
||||
borderColor: currentTheme?.colors?.interactive?.border,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex w-full min-w-0 justify-center ${className ?? ''}`}>
|
||||
<div className="relative w-full min-w-0 max-w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply(suggestion)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
aria-label={t('chat.suggestion.applyAria')}
|
||||
className="group flex w-full min-w-0 select-none items-center gap-1.5 rounded-full border py-1.5 pl-3 pr-8 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
|
||||
style={chipStyle}
|
||||
>
|
||||
<Icon name="pencil-ai-2" className="h-3.5 w-3.5 shrink-0 opacity-70 transition-opacity group-hover:opacity-100" />
|
||||
<span className="truncate">{suggestion}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm whitespace-pre-wrap">
|
||||
{suggestion}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => void handleDismiss(event)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
aria-label={t('chat.suggestion.dismissAria')}
|
||||
title={t('chat.suggestion.dismissAria')}
|
||||
className="absolute right-1.5 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SessionSuggestionChip.displayName = 'SessionSuggestionChip';
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
|
||||
interface SkillInfo {
|
||||
name: string;
|
||||
@@ -28,6 +30,8 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
style,
|
||||
}, ref) => {
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const selectedIndexRef = React.useRef(0);
|
||||
const keyboardNavigationRef = React.useRef(false);
|
||||
@@ -128,7 +132,8 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
itemRefs.current[index] = el;
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
||||
'flex gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
||||
isMobile ? 'items-center' : 'items-start',
|
||||
index === selectedIndex && 'bg-interactive-selection'
|
||||
)}
|
||||
onClick={() => onSkillSelect(skill.name)}
|
||||
@@ -152,7 +157,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
{source}
|
||||
</span>
|
||||
</div>
|
||||
{skill.description && (
|
||||
{skill.description && !isMobile && (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||
{skill.description}
|
||||
</div>
|
||||
@@ -166,9 +171,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||
style={style}
|
||||
style={mobileMaxHeight !== undefined ? { ...style, maxHeight: mobileMaxHeight } : style}
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
||||
<ScrollableOverlay preventOverscroll outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
||||
{filteredSkills.length ? (
|
||||
<div>
|
||||
{filteredSkills.map((skill, index) => renderSkill(skill, index))}
|
||||
@@ -179,9 +184,11 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||
↑↓ navigate • Enter select • Esc close
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||
↑↓ navigate • Enter select • Esc close
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { Snippet } from '@/types/snippet';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
|
||||
export interface SnippetAutocompleteHandle {
|
||||
handleKeyDown: (key: string) => void;
|
||||
@@ -30,6 +31,8 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
|
||||
}, ref) => {
|
||||
const { t } = useI18n();
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const selectedIndexRef = React.useRef(0);
|
||||
const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]);
|
||||
@@ -120,8 +123,8 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
|
||||
}), [chooseSnippet, filteredSnippets, onClose, openNewSnippetSettings]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col" style={style}>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
||||
<div ref={containerRef} className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col" style={mobileMaxHeight !== undefined ? { ...style, maxHeight: mobileMaxHeight } : style}>
|
||||
<ScrollableOverlay preventOverscroll outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
||||
<div
|
||||
ref={(el) => { itemRefs.current[0] = el; }}
|
||||
className={cn('flex items-center gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', selectedIndex === 0 && 'bg-interactive-selection')}
|
||||
@@ -135,7 +138,7 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
|
||||
<div
|
||||
key={`${snippet.source}:${snippet.filePath}`}
|
||||
ref={(el) => { itemRefs.current[index + 1] = el; }}
|
||||
className={cn('flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', index + 1 === selectedIndex && 'bg-interactive-selection')}
|
||||
className={cn('flex gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', isMobile ? 'items-center' : 'items-start', index + 1 === selectedIndex && 'bg-interactive-selection')}
|
||||
onClick={() => chooseSnippet(snippet)}
|
||||
onMouseMove={() => setSelectedIndex(index + 1)}
|
||||
>
|
||||
@@ -144,14 +147,18 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
|
||||
<span className="font-semibold truncate">#{snippet.name}</span>
|
||||
<span className="text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0 bg-[var(--surface-muted)] text-muted-foreground border-[var(--interactive-border)]/60">{t(`snippets.source.${snippet.source}`)}</span>
|
||||
</div>
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">{snippetPreview(snippet)}</div>
|
||||
{!isMobile && (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">{snippetPreview(snippet)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">{t('chat.snippetAutocomplete.empty')}</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">{t('chat.snippetAutocomplete.footer')}</div>
|
||||
{!isMobile && (
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">{t('chat.snippetAutocomplete.footer')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const STATUS_CHIP_STYLE = {
|
||||
height: '28px',
|
||||
maxHeight: '28px',
|
||||
minHeight: '28px',
|
||||
};
|
||||
|
||||
interface StatusChipProps {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) => {
|
||||
const { t } = useI18n();
|
||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||
const currentVariant = useConfigStore((state) => state.currentVariant);
|
||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
|
||||
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
|
||||
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const sessionAgentName = useContextStore((state) =>
|
||||
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
||||
);
|
||||
|
||||
const agents = getVisibleAgents();
|
||||
const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName;
|
||||
const agentLabel = getAgentDisplayName(agents, uiAgentName);
|
||||
const currentProvider = getCurrentProvider();
|
||||
const modelLabel = getModelDisplayName(currentProvider, currentModelId, t('chat.modelControls.selectModel'));
|
||||
const hasEffort = getCurrentModelVariants().length > 0;
|
||||
const effortLabel = hasEffort ? formatEffortLabel(currentVariant) : null;
|
||||
const fullLabel = [agentLabel, modelLabel, effortLabel].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex min-w-0 items-center justify-center',
|
||||
'rounded-md border border-border/50 px-1.5',
|
||||
'text-[11px] font-medium text-foreground/80',
|
||||
'focus:outline-none hover:bg-[var(--interactive-hover)]',
|
||||
className
|
||||
)}
|
||||
style={STATUS_CHIP_STYLE}
|
||||
title={fullLabel}
|
||||
>
|
||||
<span className="shrink-0">{agentLabel}</span>
|
||||
<span className="shrink-0 text-muted-foreground mx-0.5">·</span>
|
||||
<span className="min-w-0 truncate">{modelLabel}</span>
|
||||
{effortLabel && (
|
||||
<>
|
||||
<span className="shrink-0 text-muted-foreground mx-0.5">·</span>
|
||||
<span className="shrink-0">{effortLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusChip;
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessageRecords } from '@/sync/sync-context';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -22,6 +23,9 @@ interface TimelineDialogProps {
|
||||
onScrollToMessage?: (messageId: string) => void | Promise<boolean>;
|
||||
onScrollByTurnOffset?: (offset: number) => void;
|
||||
onResumeToLatest?: () => void;
|
||||
canLoadEarlier?: boolean;
|
||||
isLoadingEarlier?: boolean;
|
||||
onLoadEarlier?: () => void;
|
||||
}
|
||||
|
||||
export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
@@ -30,6 +34,9 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
onScrollToMessage,
|
||||
onScrollByTurnOffset,
|
||||
onResumeToLatest,
|
||||
canLoadEarlier = false,
|
||||
isLoadingEarlier = false,
|
||||
onLoadEarlier,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
@@ -43,31 +50,31 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const pendingLoadAnchorRef = React.useRef<{ messageId: string; top: number } | null>(null);
|
||||
const preservingLoadPositionRef = React.useRef(false);
|
||||
const wasOpenRef = React.useRef(open);
|
||||
|
||||
const formatRelativeTime = React.useCallback((timestamp: number): string => {
|
||||
const now = Date.now();
|
||||
const diffMs = now - timestamp;
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const formatDateGroup = React.useCallback((timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleDateString(getCurrentIntlLocale(), {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (diffSecs < 60) return t('chat.timeline.relative.justNow');
|
||||
if (diffMins < 60) return t('chat.timeline.relative.minutesAgo', { count: diffMins });
|
||||
if (diffHours < 24) return t('chat.timeline.relative.hoursAgo', { count: diffHours });
|
||||
if (diffDays < 7) return t('chat.timeline.relative.daysAgo', { count: diffDays });
|
||||
return new Date(timestamp).toLocaleDateString(getCurrentIntlLocale());
|
||||
}, [t]);
|
||||
const formatMessageTime = React.useCallback((timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleTimeString(getCurrentIntlLocale(), {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Timeline actions are only valid for user messages.
|
||||
const userMessages = React.useMemo(() => {
|
||||
return messages
|
||||
.filter((message) => message.info.role === 'user')
|
||||
.map((message, index) => ({
|
||||
message,
|
||||
messageNumber: index + 1,
|
||||
}))
|
||||
.reverse();
|
||||
.map((message) => ({ message }));
|
||||
}, [messages]);
|
||||
|
||||
// Filter by search query using all text parts in each user message.
|
||||
@@ -83,19 +90,89 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
}, [userMessages, searchQuery]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [filteredMessages]);
|
||||
if (preservingLoadPositionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedIndex(searchQuery.trim() ? 0 : Math.max(0, filteredMessages.length - 1));
|
||||
}, [filteredMessages, searchQuery]);
|
||||
|
||||
React.useEffect(() => {
|
||||
itemRefs.current = itemRefs.current.slice(0, filteredMessages.length);
|
||||
}, [filteredMessages.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (preservingLoadPositionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
}, [selectedIndex]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!preservingLoadPositionRef.current || pendingLoadAnchorRef.current || isLoadingEarlier) {
|
||||
return;
|
||||
}
|
||||
|
||||
preservingLoadPositionRef.current = false;
|
||||
}, [filteredMessages.length, isLoadingEarlier]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const wasOpen = wasOpenRef.current;
|
||||
wasOpenRef.current = open;
|
||||
|
||||
if (!open || wasOpen || preservingLoadPositionRef.current || searchQuery.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = listRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}, [open, searchQuery]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const anchor = pendingLoadAnchorRef.current;
|
||||
const container = listRef.current;
|
||||
if (!anchor || !container || isLoadingEarlier) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingLoadAnchorRef.current = null;
|
||||
const anchoredRow = itemRefs.current.find((row) => row?.dataset.timelineMessageId === anchor.messageId);
|
||||
if (!anchoredRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTop = anchoredRow.getBoundingClientRect().top - container.getBoundingClientRect().top;
|
||||
container.scrollTop += nextTop - anchor.top;
|
||||
}, [filteredMessages.length, isLoadingEarlier]);
|
||||
|
||||
const handleLoadEarlier = React.useCallback(() => {
|
||||
const container = listRef.current;
|
||||
if (container) {
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
const firstVisibleRow = itemRefs.current.find((row) => {
|
||||
if (!row) return false;
|
||||
return row.getBoundingClientRect().bottom >= containerTop;
|
||||
});
|
||||
|
||||
if (firstVisibleRow?.dataset.timelineMessageId) {
|
||||
pendingLoadAnchorRef.current = {
|
||||
messageId: firstVisibleRow.dataset.timelineMessageId,
|
||||
top: firstVisibleRow.getBoundingClientRect().top - containerTop,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
preservingLoadPositionRef.current = true;
|
||||
onLoadEarlier?.();
|
||||
}, [onLoadEarlier]);
|
||||
|
||||
const navigateToMessage = React.useCallback(async (messageId: string) => {
|
||||
const didNavigate = await onScrollToMessage?.(messageId);
|
||||
if (didNavigate === false) {
|
||||
@@ -171,16 +248,40 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{canLoadEarlier && onLoadEarlier && (
|
||||
<div className="flex justify-center py-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={handleLoadEarlier}
|
||||
disabled={isLoadingEarlier}
|
||||
className="h-auto px-1 py-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isLoadingEarlier && (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('chat.history.loadOlder')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={listRef} className="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')}
|
||||
</div>
|
||||
) : (
|
||||
filteredMessages.map(({ message, messageNumber }, index) => {
|
||||
filteredMessages.map(({ message }, index) => {
|
||||
const preview = getMessagePreview(message.parts);
|
||||
const timestamp = message.info.time.created;
|
||||
const relativeTime = formatRelativeTime(timestamp);
|
||||
const dateGroup = formatDateGroup(timestamp);
|
||||
const previous = filteredMessages[index - 1];
|
||||
const previousDateGroup = previous
|
||||
? formatDateGroup(previous.message.info.time.created)
|
||||
: null;
|
||||
const showDateGroup = dateGroup !== previousDateGroup;
|
||||
const messageTime = formatMessageTime(timestamp);
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
const snippet = searchQuery.trim()
|
||||
@@ -188,82 +289,85 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.info.id}
|
||||
ref={(element) => {
|
||||
itemRefs.current[index] = element;
|
||||
}}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer",
|
||||
isSelected && "bg-interactive-selection text-interactive-selection-foreground"
|
||||
<React.Fragment key={message.info.id}>
|
||||
{showDateGroup && (
|
||||
<div className="sticky top-0 z-10 flex items-center gap-3 bg-background/95 py-2 backdrop-blur-sm">
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{dateGroup}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
</div>
|
||||
)}
|
||||
onClick={() => void navigateToMessage(message.info.id)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span className={cn(
|
||||
"typography-meta w-5 text-right flex-shrink-0",
|
||||
isSelected ? "text-interactive-selection-foreground/70" : "text-muted-foreground"
|
||||
)}>
|
||||
{messageNumber}.
|
||||
</span>
|
||||
<p className={cn(
|
||||
"flex-1 min-w-0 typography-small truncate ml-0.5",
|
||||
isSelected ? "text-interactive-selection-foreground" : "text-foreground"
|
||||
)}>
|
||||
{snippet ?? (preview || t('chat.timeline.noTextContent'))}
|
||||
{!snippet && preview && preview.length >= 80 && '…'}
|
||||
</p>
|
||||
|
||||
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||
<div
|
||||
ref={(element) => {
|
||||
itemRefs.current[index] = element;
|
||||
}}
|
||||
data-timeline-message-id={message.info.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer",
|
||||
isSelected && "bg-interactive-selection text-interactive-selection-foreground"
|
||||
)}
|
||||
onClick={() => void navigateToMessage(message.info.id)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span className={cn(
|
||||
"typography-meta whitespace-nowrap",
|
||||
isSelected ? "text-interactive-selection-foreground/70" : "text-muted-foreground",
|
||||
alwaysShowActions ? "hidden" : "group-hover:hidden"
|
||||
"typography-meta w-16 flex-shrink-0 text-right tabular-nums",
|
||||
isSelected ? "text-interactive-selection-foreground/70" : "text-muted-foreground"
|
||||
)}>
|
||||
{relativeTime}
|
||||
{messageTime}
|
||||
</span>
|
||||
<p className={cn(
|
||||
"flex-1 min-w-0 typography-small truncate",
|
||||
isSelected ? "text-interactive-selection-foreground" : "text-foreground"
|
||||
)}>
|
||||
{snippet ?? (preview || t('chat.timeline.noTextContent'))}
|
||||
{!snippet && preview && preview.length >= 80 && '…'}
|
||||
</p>
|
||||
|
||||
<div className={cn("gap-1", alwaysShowActions ? "flex" : "hidden group-hover:flex")}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-5 w-5 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await revertToMessage(currentSessionId, message.info.id);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="arrow-go-back" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.revertFromHere')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||
<div className={cn("gap-1", alwaysShowActions ? "flex" : "hidden group-hover:flex")}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-5 w-5 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await revertToMessage(currentSessionId, message.info.id);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="arrow-go-back" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.revertFromHere')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-5 w-5 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFork(message.info.id);
|
||||
}}
|
||||
disabled={forkingMessageId === message.info.id}
|
||||
>
|
||||
{forkingMessageId === message.info.id ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="git-branch" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.forkFromHere')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-5 w-5 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFork(message.info.id);
|
||||
}}
|
||||
disabled={forkingMessageId === message.info.id}
|
||||
>
|
||||
{forkingMessageId === message.info.id ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="git-branch" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.timeline.actions.forkFromHere')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -4,13 +4,11 @@ import { Popover } from '@base-ui/react/popover';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useIsGitRepo } from '@/stores/useGitStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import {
|
||||
type ChangedFile,
|
||||
type ChangedFileEntry,
|
||||
FILE_EDIT_TOOLS,
|
||||
extractChangedFiles,
|
||||
isGitFile,
|
||||
toRelativePath,
|
||||
} from './changedFiles';
|
||||
import { ChangedFilesList } from './ChangedFilesList';
|
||||
@@ -18,7 +16,6 @@ import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './change
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { TurnActivityRecord } from './lib/turns/types';
|
||||
import { toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
|
||||
interface TurnChangedFilesDropdownProps {
|
||||
activityParts: TurnActivityRecord[] | undefined;
|
||||
@@ -29,7 +26,6 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
|
||||
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
|
||||
const triggerButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
const isGitRepo = useIsGitRepo(currentDirectory);
|
||||
|
||||
const changedFiles = React.useMemo<ChangedFile[]>(() => {
|
||||
@@ -56,24 +52,16 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
|
||||
|
||||
const handleOpenFile = (file: ChangedFileEntry) => {
|
||||
if (!currentDirectory) return;
|
||||
if (isGitFile(file)) return;
|
||||
|
||||
const absolutePath = toAbsoluteFilePath(currentDirectory, file.path);
|
||||
|
||||
const editor = runtime?.editor;
|
||||
if (editor) {
|
||||
void editor.openFile(absolutePath);
|
||||
setIsExpanded(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useUIStore.getState();
|
||||
const relativePath = toRelativePath(file.path, currentDirectory);
|
||||
if (!store.isMobile) {
|
||||
store.openContextFile(currentDirectory, absolutePath);
|
||||
store.openContextDiff(currentDirectory, relativePath, false, 'turn');
|
||||
setIsExpanded(false);
|
||||
return;
|
||||
}
|
||||
store.navigateToDiff(toRelativePath(file.path, currentDirectory));
|
||||
|
||||
store.navigateToDiff(relativePath, false, 'turn');
|
||||
store.setRightSidebarOpen(false);
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { ChatMessageEntry } from '../lib/turns/types';
|
||||
|
||||
/**
|
||||
* Dedup logic extracted from MessageList.tsx baseDisplayMessages.
|
||||
* Tests verify that deduplication preserves chronological order
|
||||
* while keeping the latest value for each message ID.
|
||||
*/
|
||||
function deduplicateMessages(messages: ChatMessageEntry[]): ChatMessageEntry[] {
|
||||
const seenIds = new Set<string>();
|
||||
const latestById = new Map<string, ChatMessageEntry>();
|
||||
const dedupedMessages: ChatMessageEntry[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const messageId = message.info?.id;
|
||||
if (typeof messageId === 'string') latestById.set(messageId, message);
|
||||
}
|
||||
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index];
|
||||
const messageId = message.info?.id;
|
||||
if (typeof messageId === 'string') {
|
||||
if (seenIds.has(messageId)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(messageId);
|
||||
}
|
||||
dedupedMessages.push(
|
||||
typeof messageId === 'string' ? latestById.get(messageId) ?? message : message,
|
||||
);
|
||||
}
|
||||
|
||||
return dedupedMessages;
|
||||
}
|
||||
|
||||
function createMessageEntry({
|
||||
id,
|
||||
role,
|
||||
parentID,
|
||||
createdAt,
|
||||
}: {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
parentID?: string;
|
||||
createdAt: number;
|
||||
}): ChatMessageEntry {
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
role,
|
||||
...(parentID ? { parentID } : {}),
|
||||
time: { created: createdAt },
|
||||
} as ChatMessageEntry['info'],
|
||||
parts: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('baseDisplayMessages dedup', () => {
|
||||
test('removes duplicate message IDs, keeping the latest value', () => {
|
||||
const msg1 = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 1 });
|
||||
const msg2 = createMessageEntry({ id: 'msg-2', role: 'assistant', createdAt: 2 });
|
||||
const msg1Duplicate = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 3 });
|
||||
|
||||
const result = deduplicateMessages([msg1, msg2, msg1Duplicate]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]?.info.id).toBe('msg-1');
|
||||
expect(result[0]?.info.time.created).toBe(3);
|
||||
expect(result[1]?.info.id).toBe('msg-2');
|
||||
});
|
||||
|
||||
test('preserves input order when there are no duplicates', () => {
|
||||
const msg1 = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 1 });
|
||||
const msg2 = createMessageEntry({ id: 'msg-2', role: 'assistant', createdAt: 2 });
|
||||
const msg3 = createMessageEntry({ id: 'msg-3', role: 'user', createdAt: 3 });
|
||||
|
||||
const result = deduplicateMessages([msg1, msg2, msg3]);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]?.info.id).toBe('msg-1');
|
||||
expect(result[1]?.info.id).toBe('msg-2');
|
||||
expect(result[2]?.info.id).toBe('msg-3');
|
||||
});
|
||||
|
||||
test('handles empty input', () => {
|
||||
const result = deduplicateMessages([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('handles messages without IDs (keeps all)', () => {
|
||||
const msg1 = { info: { role: 'user' } as ChatMessageEntry['info'], parts: [] };
|
||||
const msg2 = { info: { role: 'assistant' } as ChatMessageEntry['info'], parts: [] };
|
||||
|
||||
const result = deduplicateMessages([msg1, msg2]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('handles empty string ID (treated as no ID, keeps all)', () => {
|
||||
const msg1 = createMessageEntry({ id: '', role: 'user', createdAt: 1 });
|
||||
const msg2 = createMessageEntry({ id: '', role: 'assistant', createdAt: 2 });
|
||||
|
||||
const result = deduplicateMessages([msg1, msg2]);
|
||||
|
||||
// Empty string passes typeof === 'string' check, so it IS deduplicated
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('handles single-element input', () => {
|
||||
const msg1 = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 1 });
|
||||
|
||||
const result = deduplicateMessages([msg1]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.info.id).toBe('msg-1');
|
||||
});
|
||||
|
||||
test('all messages sharing same ID keeps only the latest value', () => {
|
||||
const msg1 = createMessageEntry({ id: 'same-id', role: 'user', createdAt: 1 });
|
||||
const msg2 = createMessageEntry({ id: 'same-id', role: 'assistant', createdAt: 2 });
|
||||
const msg3 = createMessageEntry({ id: 'same-id', role: 'user', createdAt: 3 });
|
||||
|
||||
const result = deduplicateMessages([msg1, msg2, msg3]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.info.id).toBe('same-id');
|
||||
expect(result[0]?.info.role).toBe('user');
|
||||
expect(result[0]?.info.time.created).toBe(3);
|
||||
});
|
||||
|
||||
test('deduplication scenario: prepend history with overlapping IDs', () => {
|
||||
// Simulates history pagination where older messages are prepended
|
||||
// and may overlap with existing messages in the view
|
||||
const existingMsg1 = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 1 });
|
||||
const existingMsg2 = createMessageEntry({ id: 'msg-2', role: 'assistant', createdAt: 2 });
|
||||
|
||||
// Prepended history (older) that overlaps with existing view
|
||||
const prependedMsg1 = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 1 });
|
||||
const prependedMsg2 = createMessageEntry({ id: 'msg-0', role: 'assistant', createdAt: 0 });
|
||||
|
||||
// After prepend, array is: [prepended older msgs, existing msgs]
|
||||
const messages = [prependedMsg2, prependedMsg1, existingMsg1, existingMsg2];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
// Keep the prepended ordering position while retaining the existing value.
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]?.info.id).toBe('msg-0'); // prepended (oldest)
|
||||
expect(result[1]).toBe(existingMsg1);
|
||||
expect(result[2]?.info.id).toBe('msg-2'); // existing
|
||||
});
|
||||
|
||||
test('handles multiple duplicates of the same ID', () => {
|
||||
const msg1 = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 1 });
|
||||
const msg1Dup1 = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 2 });
|
||||
const msg1Dup2 = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 3 });
|
||||
|
||||
const result = deduplicateMessages([msg1, msg1Dup1, msg1Dup2]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.info.id).toBe('msg-1');
|
||||
expect(result[0]?.info.time.created).toBe(3);
|
||||
});
|
||||
|
||||
test('preserves first position while using a later duplicate value', () => {
|
||||
const msg1First = createMessageEntry({ id: 'msg-1', role: 'user', createdAt: 1 });
|
||||
const msg1Later = createMessageEntry({ id: 'msg-1', role: 'assistant', createdAt: 5 });
|
||||
|
||||
const result = deduplicateMessages([msg1First, msg1Later]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.info.role).toBe('assistant');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getFileMentionAutocompleteQuery } from '../fileMentionAutocompleteState';
|
||||
|
||||
describe('getFileMentionAutocompleteQuery', () => {
|
||||
test('opens file mention autocomplete for manually typed boundary @ text', () => {
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value: '@config',
|
||||
cursorPosition: '@config'.length,
|
||||
inputSource: 'manual',
|
||||
})).toBe('config');
|
||||
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value: 'check @main.ts',
|
||||
cursorPosition: 'check @main.ts'.length,
|
||||
inputSource: 'manual',
|
||||
})).toBe('main.ts');
|
||||
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value: 'check @docs',
|
||||
cursorPosition: 'check @docs'.length,
|
||||
})).toBe('docs');
|
||||
});
|
||||
|
||||
test('does not open file mention autocomplete when pasted text contains @', () => {
|
||||
const pastedValues = [
|
||||
'@config',
|
||||
'@/path/to/file',
|
||||
'Use @main.ts',
|
||||
];
|
||||
|
||||
for (const value of pastedValues) {
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value,
|
||||
cursorPosition: value.length,
|
||||
inputSource: 'paste',
|
||||
insertedText: value,
|
||||
})).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not open file mention autocomplete for pasted package and email text', () => {
|
||||
const pastedValues = [
|
||||
'user@email.com',
|
||||
'npx @scope/pkg@latest',
|
||||
];
|
||||
|
||||
for (const value of pastedValues) {
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value,
|
||||
cursorPosition: value.length,
|
||||
inputSource: 'paste',
|
||||
insertedText: value,
|
||||
})).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps autocomplete open when pasting a query fragment after a manually typed @', () => {
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value: '@config',
|
||||
cursorPosition: '@config'.length,
|
||||
inputSource: 'paste',
|
||||
insertedText: 'config',
|
||||
})).toBe('config');
|
||||
});
|
||||
|
||||
test('uses current value when paste source lacks inserted text context', () => {
|
||||
expect(getFileMentionAutocompleteQuery({
|
||||
value: '@config',
|
||||
cursorPosition: '@config'.length,
|
||||
inputSource: 'paste',
|
||||
})).toBe('config');
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TurnListEntry {
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface TurnListProps<TEntry extends TurnListEntry> {
|
||||
entries: TEntry[];
|
||||
renderEntry: (entry: TEntry) => React.ReactNode;
|
||||
}
|
||||
|
||||
const TurnList = <TEntry extends TurnListEntry>({ entries, renderEntry }: TurnListProps<TEntry>): React.ReactElement => {
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
data-turn-entry={entry.key}
|
||||
>
|
||||
{renderEntry(entry)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TurnList) as typeof TurnList;
|
||||
@@ -17,7 +17,7 @@
|
||||
* with ordinary prose (`2 * 3`, `foo_bar`).
|
||||
*/
|
||||
|
||||
export type HighlightStyle =
|
||||
type HighlightStyle =
|
||||
| 'marker'
|
||||
| 'code'
|
||||
| 'codeFence'
|
||||
@@ -27,7 +27,7 @@ export type HighlightStyle =
|
||||
| 'blockquote'
|
||||
| 'listMarker';
|
||||
|
||||
export type MentionKind = 'file' | 'agent';
|
||||
type MentionKind = 'file' | 'agent';
|
||||
|
||||
export interface HighlightRange {
|
||||
start: number;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export type FileMentionAutocompleteInputSource = 'manual' | 'paste';
|
||||
|
||||
export const getFileMentionAutocompleteQuery = ({
|
||||
value,
|
||||
cursorPosition,
|
||||
inputSource = 'manual',
|
||||
insertedText,
|
||||
}: {
|
||||
value: string;
|
||||
cursorPosition: number;
|
||||
inputSource?: FileMentionAutocompleteInputSource;
|
||||
insertedText?: string;
|
||||
}): string | null => {
|
||||
if (inputSource === 'paste' && insertedText?.includes('@')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textBeforeCursor = value.substring(0, cursorPosition);
|
||||
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||
if (lastAtSymbol === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null;
|
||||
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
|
||||
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
||||
if (!isWordBoundary || textAfterAt.includes(' ') || textAfterAt.includes('\n')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return textAfterAt;
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import { isAbsoluteFilePath, normalizeFilePath } from '@/lib/path-utils';
|
||||
|
||||
export type ParsedFileReference = {
|
||||
path: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
endLine?: number;
|
||||
};
|
||||
|
||||
const KNOWN_FILE_BASENAMES = new Set([
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
'readme',
|
||||
'license',
|
||||
'.env',
|
||||
'.gitignore',
|
||||
'.npmrc',
|
||||
]);
|
||||
const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
|
||||
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('|');
|
||||
|
||||
export const normalizeReferencePath = (value: string): string => normalizeFilePath(value);
|
||||
|
||||
export const isAbsoluteReferencePath = (value: string): boolean => isAbsoluteFilePath(value);
|
||||
|
||||
const trimPathCandidate = (value: string): string => {
|
||||
let next = (value || '').trim();
|
||||
if (!next) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) {
|
||||
next = next.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
next = next.replace(/[.,;!?]+$/g, '');
|
||||
|
||||
if (next.endsWith(')') && !next.includes('(')) {
|
||||
next = next.slice(0, -1);
|
||||
}
|
||||
if (next.endsWith(']') && !next.includes('[')) {
|
||||
next = next.slice(0, -1);
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const stripTrailingReference = (value: string): string => {
|
||||
let next = trimPathCandidate(value);
|
||||
if (!next) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const semicolonIndex = next.indexOf(';');
|
||||
if (semicolonIndex >= 0) {
|
||||
next = next.slice(0, semicolonIndex);
|
||||
}
|
||||
|
||||
next = next.replace(/#.*$/, '');
|
||||
|
||||
const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/);
|
||||
if (extensionSuffixMatch) {
|
||||
next = extensionSuffixMatch[1] ?? next;
|
||||
}
|
||||
|
||||
const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0
|
||||
? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i'))
|
||||
: null;
|
||||
if (basenameSuffixMatch) {
|
||||
next = basenameSuffixMatch[1] ?? next;
|
||||
}
|
||||
|
||||
return trimPathCandidate(next);
|
||||
};
|
||||
|
||||
export const parseFileReference = (value: string): ParsedFileReference | null => {
|
||||
const trimmed = trimPathCandidate(value);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const semicolonIndex = trimmed.indexOf(';');
|
||||
const withoutSemicolonSuffix = semicolonIndex >= 0
|
||||
? trimPathCandidate(trimmed.slice(0, semicolonIndex))
|
||||
: trimmed;
|
||||
if (!withoutSemicolonSuffix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Range form: `path:start-end`. Tried before the colon form so a suffix
|
||||
// like `:10-20` is consumed as a range rather than truncated to a line
|
||||
// number. Range and col (`:line:col`) are mutually exclusive.
|
||||
const rangeMatch = withoutSemicolonSuffix.match(/^(.*?):(\d+)-(\d+)$/);
|
||||
if (rangeMatch) {
|
||||
const path = stripTrailingReference(rangeMatch[1] ?? '');
|
||||
const line = Number.parseInt(rangeMatch[2] ?? '', 10);
|
||||
const endLine = Number.parseInt(rangeMatch[3] ?? '', 10);
|
||||
if (!path || !Number.isFinite(line) || !Number.isFinite(endLine) || endLine < line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path, line, endLine };
|
||||
}
|
||||
|
||||
const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i);
|
||||
if (hashMatch) {
|
||||
const path = stripTrailingReference(hashMatch[1] ?? '');
|
||||
const line = Number.parseInt(hashMatch[2] ?? '', 10);
|
||||
const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined;
|
||||
if (!path || !Number.isFinite(line)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
line,
|
||||
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const colonMatch = withoutSemicolonSuffix.match(/^(.*?):(\d+)(?::(\d+))?$/);
|
||||
if (colonMatch) {
|
||||
const path = stripTrailingReference(colonMatch[1] ?? '');
|
||||
const line = Number.parseInt(colonMatch[2] ?? '', 10);
|
||||
const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined;
|
||||
if (!path || !Number.isFinite(line)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
line,
|
||||
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const pathOnly = stripTrailingReference(withoutSemicolonSuffix);
|
||||
if (!pathOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: pathOnly };
|
||||
};
|
||||
|
||||
// Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style
|
||||
// output. Requires a file extension (1-8 alphanumerics) so plain words don't
|
||||
// qualify; the path itself must contain at least one extension-bearing
|
||||
// segment. The line suffix is either `:N`, `:N:M`, or `:N-M` (range); col and
|
||||
// range are mutually exclusive.
|
||||
//
|
||||
// Known limitation: backslash-separated Windows paths (e.g.
|
||||
// `C:\Users\test\file.ts:12`) are not matched because the path character class
|
||||
// does not include `\`. Compiler output inside fenced code blocks predominantly
|
||||
// uses forward slashes, so this is a niche gap. The inline-code pipeline is not
|
||||
// affected — it reads full text content rather than matching with a regex.
|
||||
export const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?(?::\d+)?)?/g;
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { shouldAutoLoadEarlierForUnderfilledPinnedViewport } from './useChatTimelineController';
|
||||
import {
|
||||
isOlderHistoryPrependCommit,
|
||||
shouldAutoLoadEarlierForUnderfilledPinnedViewport,
|
||||
} from './useChatTimelineController';
|
||||
|
||||
const baseInput = {
|
||||
sessionId: 'ses_1',
|
||||
@@ -39,3 +42,29 @@ describe('shouldAutoLoadEarlierForUnderfilledPinnedViewport', () => {
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOlderHistoryPrependCommit', () => {
|
||||
test('detects older messages inserted above the existing timeline', () => {
|
||||
expect(isOlderHistoryPrependCommit({
|
||||
previousOldestId: 'msg_2',
|
||||
previousNewestId: 'msg_4',
|
||||
currentOldestId: 'msg_1',
|
||||
currentNewestId: 'msg_4',
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('does not treat appends or replacements as prepends', () => {
|
||||
expect(isOlderHistoryPrependCommit({
|
||||
previousOldestId: 'msg_2',
|
||||
previousNewestId: 'msg_4',
|
||||
currentOldestId: 'msg_2',
|
||||
currentNewestId: 'msg_5',
|
||||
})).toBe(false);
|
||||
expect(isOlderHistoryPrependCommit({
|
||||
previousOldestId: 'msg_2',
|
||||
previousNewestId: 'msg_4',
|
||||
currentOldestId: 'msg_1',
|
||||
currentNewestId: 'msg_5',
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,17 @@ export interface UseChatTimelineControllerResult {
|
||||
}
|
||||
|
||||
const TURN_MODEL_CACHE_MAX = 30
|
||||
const HISTORY_SCROLL_THRESHOLD = 200
|
||||
// Desktop load-older lead distance. Trigger well before the top: the fetch
|
||||
// then completes and the prepend lands ABOVE the viewport, where key-anchored
|
||||
// compensation is exact and invisible. A short lead (the old 200px) let the
|
||||
// user reach the estimated-height region near the absolute top mid-fetch,
|
||||
// where the post-insert restore is least precise and reads as a small jump.
|
||||
const HISTORY_SCROLL_THRESHOLD_MIN_PX = 1200
|
||||
const HISTORY_SCROLL_VIEWPORT_FACTOR = 1.5
|
||||
const resolveHistoryScrollThreshold = (clientHeight: number): number => Math.max(
|
||||
HISTORY_SCROLL_THRESHOLD_MIN_PX,
|
||||
clientHeight * HISTORY_SCROLL_VIEWPORT_FACTOR,
|
||||
)
|
||||
const VSCODE_TURN_MODEL_CACHE_MAX = 4
|
||||
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
||||
const MOBILE_TURN_MODEL_CACHE_MAX = 4
|
||||
@@ -108,6 +118,75 @@ export const shouldAutoLoadEarlierForUnderfilledPinnedViewport = (input: {
|
||||
return input.scrollHeight <= input.clientHeight + 1;
|
||||
};
|
||||
|
||||
export const isOlderHistoryPrependCommit = (input: {
|
||||
previousOldestId: string | null;
|
||||
previousNewestId: string | null;
|
||||
currentOldestId: string | null;
|
||||
currentNewestId: string | null;
|
||||
}): boolean => Boolean(
|
||||
input.previousOldestId
|
||||
&& input.currentOldestId
|
||||
&& input.currentOldestId !== input.previousOldestId
|
||||
&& input.previousNewestId
|
||||
&& input.currentNewestId
|
||||
&& input.currentNewestId === input.previousNewestId,
|
||||
);
|
||||
|
||||
// iOS WKWebView ignores programmatic scrollTop writes while a touch drag or
|
||||
// momentum (fling) scroll is active: the native scroll animation keeps running
|
||||
// and overwrites the value on the next frame. The mobile history threshold is
|
||||
// large enough that the prepend commit almost always lands mid-fling, so a
|
||||
// plain `container.scrollTop = target` never sticks. Toggling overflow kills
|
||||
// the native scroll synchronously (pre-paint, invisible inside a layout
|
||||
// effect); a short post-paint watchdog re-asserts the target if residual
|
||||
// momentum still drags the viewport upward.
|
||||
const MOMENTUM_WATCHDOG_FRAMES = 20;
|
||||
const MOMENTUM_WATCHDOG_TOLERANCE_PX = 4;
|
||||
|
||||
const setScrollTopDefeatingMomentum = (container: HTMLElement, target: number) => {
|
||||
const previousOverflow = container.style.overflow;
|
||||
container.style.overflow = 'hidden';
|
||||
container.scrollTop = target;
|
||||
void container.scrollHeight;
|
||||
container.style.overflow = previousOverflow;
|
||||
container.scrollTop = target;
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
let cancelled = false;
|
||||
let frames = 0;
|
||||
const cancelOnUserTouch = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
container.addEventListener('touchstart', cancelOnUserTouch, { passive: true, once: true });
|
||||
const watch = () => {
|
||||
if (cancelled) return;
|
||||
// Only correct upward drift (residual momentum). Downward movement or
|
||||
// content growth above the viewport must not be fought here.
|
||||
if (container.scrollTop < target - MOMENTUM_WATCHDOG_TOLERANCE_PX) {
|
||||
container.scrollTop = target;
|
||||
}
|
||||
frames += 1;
|
||||
if (frames < MOMENTUM_WATCHDOG_FRAMES) {
|
||||
window.requestAnimationFrame(watch);
|
||||
} else {
|
||||
container.removeEventListener('touchstart', cancelOnUserTouch);
|
||||
}
|
||||
};
|
||||
window.requestAnimationFrame(watch);
|
||||
};
|
||||
|
||||
const hasInsertedBeforeKnownOldest = (
|
||||
previousOldestId: string | null,
|
||||
currentOldestId: string | null,
|
||||
messages: ChatMessageEntry[],
|
||||
): boolean => {
|
||||
if (!previousOldestId || !currentOldestId || currentOldestId === previousOldestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return messages.some((message) => message.info.id === previousOldestId);
|
||||
};
|
||||
|
||||
export const useChatTimelineController = ({
|
||||
sessionId,
|
||||
messages,
|
||||
@@ -321,9 +400,12 @@ export const useChatTimelineController = ({
|
||||
// before triggering the state change. useLayoutEffect consumes it
|
||||
// after React commits new DOM — before the browser paints.
|
||||
const prePrependScrollRef = React.useRef<{
|
||||
sessionId: string | null;
|
||||
height: number;
|
||||
top: number;
|
||||
anchor: ViewportAnchor | null;
|
||||
oldestId: string | null;
|
||||
newestId: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
|
||||
@@ -346,56 +428,165 @@ export const useChatTimelineController = ({
|
||||
scrollHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
prePrependScrollRef.current = null;
|
||||
prependTrackingRef.current = null;
|
||||
}, [sessionId]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const snap = prePrependScrollRef.current;
|
||||
if (snap) {
|
||||
let snap = prePrependScrollRef.current;
|
||||
const prev = prependTrackingRef.current;
|
||||
const currentOldestId = renderedMessages[0]?.info?.id ?? null;
|
||||
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
|
||||
// A prepend = content inserted ABOVE the viewport: either the newest
|
||||
// stayed fixed, or the old first message still exists below a new first
|
||||
// message. The latter keeps preservation alive if a tail append lands in
|
||||
// the same commit as the history page.
|
||||
const isPrepend = prev
|
||||
? isOlderHistoryPrependCommit({
|
||||
previousOldestId: prev.oldestId,
|
||||
previousNewestId: prev.newestId,
|
||||
currentOldestId,
|
||||
currentNewestId,
|
||||
}) || hasInsertedBeforeKnownOldest(prev.oldestId, currentOldestId, renderedMessages)
|
||||
: false;
|
||||
|
||||
if (snap && snap.sessionId !== sessionIdRef.current) {
|
||||
prePrependScrollRef.current = null;
|
||||
snap = null;
|
||||
}
|
||||
|
||||
const isSnapshotPrepend = snap
|
||||
? isOlderHistoryPrependCommit({
|
||||
previousOldestId: snap.oldestId,
|
||||
previousNewestId: snap.newestId,
|
||||
currentOldestId,
|
||||
currentNewestId,
|
||||
}) || hasInsertedBeforeKnownOldest(snap.oldestId, currentOldestId, renderedMessages)
|
||||
: false;
|
||||
const didPrepend = isPrepend || isSnapshotPrepend;
|
||||
const shouldConsumeSnapshot = Boolean(snap && (isPrepend || isSnapshotPrepend));
|
||||
|
||||
const updateTracking = () => {
|
||||
prependTrackingRef.current = {
|
||||
oldestId: currentOldestId,
|
||||
newestId: currentNewestId,
|
||||
scrollHeight: container.scrollHeight,
|
||||
};
|
||||
};
|
||||
|
||||
const refreshPendingSnapshot = () => {
|
||||
const pending = prePrependScrollRef.current;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
prePrependScrollRef.current = {
|
||||
...pending,
|
||||
height: container.scrollHeight,
|
||||
top: container.scrollTop,
|
||||
anchor: captureViewportAnchor(),
|
||||
oldestId: currentOldestId,
|
||||
newestId: currentNewestId,
|
||||
};
|
||||
};
|
||||
|
||||
if (isPinnedRef.current) {
|
||||
// Bottom-pinned. Only content inserted ABOVE (a prepend / history load)
|
||||
// needs an explicit re-pin: with overflow-anchor:none the browser leaves
|
||||
// scrollTop unchanged, so the viewport would visibly jump. Route that
|
||||
// through goToBottom — the single programmatic writer.
|
||||
//
|
||||
// A normal bottom APPEND (a sent message, a streaming part) must NOT
|
||||
// re-pin here. Auto-follow already owns the bottom: its content
|
||||
// ResizeObserver re-pins instantly (scrollTop = scrollHeight, before
|
||||
// paint) on every append. Re-pinning again from here would just be a
|
||||
// second writer chasing the same target a frame later — redundant at
|
||||
// best, and the source of the old up/down jiggle on send / from the
|
||||
// queue / while streaming. So for an append we do nothing and let
|
||||
// auto-follow own it.
|
||||
if (didPrepend) {
|
||||
prePrependScrollRef.current = null;
|
||||
goToBottom('instant');
|
||||
} else if (snap) {
|
||||
refreshPendingSnapshot();
|
||||
}
|
||||
updateTracking();
|
||||
return;
|
||||
}
|
||||
|
||||
// When the history list is virtualized, virtua runs with `shift` during
|
||||
// history loads and compensates the prepend internally. Manual
|
||||
// height-delta compensation on top of that applies the same delta twice
|
||||
// and throws the viewport far downward. Anchor restore stays allowed —
|
||||
// it corrects to an absolute element position, so it cannot double up.
|
||||
const historyVirtualized = messageListRef.current?.isHistoryVirtualized() ?? false;
|
||||
|
||||
if (snap && shouldConsumeSnapshot) {
|
||||
prePrependScrollRef.current = null;
|
||||
const heightDelta = container.scrollHeight - snap.height;
|
||||
const applyHeightDelta = (): boolean => {
|
||||
if (historyVirtualized || heightDelta <= 0) {
|
||||
return false;
|
||||
}
|
||||
container.scrollTop = snap.top + heightDelta;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Non-virtualized mobile list only: fight iOS momentum manually.
|
||||
// The virtualized mobile list (tanstack) defers prepend adjustments
|
||||
// through touch/momentum in core, so manual writes would double up.
|
||||
if (isMobileSurfaceRuntime() && !historyVirtualized && heightDelta > 0) {
|
||||
setScrollTopDefeatingMomentum(container, snap.top + heightDelta);
|
||||
updateTracking();
|
||||
return;
|
||||
}
|
||||
|
||||
// When a viewport anchor is available, delegate to MessageList
|
||||
// restoreViewportAnchor which falls back to virtualizer-aware
|
||||
// scrollHistoryIndexIntoView when the element is not in the DOM.
|
||||
// Note: an unchanged scrollTop after restore is NOT a failure here —
|
||||
// the virtualized list compensates the prepend internally, so
|
||||
// staying near snap.top is the correct outcome.
|
||||
if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
|
||||
// Fallback: height-delta compensation
|
||||
const delta = container.scrollHeight - snap.height;
|
||||
if (delta > 0) {
|
||||
container.scrollTop = snap.top + delta;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Auto-detect a prepend: the oldest message changed while the newest
|
||||
// stayed the same (distinguishes a real prepend from a session
|
||||
// switch, a bottom append, or a streaming part growing). Compensate
|
||||
// synchronously by the exact height delta — for a bottom-pinned
|
||||
// viewport this keeps it pinned, for a released one it preserves the
|
||||
// read position, with no intermediate frame for auto-follow to fight.
|
||||
const prev = prependTrackingRef.current;
|
||||
const currentOldestId = renderedMessages[0]?.info?.id ?? null;
|
||||
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
|
||||
const isPrepend = Boolean(
|
||||
prev
|
||||
&& prev.oldestId
|
||||
&& currentOldestId
|
||||
&& currentOldestId !== prev.oldestId
|
||||
&& prev.newestId
|
||||
&& currentNewestId
|
||||
&& currentNewestId === prev.newestId,
|
||||
);
|
||||
if (isPrepend && prev) {
|
||||
const delta = container.scrollHeight - prev.scrollHeight;
|
||||
if (delta > 0) {
|
||||
container.scrollTop = container.scrollTop + delta;
|
||||
applyHeightDelta();
|
||||
}
|
||||
if (historyVirtualized && snap.anchor && isMobileSurfaceRuntime()) {
|
||||
// Mobile only: freshly prepended rows keep re-measuring for a
|
||||
// few frames and each pass can shift content, so hold the
|
||||
// anchor until it settles. Desktop must NOT run this — wheel
|
||||
// scrolling during the hold would fight the re-assertions and
|
||||
// read as a frozen scroll; the virtualizer's own anchoring is
|
||||
// enough there.
|
||||
messageListRef.current?.holdViewportAnchor(snap.anchor);
|
||||
}
|
||||
} else if (isPrepend && prev && !historyVirtualized) {
|
||||
// Released viewport: preserve the read position by compensating for the
|
||||
// exact height the prepend added above, with no intermediate frame for
|
||||
// auto-follow to fight. Virtualized lists skip this — virtua `shift`
|
||||
// already compensated the prepend.
|
||||
const delta = container.scrollHeight - prev.scrollHeight;
|
||||
if (delta > 0) {
|
||||
const target = container.scrollTop + delta;
|
||||
if (isMobileSurfaceRuntime()) {
|
||||
setScrollTopDefeatingMomentum(container, target);
|
||||
} else {
|
||||
container.scrollTop = target;
|
||||
}
|
||||
}
|
||||
} else if (snap) {
|
||||
// setIsLoadingOlder/historyMeta can commit before the server page
|
||||
// arrives. Keep the snapshot armed, but refresh it so later fallback
|
||||
// compensation only accounts for rows actually prepended above.
|
||||
refreshPendingSnapshot();
|
||||
}
|
||||
|
||||
prependTrackingRef.current = {
|
||||
oldestId: renderedMessages[0]?.info?.id ?? null,
|
||||
newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null,
|
||||
scrollHeight: container.scrollHeight,
|
||||
};
|
||||
}, [renderedMessages, scrollRef, restoreViewportAnchor]);
|
||||
updateTracking();
|
||||
}, [captureViewportAnchor, messageListRef, renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]);
|
||||
|
||||
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []);
|
||||
|
||||
@@ -419,9 +610,12 @@ export const useChatTimelineController = ({
|
||||
// compensate synchronously when React commits the new messages.
|
||||
if (input.preserveViewport && container) {
|
||||
prePrependScrollRef.current = {
|
||||
sessionId: sessionIdRef.current,
|
||||
height: container.scrollHeight,
|
||||
top: container.scrollTop,
|
||||
anchor: captureViewportAnchor(),
|
||||
oldestId: beforeOldestMessageId,
|
||||
newestId: beforeMessages[beforeMessages.length - 1]?.info?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -431,6 +625,7 @@ export const useChatTimelineController = ({
|
||||
try {
|
||||
const targetSessionId = sessionIdRef.current;
|
||||
if (!targetSessionId) {
|
||||
prePrependScrollRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -442,6 +637,7 @@ export const useChatTimelineController = ({
|
||||
while (true) {
|
||||
await loadMoreMessages(targetSessionId, 'up');
|
||||
if (sessionIdRef.current !== targetSessionId) {
|
||||
prePrependScrollRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -463,6 +659,7 @@ export const useChatTimelineController = ({
|
||||
return true;
|
||||
}
|
||||
if (!messageGrowth) {
|
||||
prePrependScrollRef.current = null;
|
||||
return false;
|
||||
}
|
||||
if (!historySignalsRef.current.hasMoreAboveTurns) {
|
||||
@@ -473,6 +670,9 @@ export const useChatTimelineController = ({
|
||||
loadedOldestMessageId = afterOldestMessageId;
|
||||
loadedLimit = afterLimit;
|
||||
}
|
||||
} catch (error) {
|
||||
prePrependScrollRef.current = null;
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoadingOlder(false);
|
||||
settleHistoryInteraction();
|
||||
@@ -493,10 +693,16 @@ export const useChatTimelineController = ({
|
||||
}, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, settleHistoryInteraction]);
|
||||
|
||||
const handleHistoryScroll = React.useCallback(() => {
|
||||
// Mobile never loads history from scroll position: any prepend racing
|
||||
// an active touch gesture can be hijacked by the native scroll
|
||||
// animation. The user scrolls to the natural top and taps an explicit
|
||||
// "load older" button instead — the insert then happens from a resting
|
||||
// state, which is fully deterministic.
|
||||
if (isMobileSurfaceRuntime()) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
if (isPinnedRef.current) return;
|
||||
if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return;
|
||||
if (container.scrollTop >= resolveHistoryScrollThreshold(container.clientHeight)) return;
|
||||
if (!historySignalsRef.current.canLoadEarlier) return;
|
||||
if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return;
|
||||
|
||||
@@ -504,6 +710,10 @@ export const useChatTimelineController = ({
|
||||
}, [loadEarlier, scrollRef]);
|
||||
|
||||
const loadEarlierIfPinnedViewportUnderfilled = React.useCallback(() => {
|
||||
// On mobile the initial page is intentionally smaller. Auto-prepending
|
||||
// older rows after first paint shifts the narrow timeline; let explicit
|
||||
// upward scroll request history instead.
|
||||
if (isMobileSurfaceRuntime()) return;
|
||||
if (historyInteractionRef.current) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
export type ChatHashTarget =
|
||||
type ChatHashTarget =
|
||||
| { kind: 'turn'; id: string }
|
||||
| { kind: 'message'; id: string };
|
||||
|
||||
export const parseChatHashTarget = (hashValue: string): ChatHashTarget | null => {
|
||||
const parseChatHashTarget = (hashValue: string): ChatHashTarget | null => {
|
||||
const value = hashValue.startsWith('#') ? hashValue.slice(1) : hashValue;
|
||||
if (!value) {
|
||||
return null;
|
||||
@@ -28,7 +28,7 @@ type TurnOffsetTarget =
|
||||
| { kind: 'resume' }
|
||||
| { kind: 'turn'; turnId: string };
|
||||
|
||||
export const resolveTurnOffsetTarget = (
|
||||
const resolveTurnOffsetTarget = (
|
||||
turnIds: string[],
|
||||
activeTurnId: string | null,
|
||||
offset: number,
|
||||
|
||||
@@ -9,7 +9,7 @@ interface UseStreamingTextThrottleInput {
|
||||
|
||||
const DEFAULT_STREAMING_TEXT_THROTTLE_MS = 100;
|
||||
|
||||
export const computeStreamingThrottleDelay = (lastEmitAt: number, now: number, throttleMs: number): number => {
|
||||
const computeStreamingThrottleDelay = (lastEmitAt: number, now: number, throttleMs: number): number => {
|
||||
const elapsed = now - lastEmitAt;
|
||||
return Math.max(0, throttleMs - elapsed);
|
||||
};
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { TurnProjectionResult } from '../lib/turns/types';
|
||||
|
||||
export interface TurnLookupResult {
|
||||
turnById: TurnProjectionResult['indexes']['turnById'];
|
||||
messageToTurnId: TurnProjectionResult['indexes']['messageToTurnId'];
|
||||
messageMetaById: TurnProjectionResult['indexes']['messageMetaById'];
|
||||
getTurnByMessageId: (messageId: string) => TurnProjectionResult['turns'][number] | undefined;
|
||||
}
|
||||
|
||||
export const useTurnLookup = (projection: TurnProjectionResult): TurnLookupResult => {
|
||||
const getTurnByMessageId = React.useCallback((messageId: string) => {
|
||||
const turnId = projection.indexes.messageToTurnId.get(messageId);
|
||||
if (!turnId) {
|
||||
return undefined;
|
||||
}
|
||||
return projection.indexes.turnById.get(turnId);
|
||||
}, [projection.indexes.messageToTurnId, projection.indexes.turnById]);
|
||||
|
||||
return {
|
||||
turnById: projection.indexes.turnById,
|
||||
messageToTurnId: projection.indexes.messageToTurnId,
|
||||
messageMetaById: projection.indexes.messageMetaById,
|
||||
getTurnByMessageId,
|
||||
};
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
interface SessionLinkRecord {
|
||||
id: string;
|
||||
parentID?: string;
|
||||
}
|
||||
|
||||
export const collectVisibleSessionIdsForBlockingRequests = (
|
||||
sessions: SessionLinkRecord[] | undefined,
|
||||
currentSessionId: string | null,
|
||||
): string[] => {
|
||||
if (!currentSessionId) return [];
|
||||
if (!Array.isArray(sessions) || sessions.length === 0) return [currentSessionId];
|
||||
|
||||
const current = sessions.find((session) => session.id === currentSessionId);
|
||||
if (!current) return [currentSessionId];
|
||||
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const session of sessions) {
|
||||
if (!session.parentID) {
|
||||
continue;
|
||||
}
|
||||
const existing = childrenByParent.get(session.parentID) ?? [];
|
||||
existing.push(session.id);
|
||||
childrenByParent.set(session.parentID, existing);
|
||||
}
|
||||
|
||||
const scoped = [currentSessionId];
|
||||
const seen = new Set(scoped);
|
||||
for (const sessionId of scoped) {
|
||||
const children = childrenByParent.get(sessionId) ?? [];
|
||||
for (const childId of children) {
|
||||
if (seen.has(childId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(childId);
|
||||
scoped.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
return scoped;
|
||||
};
|
||||
|
||||
export const flattenBlockingRequests = <T extends { id: string }>(
|
||||
source: Map<string, T[]>,
|
||||
sessionIds: string[],
|
||||
): T[] => {
|
||||
if (sessionIds.length === 0) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: T[] = [];
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
const entries = source.get(sessionId);
|
||||
if (!entries || entries.length === 0) continue;
|
||||
for (const entry of entries) {
|
||||
if (seen.has(entry.id)) continue;
|
||||
seen.add(entry.id);
|
||||
result.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
export const normalizeWheelDelta = (input: {
|
||||
deltaY: number;
|
||||
deltaMode: number;
|
||||
rootHeight?: number;
|
||||
}): number => {
|
||||
if (input.deltaMode === 1) {
|
||||
return input.deltaY * 40;
|
||||
}
|
||||
if (input.deltaMode === 2) {
|
||||
return input.deltaY * (input.rootHeight ?? 120);
|
||||
}
|
||||
return input.deltaY;
|
||||
};
|
||||
|
||||
export const shouldMarkBoundaryGesture = (input: {
|
||||
delta: number;
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}): boolean => {
|
||||
const max = input.scrollHeight - input.clientHeight;
|
||||
if (max <= 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!input.delta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.delta < 0) {
|
||||
return input.scrollTop + input.delta <= 0;
|
||||
}
|
||||
|
||||
const remaining = max - input.scrollTop;
|
||||
return input.delta > remaining;
|
||||
};
|
||||
|
||||
export const boundaryTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement => {
|
||||
const current = target instanceof Element ? target : undefined;
|
||||
const nested = current?.closest('[data-scrollable]');
|
||||
if (!nested || nested === root) {
|
||||
return root;
|
||||
}
|
||||
if (!(nested instanceof HTMLElement)) {
|
||||
return root;
|
||||
}
|
||||
return nested;
|
||||
};
|
||||
|
||||
export const shouldPauseAutoScrollOnWheel = (input: {
|
||||
root: HTMLElement;
|
||||
target: EventTarget | null;
|
||||
delta: number;
|
||||
}): boolean => {
|
||||
if (input.delta >= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = boundaryTarget(input.root, input.target);
|
||||
if (target === input.root) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return shouldMarkBoundaryGesture({
|
||||
delta: input.delta,
|
||||
scrollTop: target.scrollTop,
|
||||
scrollHeight: target.scrollHeight,
|
||||
clientHeight: target.clientHeight,
|
||||
});
|
||||
};
|
||||
|
||||
export const isNearTop = (scrollTop: number, threshold: number): boolean => {
|
||||
return scrollTop <= threshold;
|
||||
};
|
||||
|
||||
export const isNearBottom = (distanceFromBottom: number, threshold: number): boolean => {
|
||||
return distanceFromBottom <= threshold;
|
||||
};
|
||||
@@ -18,7 +18,7 @@ type ScrollSpyInput = {
|
||||
MutationObserver?: typeof globalThis.MutationObserver;
|
||||
};
|
||||
|
||||
export const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | undefined => {
|
||||
const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | undefined => {
|
||||
if (list.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | u
|
||||
return sorted[0]?.id;
|
||||
};
|
||||
|
||||
export const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => {
|
||||
const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => {
|
||||
if (list.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
export const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
|
||||
|
||||
export const HIDDEN_INTERNAL_TOOL_NAMES = new Set<string>(['todowrite', 'todoread']);
|
||||
|
||||
export const TURN_TEXT_THROTTLE_DEFAULT_MS = 100;
|
||||
|
||||
@@ -1,67 +1,6 @@
|
||||
import type { SessionMemoryState } from '@/sync/viewport-store';
|
||||
|
||||
export interface TurnHistorySignalsInput {
|
||||
memoryState: SessionMemoryState | null;
|
||||
loadedMessageCount: number;
|
||||
loadedTurnCount: number;
|
||||
turnStart: number;
|
||||
defaultHistoryLimit: number;
|
||||
}
|
||||
|
||||
export interface TurnHistorySignals {
|
||||
hasBufferedTurns: boolean;
|
||||
hasMoreAboveTurns: boolean;
|
||||
historyLoading: boolean;
|
||||
canLoadEarlier: boolean;
|
||||
}
|
||||
|
||||
const deriveHasMoreAbove = (
|
||||
memoryState: SessionMemoryState | null,
|
||||
loadedMessageCount: number,
|
||||
loadedTurnCount: number,
|
||||
defaultHistoryLimit: number,
|
||||
): boolean => {
|
||||
if (!memoryState) {
|
||||
return loadedMessageCount >= defaultHistoryLimit;
|
||||
}
|
||||
|
||||
if (memoryState.historyComplete === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (memoryState.hasMoreTurnsAbove === true || memoryState.hasMoreAbove === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (memoryState.historyComplete === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (memoryState.hasMoreTurnsAbove === false || memoryState.hasMoreAbove === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fallbackMessageSignal = loadedMessageCount >= defaultHistoryLimit;
|
||||
const fallbackTurnSignal = loadedTurnCount >= Math.max(1, Math.floor(defaultHistoryLimit / 2));
|
||||
return fallbackMessageSignal || fallbackTurnSignal;
|
||||
};
|
||||
|
||||
export const deriveTurnHistorySignals = (
|
||||
input: TurnHistorySignalsInput,
|
||||
): TurnHistorySignals => {
|
||||
const hasBufferedTurns = input.turnStart > 0;
|
||||
const hasMoreAboveTurns = deriveHasMoreAbove(
|
||||
input.memoryState,
|
||||
input.loadedMessageCount,
|
||||
input.loadedTurnCount,
|
||||
input.defaultHistoryLimit,
|
||||
);
|
||||
const historyLoading = Boolean(input.memoryState?.historyLoading);
|
||||
|
||||
return {
|
||||
hasBufferedTurns,
|
||||
hasMoreAboveTurns,
|
||||
historyLoading,
|
||||
canLoadEarlier: hasBufferedTurns || hasMoreAboveTurns,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { projectTurnIndexes } from './projectTurnIndexes';
|
||||
import type { TurnProjectionResult, TurnRecord } from './types';
|
||||
|
||||
const areTurnMessagesReferenceStable = (previousTurn: TurnRecord, nextTurn: TurnRecord): boolean => {
|
||||
if (previousTurn.userMessage !== nextTurn.userMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previousTurn.assistantMessages.length !== nextTurn.assistantMessages.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < previousTurn.assistantMessages.length; index += 1) {
|
||||
if (previousTurn.assistantMessages[index] !== nextTurn.assistantMessages[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const buildTurnSignature = (turn: TurnRecord): string => {
|
||||
const assistantIds = turn.assistantMessageIds.join(',');
|
||||
return [
|
||||
turn.turnId,
|
||||
turn.headerMessageId ?? '',
|
||||
assistantIds,
|
||||
turn.summaryText ?? '',
|
||||
turn.stream.isStreaming ? '1' : '0',
|
||||
turn.stream.isRetrying ? '1' : '0',
|
||||
turn.completedAt ?? '',
|
||||
].join('|');
|
||||
};
|
||||
|
||||
export const stabilizeTurnProjection = (
|
||||
nextProjection: TurnProjectionResult,
|
||||
previousProjection: TurnProjectionResult | null,
|
||||
): TurnProjectionResult => {
|
||||
if (!previousProjection || previousProjection.turns.length === 0 || nextProjection.turns.length === 0) {
|
||||
return nextProjection;
|
||||
}
|
||||
|
||||
const previousById = new Map(previousProjection.turns.map((turn) => [turn.turnId, turn]));
|
||||
let reused = false;
|
||||
|
||||
const stabilizedTurns = nextProjection.turns.map((turn, index) => {
|
||||
const isLastTurn = index === nextProjection.turns.length - 1;
|
||||
if (isLastTurn) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
const previousTurn = previousById.get(turn.turnId);
|
||||
if (!previousTurn) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
if (buildTurnSignature(previousTurn) !== buildTurnSignature(turn)) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
if (!areTurnMessagesReferenceStable(previousTurn, turn)) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
reused = true;
|
||||
return previousTurn;
|
||||
});
|
||||
|
||||
if (!reused) {
|
||||
return nextProjection;
|
||||
}
|
||||
|
||||
const projection = projectTurnIndexes(stabilizedTurns);
|
||||
return {
|
||||
...projection,
|
||||
ungroupedMessageIds: nextProjection.ungroupedMessageIds,
|
||||
};
|
||||
};
|
||||
@@ -1,159 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface TurnStageConfig {
|
||||
init: number;
|
||||
batch: number;
|
||||
}
|
||||
|
||||
export interface UseStageTurnsOptions {
|
||||
sessionKey: string;
|
||||
turnStart: number;
|
||||
totalTurns: number;
|
||||
config?: Partial<TurnStageConfig>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface StageTurnsResult {
|
||||
stagedCount: number;
|
||||
stageStartIndex: number;
|
||||
isStaging: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_STAGE_CONFIG: TurnStageConfig = {
|
||||
init: 10,
|
||||
batch: 8,
|
||||
};
|
||||
|
||||
export const getInitialStageCount = (total: number, config: TurnStageConfig): number => {
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(total, Math.max(1, config.init));
|
||||
};
|
||||
|
||||
export const getNextStageCount = (current: number, total: number, config: TurnStageConfig): number => {
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const batch = Math.max(1, config.batch);
|
||||
return Math.min(total, current + batch);
|
||||
};
|
||||
|
||||
export const getStageStartIndex = (total: number, stagedCount: number): number => {
|
||||
if (stagedCount >= total) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, total - stagedCount);
|
||||
};
|
||||
|
||||
export const useStageTurns = ({
|
||||
sessionKey,
|
||||
turnStart,
|
||||
totalTurns,
|
||||
config,
|
||||
disabled,
|
||||
}: UseStageTurnsOptions): StageTurnsResult => {
|
||||
const effectiveConfig = React.useMemo<TurnStageConfig>(() => {
|
||||
return {
|
||||
init: config?.init ?? DEFAULT_STAGE_CONFIG.init,
|
||||
batch: config?.batch ?? DEFAULT_STAGE_CONFIG.batch,
|
||||
};
|
||||
}, [config?.batch, config?.init]);
|
||||
|
||||
const [state, setState] = React.useState(() => ({
|
||||
activeSession: '',
|
||||
completedSession: '',
|
||||
count: totalTurns,
|
||||
}));
|
||||
|
||||
const stateRef = React.useRef(state);
|
||||
React.useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let frameId: number | null = null;
|
||||
const snapshot = stateRef.current;
|
||||
const shouldStage =
|
||||
!disabled
|
||||
&& turnStart > 0
|
||||
&& totalTurns > effectiveConfig.init
|
||||
&& snapshot.completedSession !== sessionKey
|
||||
&& snapshot.activeSession !== sessionKey;
|
||||
|
||||
if (!shouldStage) {
|
||||
setState((previous) => {
|
||||
if (previous.count === totalTurns && previous.activeSession === '') {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
activeSession: '',
|
||||
count: totalTurns,
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
if (frameId !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let nextCount = getInitialStageCount(totalTurns, effectiveConfig);
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
activeSession: sessionKey,
|
||||
count: nextCount,
|
||||
}));
|
||||
|
||||
const step = () => {
|
||||
nextCount = getNextStageCount(nextCount, totalTurns, effectiveConfig);
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
count: nextCount,
|
||||
}));
|
||||
|
||||
if (nextCount >= totalTurns) {
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
completedSession: sessionKey,
|
||||
activeSession: '',
|
||||
count: totalTurns,
|
||||
}));
|
||||
frameId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
frameId = window.requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
frameId = window.requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (frameId !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}, [disabled, effectiveConfig, sessionKey, totalTurns, turnStart]);
|
||||
|
||||
const stagedCount = React.useMemo(() => {
|
||||
if (turnStart <= 0 || disabled) {
|
||||
return totalTurns;
|
||||
}
|
||||
if (state.completedSession === sessionKey) {
|
||||
return totalTurns;
|
||||
}
|
||||
if (state.count <= 0) {
|
||||
return getInitialStageCount(totalTurns, effectiveConfig);
|
||||
}
|
||||
return Math.min(totalTurns, state.count);
|
||||
}, [disabled, effectiveConfig, sessionKey, state.completedSession, state.count, totalTurns, turnStart]);
|
||||
|
||||
return {
|
||||
stagedCount,
|
||||
stageStartIndex: getStageStartIndex(totalTurns, stagedCount),
|
||||
isStaging: !disabled && turnStart > 0 && state.activeSession === sessionKey && state.completedSession !== sessionKey,
|
||||
};
|
||||
};
|
||||
@@ -5,7 +5,7 @@ export interface ChatMessageEntry {
|
||||
parts: Part[];
|
||||
}
|
||||
|
||||
export type TurnActivityKind = 'tool' | 'reasoning' | 'justification';
|
||||
type TurnActivityKind = 'tool' | 'reasoning' | 'justification';
|
||||
|
||||
export interface TurnMessageRecord {
|
||||
messageId: string;
|
||||
@@ -83,7 +83,7 @@ export interface TurnRecord {
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface TurnMessageMeta {
|
||||
interface TurnMessageMeta {
|
||||
turnId: string;
|
||||
messageId: string;
|
||||
userMessageId: string;
|
||||
@@ -115,6 +115,7 @@ export interface TurnGroupingContext {
|
||||
activityOwnerMessageId?: string;
|
||||
isFirstAssistantInTurn: boolean;
|
||||
isLastAssistantInTurn: boolean;
|
||||
isLatestTurn: boolean;
|
||||
summaryBody?: string;
|
||||
activityParts?: TurnActivityRecord[];
|
||||
activityGroupSegments?: TurnActivityGroup[];
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl } from '@/lib/url';
|
||||
import { dropdownMenuItemClass, dropdownMenuPopupClass } from '@/components/ui/dropdown-menu.styles';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { getMermaidViewerController } from './mermaidViewer';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared decoration context
|
||||
@@ -11,16 +13,31 @@ export type MermaidRender = { svg?: string; ascii?: string };
|
||||
export type DecorateLabels = {
|
||||
copy: string;
|
||||
copied: string;
|
||||
enableCodeWrap: string;
|
||||
disableCodeWrap: string;
|
||||
copyTable: string;
|
||||
downloadTable: string;
|
||||
copyDiagram: string;
|
||||
downloadDiagram: string;
|
||||
zoomInDiagram: string;
|
||||
zoomOutDiagram: string;
|
||||
resetDiagramView: string;
|
||||
previewLabel: string;
|
||||
previewTitle: string;
|
||||
};
|
||||
|
||||
export type MermaidControlOptions = {
|
||||
download: boolean;
|
||||
copy: boolean;
|
||||
showPanZoomControls: boolean;
|
||||
};
|
||||
|
||||
export type DecorateContext = {
|
||||
labels: DecorateLabels;
|
||||
mermaidControls: MermaidControlOptions;
|
||||
codeBlockLineWrap: boolean;
|
||||
deferCodeLineNumberSync?: boolean;
|
||||
onToggleCodeBlockLineWrap?: () => void;
|
||||
// Renders a mermaid block source to svg/ascii using current theme colors.
|
||||
renderMermaid: (source: string) => MermaidRender;
|
||||
onPreviewLoopback?: (url: string) => void;
|
||||
@@ -29,19 +46,23 @@ export type DecorateContext = {
|
||||
// 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: string): string =>
|
||||
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'),
|
||||
} as const;
|
||||
|
||||
const ICON_BTN_CLASS =
|
||||
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors';
|
||||
'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 setHtml = (el: Element, html: string): void => {
|
||||
const setIconHtml = (el: Element, html: string): void => {
|
||||
el.innerHTML = html;
|
||||
};
|
||||
|
||||
@@ -52,16 +73,162 @@ 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);
|
||||
setHtml(button, ICONS[icon]);
|
||||
setIconHtml(button, ICONS[icon]);
|
||||
return button;
|
||||
};
|
||||
|
||||
const applyCodeBlockWrapState = (wrapper: HTMLElement, enabled: boolean, labels: DecorateLabels): void => {
|
||||
const body = wrapper.querySelector<HTMLElement>('[data-md-code-body]');
|
||||
const pre = wrapper.querySelector<HTMLElement>('pre');
|
||||
const code = wrapper.querySelector<HTMLElement>('pre code');
|
||||
const wrapButton = wrapper.querySelector<HTMLButtonElement>('[data-md-action="toggle-code-wrap"]');
|
||||
wrapper.setAttribute('data-code-wrap', enabled ? 'true' : 'false');
|
||||
body?.classList.toggle('overflow-x-auto', !enabled);
|
||||
body?.classList.toggle('overflow-x-hidden', enabled);
|
||||
pre?.classList.toggle('whitespace-pre-wrap', enabled);
|
||||
pre?.classList.toggle('break-words', enabled);
|
||||
code?.classList.toggle('whitespace-pre-wrap', enabled);
|
||||
code?.classList.toggle('break-words', enabled);
|
||||
if (pre) {
|
||||
pre.style.whiteSpace = enabled ? 'pre-wrap' : 'pre';
|
||||
pre.style.overflowWrap = enabled ? 'anywhere' : 'normal';
|
||||
}
|
||||
if (code) {
|
||||
code.style.whiteSpace = enabled ? 'pre-wrap' : 'pre';
|
||||
code.style.overflowWrap = enabled ? 'anywhere' : 'normal';
|
||||
}
|
||||
if (wrapButton) {
|
||||
const title = enabled ? labels.disableCodeWrap : labels.enableCodeWrap;
|
||||
wrapButton.setAttribute('title', title);
|
||||
wrapButton.setAttribute('aria-label', title);
|
||||
wrapButton.classList.toggle('text-foreground', enabled);
|
||||
wrapButton.classList.toggle('opacity-100', enabled);
|
||||
wrapButton.classList.toggle('text-muted-foreground', !enabled);
|
||||
wrapButton.classList.toggle('opacity-65', !enabled);
|
||||
wrapButton.setAttribute('aria-pressed', enabled ? 'true' : 'false');
|
||||
}
|
||||
};
|
||||
|
||||
const createCodeLineNumbers = (pre: HTMLPreElement): HTMLDivElement => {
|
||||
const gutter = document.createElement('div');
|
||||
gutter.setAttribute('data-md-code-line-numbers', '');
|
||||
gutter.setAttribute('aria-hidden', 'true');
|
||||
gutter.className = 'min-w-8 shrink-0 select-none border-r border-border/50 pr-3 text-right font-mono text-[13px] text-muted-foreground/45';
|
||||
|
||||
const text = pre.textContent ?? '';
|
||||
const lineCount = Math.max(1, text.endsWith('\n') ? text.split('\n').length - 1 : text.split('\n').length);
|
||||
for (let index = 1; index <= lineCount; index += 1) {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'tabular-nums';
|
||||
line.textContent = String(index);
|
||||
gutter.appendChild(line);
|
||||
}
|
||||
|
||||
return gutter;
|
||||
};
|
||||
|
||||
const collectTextNodes = (root: HTMLElement): Text[] => {
|
||||
const nodes: Text[] = [];
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
nodes.push(node as Text);
|
||||
node = walker.nextNode();
|
||||
}
|
||||
return nodes;
|
||||
};
|
||||
|
||||
const findTextPosition = (nodes: Text[], targetOffset: number): { node: Text; offset: number } | null => {
|
||||
let offset = 0;
|
||||
for (const node of nodes) {
|
||||
const nextOffset = offset + node.data.length;
|
||||
if (targetOffset <= nextOffset) {
|
||||
return { node, offset: Math.max(0, targetOffset - offset) };
|
||||
}
|
||||
offset = nextOffset;
|
||||
}
|
||||
const last = nodes.at(-1);
|
||||
return last ? { node: last, offset: last.data.length } : null;
|
||||
};
|
||||
|
||||
export const syncMarkdownCodeLineNumbers = (root: HTMLElement): void => {
|
||||
const wrappers = root.querySelectorAll<HTMLElement>('[data-component="markdown-code"]');
|
||||
for (const wrapper of Array.from(wrappers)) {
|
||||
const code = wrapper.querySelector<HTMLElement>('pre code');
|
||||
const gutter = wrapper.querySelector<HTMLElement>('[data-md-code-line-numbers]');
|
||||
if (!code || !gutter) continue;
|
||||
|
||||
const numbers = Array.from(gutter.children) as HTMLElement[];
|
||||
const text = code.textContent ?? '';
|
||||
const textNodes = collectTextNodes(code);
|
||||
const codeStyle = window.getComputedStyle(code);
|
||||
const lineHeight = Number.parseFloat(codeStyle.lineHeight) || 20;
|
||||
gutter.style.fontFamily = codeStyle.fontFamily;
|
||||
gutter.style.fontSize = codeStyle.fontSize;
|
||||
gutter.style.lineHeight = `${lineHeight}px`;
|
||||
let lineStart = 0;
|
||||
|
||||
for (let index = 0; index < numbers.length; index += 1) {
|
||||
const nextBreak = text.indexOf('\n', lineStart);
|
||||
const lineEnd = nextBreak === -1 ? text.length : nextBreak;
|
||||
const lineEl = numbers[index];
|
||||
if (!lineEl) continue;
|
||||
|
||||
const start = findTextPosition(textNodes, lineStart);
|
||||
const end = findTextPosition(textNodes, lineEnd);
|
||||
if (!start || !end || lineStart === lineEnd) {
|
||||
lineEl.style.height = `${lineHeight}px`;
|
||||
lineEl.style.lineHeight = `${lineHeight}px`;
|
||||
} else {
|
||||
const range = document.createRange();
|
||||
range.setStart(start.node, start.offset);
|
||||
range.setEnd(end.node, end.offset);
|
||||
const rowTops: number[] = [];
|
||||
for (const rect of Array.from(range.getClientRects())) {
|
||||
if (rect.width === 0 && rect.height === 0) continue;
|
||||
if (!rowTops.some((top) => Math.abs(top - rect.top) < 2)) {
|
||||
rowTops.push(rect.top);
|
||||
}
|
||||
}
|
||||
const height = Math.max(lineHeight, Math.max(1, rowTops.length) * lineHeight);
|
||||
range.detach();
|
||||
lineEl.style.height = `${height}px`;
|
||||
lineEl.style.lineHeight = `${lineHeight}px`;
|
||||
}
|
||||
|
||||
lineStart = lineEnd + 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const scheduleMarkdownCodeLineNumberSync = (root: HTMLElement): void => {
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => syncMarkdownCodeLineNumbers(root));
|
||||
});
|
||||
};
|
||||
|
||||
export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: boolean, labels: DecorateLabels): void => {
|
||||
const wrappers = root.querySelectorAll<HTMLElement>('[data-component="markdown-code"]');
|
||||
for (const wrapper of Array.from(wrappers)) {
|
||||
const body = wrapper.querySelector<HTMLElement>('[data-md-code-body]');
|
||||
const pre = wrapper.querySelector<HTMLPreElement>('pre');
|
||||
if (body && pre && !body.querySelector('[data-md-code-line-numbers]')) {
|
||||
body.classList.add('flex', 'gap-3');
|
||||
body.insertBefore(createCodeLineNumbers(pre), pre);
|
||||
}
|
||||
applyCodeBlockWrapState(wrapper, enabled, labels);
|
||||
}
|
||||
scheduleMarkdownCodeLineNumberSync(root);
|
||||
};
|
||||
|
||||
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
|
||||
setHtml(button, ICONS.check);
|
||||
setIconHtml(button, ICONS.check);
|
||||
button.setAttribute('title', copiedTitle);
|
||||
button.setAttribute('aria-label', copiedTitle);
|
||||
window.setTimeout(() => {
|
||||
setHtml(button, ICONS[restore]);
|
||||
setIconHtml(button, ICONS[restore]);
|
||||
button.setAttribute('title', restoreTitle);
|
||||
button.setAttribute('aria-label', restoreTitle);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
@@ -78,7 +245,7 @@ const decorateInlineCode = (root: HTMLElement): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const decorateCodeBlocks = (root: HTMLElement, labels: DecorateLabels): void => {
|
||||
const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
const blocks = root.querySelectorAll<HTMLPreElement>('pre');
|
||||
for (const pre of Array.from(blocks)) {
|
||||
// Skip mermaid placeholders (handled separately).
|
||||
@@ -104,19 +271,33 @@ const decorateCodeBlocks = (root: HTMLElement, labels: DecorateLabels): void =>
|
||||
const langLabel = document.createElement('span');
|
||||
langLabel.className = 'font-mono text-[13px] text-muted-foreground';
|
||||
langLabel.textContent = language;
|
||||
const copyBtn = makeIconButton('copy', labels.copy, 'copy-code');
|
||||
const copyBtn = makeIconButton('copy', ctx.labels.copy, 'copy-code');
|
||||
const wrapBtn = makeIconButton('textWrap', ctx.codeBlockLineWrap ? ctx.labels.disableCodeWrap : ctx.labels.enableCodeWrap, 'toggle-code-wrap');
|
||||
header.appendChild(langLabel);
|
||||
header.appendChild(copyBtn);
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'flex items-center gap-1';
|
||||
actions.appendChild(wrapBtn);
|
||||
actions.appendChild(copyBtn);
|
||||
header.appendChild(actions);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'px-3 py-2.5 overflow-x-auto';
|
||||
body.setAttribute('data-md-code-body', '');
|
||||
body.className = ctx.deferCodeLineNumberSync ? 'px-3 py-2.5 overflow-x-auto' : 'flex gap-3 px-3 py-2.5 overflow-x-auto';
|
||||
|
||||
parent.replaceChild(wrapper, pre);
|
||||
pre.style.margin = '0';
|
||||
pre.style.background = 'transparent';
|
||||
pre.classList.add('min-w-0', 'w-full', 'flex-1');
|
||||
if (!ctx.deferCodeLineNumberSync) {
|
||||
body.appendChild(createCodeLineNumbers(pre));
|
||||
}
|
||||
body.appendChild(pre);
|
||||
wrapper.appendChild(header);
|
||||
wrapper.appendChild(body);
|
||||
applyCodeBlockWrapState(wrapper, ctx.codeBlockLineWrap, ctx.labels);
|
||||
if (!ctx.deferCodeLineNumberSync) {
|
||||
scheduleMarkdownCodeLineNumberSync(wrapper);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -140,13 +321,13 @@ const extractTableData = (table: HTMLTableElement): { headers: string[]; rows: s
|
||||
const escapeCsv = (value: string): string =>
|
||||
/[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
||||
|
||||
export const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string =>
|
||||
const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string =>
|
||||
[headers, ...rows].map((row) => row.map(escapeCsv).join(',')).join('\n');
|
||||
|
||||
export const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string =>
|
||||
const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string =>
|
||||
[headers, ...rows].map((row) => row.join('\t')).join('\n');
|
||||
|
||||
export const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
||||
const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
||||
const head = `| ${headers.join(' | ')} |`;
|
||||
const sep = `| ${headers.map(() => '---').join(' | ')} |`;
|
||||
const body = rows.map((row) => `| ${row.join(' | ')} |`).join('\n');
|
||||
@@ -246,33 +427,52 @@ const decorateMermaid = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
|
||||
const block = document.createElement('div');
|
||||
block.setAttribute('data-markdown', 'mermaid-block');
|
||||
block.setAttribute('data-md-source', source);
|
||||
block.className = 'group relative';
|
||||
|
||||
const scroll = document.createElement('div');
|
||||
scroll.setAttribute('data-markdown', 'mermaid-scroll');
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'absolute top-1 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity';
|
||||
toolbar.setAttribute('data-markdown', 'mermaid-toolbar');
|
||||
toolbar.className = 'absolute top-1 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity';
|
||||
|
||||
if (rendered.svg) {
|
||||
block.setAttribute('data-mermaid-render', 'svg');
|
||||
const viewport = document.createElement('div');
|
||||
viewport.setAttribute('data-markdown', 'mermaid-viewport');
|
||||
const svgHost = document.createElement('div');
|
||||
svgHost.setAttribute('data-markdown', 'mermaid');
|
||||
setHtml(svgHost, rendered.svg);
|
||||
scroll.appendChild(svgHost);
|
||||
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
|
||||
copy.setAttribute('data-md-source', source);
|
||||
const download = makeIconButton('download', ctx.labels.downloadDiagram, 'mermaid-download');
|
||||
download.setAttribute('data-md-svg', '1');
|
||||
toolbar.appendChild(copy);
|
||||
toolbar.appendChild(download);
|
||||
svgHost.setAttribute('data-md-original-svg', rendered.svg);
|
||||
svgHost.innerHTML = rendered.svg;
|
||||
viewport.appendChild(svgHost);
|
||||
scroll.appendChild(viewport);
|
||||
if (ctx.mermaidControls.showPanZoomControls) {
|
||||
toolbar.appendChild(makeIconButton('zoomIn', ctx.labels.zoomInDiagram, 'mermaid-zoom-in'));
|
||||
toolbar.appendChild(makeIconButton('zoomOut', ctx.labels.zoomOutDiagram, 'mermaid-zoom-out'));
|
||||
toolbar.appendChild(makeIconButton('fit', ctx.labels.resetDiagramView, 'mermaid-fit'));
|
||||
}
|
||||
if (ctx.mermaidControls.copy) {
|
||||
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
|
||||
copy.setAttribute('data-md-source', source);
|
||||
toolbar.appendChild(copy);
|
||||
}
|
||||
if (ctx.mermaidControls.download) {
|
||||
const download = makeIconButton('download', ctx.labels.downloadDiagram, 'mermaid-download');
|
||||
download.setAttribute('data-md-svg', '1');
|
||||
toolbar.appendChild(download);
|
||||
}
|
||||
} else {
|
||||
block.setAttribute('data-mermaid-render', 'ascii');
|
||||
const asciiPre = document.createElement('pre');
|
||||
asciiPre.setAttribute('data-markdown', 'mermaid-ascii');
|
||||
asciiPre.textContent = rendered.ascii || source;
|
||||
scroll.appendChild(asciiPre);
|
||||
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
|
||||
copy.setAttribute('data-md-source', rendered.ascii || source);
|
||||
toolbar.appendChild(copy);
|
||||
if (ctx.mermaidControls.copy) {
|
||||
const copy = makeIconButton('copy', ctx.labels.copyDiagram, 'mermaid-copy');
|
||||
copy.setAttribute('data-md-source', rendered.ascii || source);
|
||||
toolbar.appendChild(copy);
|
||||
}
|
||||
}
|
||||
|
||||
block.appendChild(scroll);
|
||||
@@ -322,7 +522,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);
|
||||
setHtml(preview, ICONS.download);
|
||||
setIconHtml(preview, ICONS.download);
|
||||
anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
|
||||
}
|
||||
}
|
||||
@@ -332,7 +532,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
export const decorateMarkdown = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
decorateInlineCode(root);
|
||||
decorateMermaid(root, ctx);
|
||||
decorateCodeBlocks(root, ctx.labels);
|
||||
decorateCodeBlocks(root, ctx);
|
||||
decorateTables(root, ctx.labels);
|
||||
decorateLinks(root, ctx);
|
||||
};
|
||||
@@ -387,6 +587,12 @@ export const attachMarkdownInteractions = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'toggle-code-wrap') {
|
||||
event.preventDefault();
|
||||
ctx.onToggleCodeBlockLineWrap?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle table menus
|
||||
if (action === 'table-copy-toggle' || action === 'table-download-toggle') {
|
||||
event.preventDefault();
|
||||
@@ -430,10 +636,25 @@ export const attachMarkdownInteractions = (
|
||||
return;
|
||||
}
|
||||
|
||||
// Mermaid local pan/zoom controls
|
||||
if (action === 'mermaid-zoom-in' || action === 'mermaid-zoom-out' || action === 'mermaid-fit') {
|
||||
event.preventDefault();
|
||||
const block = actionEl.closest('[data-markdown="mermaid-block"]');
|
||||
const controller = getMermaidViewerController(block);
|
||||
if (action === 'mermaid-zoom-in') {
|
||||
controller?.zoomIn();
|
||||
} else if (action === 'mermaid-zoom-out') {
|
||||
controller?.zoomOut();
|
||||
} else {
|
||||
controller?.fit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mermaid download svg
|
||||
if (action === 'mermaid-download') {
|
||||
const svgHost = actionEl.closest('[data-markdown="mermaid-block"]')?.querySelector('[data-markdown="mermaid"]');
|
||||
const svg = svgHost?.innerHTML ?? '';
|
||||
const svg = svgHost?.getAttribute('data-md-original-svg') ?? svgHost?.innerHTML ?? '';
|
||||
if (svg) downloadBlob('diagram.svg', svg, 'image/svg+xml;charset=utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ const getWorker = (): Worker | undefined => {
|
||||
if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined;
|
||||
try {
|
||||
worker = new Worker(MarkdownShikiWorkerUrl, { type: 'module' });
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('Failed to create Shiki worker:', err);
|
||||
return undefined;
|
||||
}
|
||||
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
|
||||
@@ -13,7 +13,7 @@ const escapeAttr = (value: string): string =>
|
||||
// Streaming block segmentation (port of OpenCode's markdown-stream)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MarkdownBlock = {
|
||||
type MarkdownBlock = {
|
||||
raw: string;
|
||||
src: string;
|
||||
mode: 'full' | 'live';
|
||||
@@ -54,7 +54,7 @@ const heal = (text: string): string => {
|
||||
* unclosed trailing code fence into its own `live` block so a partial fence
|
||||
* does not corrupt the parse of stable content above it.
|
||||
*/
|
||||
export const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
|
||||
const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
|
||||
if (!live) return [{ raw: text, src: text, mode: 'full', highlight: true }];
|
||||
// Reference-style links/footnotes span multiple tokens (definition elsewhere);
|
||||
// keep them as a single block so per-block parsing doesn't break the refs.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdow
|
||||
// `--md-syntax-*` CSS variables) lives in the dependency-free
|
||||
// `markdownShikiThemeDefinition` module so it can also be imported inside the
|
||||
// Shiki Web Worker. See that module for the rationale.
|
||||
export { MARKDOWN_SHIKI_THEME };
|
||||
|
||||
|
||||
let registered = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
fitMermaidViewBox,
|
||||
formatMermaidViewBox,
|
||||
getMermaidSvgContentBox,
|
||||
getMermaidViewerSignature,
|
||||
hasMermaidPointerDragMoved,
|
||||
MERMAID_BLOCK_SELECTOR,
|
||||
panMermaidViewBox,
|
||||
shouldRefreshMermaidViewers,
|
||||
zoomMermaidViewBoxAtPoint,
|
||||
} from './mermaidViewer';
|
||||
|
||||
describe('mermaidViewer', () => {
|
||||
test('extracts content bounds from the root SVG viewBox', () => {
|
||||
expect(getMermaidSvgContentBox({ viewBox: '10 20 400 200', width: '999', height: '999' })).toEqual({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 400,
|
||||
height: 200,
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to numeric SVG width and height when viewBox is missing', () => {
|
||||
expect(getMermaidSvgContentBox({ width: '640', height: '320' })).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 640,
|
||||
height: 320,
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts bare and px SVG width and height values without parsing unresolved units', () => {
|
||||
expect(getMermaidSvgContentBox({ width: '1e3', height: '2.5e2px' })).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1000,
|
||||
height: 250,
|
||||
});
|
||||
|
||||
expect(getMermaidSvgContentBox({ width: '100%', height: '200' })).toBeNull();
|
||||
expect(getMermaidSvgContentBox({ width: '100em', height: '200' })).toBeNull();
|
||||
});
|
||||
|
||||
test('fits content into a viewport while preserving aspect ratio', () => {
|
||||
expect(fitMermaidViewBox({ x: 0, y: 0, width: 400, height: 200 }, { width: 300, height: 300 })).toEqual({
|
||||
x: 0,
|
||||
y: -100,
|
||||
width: 400,
|
||||
height: 400,
|
||||
});
|
||||
});
|
||||
|
||||
test('zooms around a pointer so the SVG point under the pointer stays stable', () => {
|
||||
const current = { x: 0, y: 0, width: 400, height: 400 };
|
||||
const next = zoomMermaidViewBoxAtPoint({
|
||||
currentBox: current,
|
||||
contentBox: { x: 0, y: 0, width: 400, height: 200 },
|
||||
viewport: { width: 300, height: 300 },
|
||||
pointer: { x: 75, y: 150 },
|
||||
zoomFactor: 2,
|
||||
minScale: 0.5,
|
||||
maxScale: 4,
|
||||
});
|
||||
|
||||
const before = {
|
||||
x: current.x + (75 / 300) * current.width,
|
||||
y: current.y + (150 / 300) * current.height,
|
||||
};
|
||||
const after = {
|
||||
x: next.x + (75 / 300) * next.width,
|
||||
y: next.y + (150 / 300) * next.height,
|
||||
};
|
||||
|
||||
expect(Math.abs(after.x - before.x) < 1e-6).toBe(true);
|
||||
expect(Math.abs(after.y - before.y) < 1e-6).toBe(true);
|
||||
expect(next).toEqual({ x: 50, y: 100, width: 200, height: 200 });
|
||||
});
|
||||
|
||||
test('clamps zoom to the configured viewBox scale bounds', () => {
|
||||
const next = zoomMermaidViewBoxAtPoint({
|
||||
currentBox: { x: 0, y: 0, width: 400, height: 400 },
|
||||
contentBox: { x: 0, y: 0, width: 400, height: 200 },
|
||||
viewport: { width: 300, height: 300 },
|
||||
pointer: { x: 150, y: 150 },
|
||||
zoomFactor: 100,
|
||||
minScale: 0.5,
|
||||
maxScale: 4,
|
||||
});
|
||||
|
||||
expect(next.width).toBe(100);
|
||||
expect(next.height).toBe(100);
|
||||
expect(next.x).toBe(150);
|
||||
expect(next.y).toBe(150);
|
||||
});
|
||||
|
||||
test('clamps zoom scale relative to the fitted viewport box', () => {
|
||||
const next = zoomMermaidViewBoxAtPoint({
|
||||
currentBox: { x: -100, y: 0, width: 400, height: 400 },
|
||||
contentBox: { x: 0, y: 0, width: 200, height: 400 },
|
||||
viewport: { width: 300, height: 300 },
|
||||
pointer: { x: 150, y: 150 },
|
||||
zoomFactor: 100,
|
||||
minScale: 0.5,
|
||||
maxScale: 4,
|
||||
});
|
||||
|
||||
expect(next).toEqual({ x: 50, y: 150, width: 100, height: 100 });
|
||||
});
|
||||
|
||||
test('returns the current viewBox for invalid zoom scale bounds', () => {
|
||||
const current = { x: 0, y: 0, width: 400, height: 400 };
|
||||
|
||||
expect(zoomMermaidViewBoxAtPoint({
|
||||
currentBox: current,
|
||||
contentBox: { x: 0, y: 0, width: 400, height: 200 },
|
||||
viewport: { width: 300, height: 300 },
|
||||
pointer: { x: 150, y: 150 },
|
||||
zoomFactor: 2,
|
||||
minScale: 0,
|
||||
maxScale: 4,
|
||||
})).toBe(current);
|
||||
|
||||
expect(zoomMermaidViewBoxAtPoint({
|
||||
currentBox: current,
|
||||
contentBox: { x: 0, y: 0, width: 400, height: 200 },
|
||||
viewport: { width: 300, height: 300 },
|
||||
pointer: { x: 150, y: 150 },
|
||||
zoomFactor: 2,
|
||||
minScale: 0.5,
|
||||
maxScale: Number.POSITIVE_INFINITY,
|
||||
})).toBe(current);
|
||||
});
|
||||
|
||||
test('formats viewBox numbers without noisy floating point tails', () => {
|
||||
expect(formatMermaidViewBox({
|
||||
x: 1 / 3,
|
||||
y: -2.5,
|
||||
width: 100.0000001,
|
||||
height: 40,
|
||||
})).toBe('0.333333 -2.5 100 40');
|
||||
});
|
||||
|
||||
test('pans viewBox by viewport pixel deltas in SVG coordinates', () => {
|
||||
expect(panMermaidViewBox({
|
||||
currentBox: { x: 10, y: 20, width: 400, height: 200 },
|
||||
viewport: { width: 200, height: 100 },
|
||||
delta: { x: 25, y: -10 },
|
||||
})).toEqual({
|
||||
x: -40,
|
||||
y: 40,
|
||||
width: 400,
|
||||
height: 200,
|
||||
});
|
||||
});
|
||||
|
||||
test('distinguishes real pointer drag from click jitter', () => {
|
||||
expect(hasMermaidPointerDragMoved({ x: 10, y: 10 }, { x: 12, y: 11 })).toBe(false);
|
||||
expect(hasMermaidPointerDragMoved({ x: 10, y: 10 }, { x: 14, y: 10 })).toBe(true);
|
||||
});
|
||||
|
||||
test('viewer signature changes when SVG identity changes within an existing block', () => {
|
||||
const first = getMermaidViewerSignature({
|
||||
renderMode: 'svg',
|
||||
svgMarkup: '<svg viewBox="0 0 100 50"></svg>',
|
||||
viewBox: '0 0 100 50',
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
const second = getMermaidViewerSignature({
|
||||
renderMode: 'svg',
|
||||
svgMarkup: '<svg viewBox="0 0 240 120"></svg>',
|
||||
viewBox: '0 0 240 120',
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
test('viewer signature distinguishes render mode flips and missing SVGs', () => {
|
||||
expect(getMermaidViewerSignature({ renderMode: 'ascii' })).toBe('ascii:no-svg');
|
||||
expect(getMermaidViewerSignature({ renderMode: 'svg' })).toBe('svg:no-svg');
|
||||
});
|
||||
|
||||
test('only requests renderer refresh work for existing mermaid DOM blocks', () => {
|
||||
const withoutMermaidBlock = { querySelector: () => null };
|
||||
const withMermaidBlock = { querySelector: () => ({}) as Element };
|
||||
|
||||
expect(shouldRefreshMermaidViewers(withoutMermaidBlock)).toBe(false);
|
||||
expect(shouldRefreshMermaidViewers(withMermaidBlock)).toBe(true);
|
||||
});
|
||||
|
||||
test('exports the shared Mermaid block selector', () => {
|
||||
expect(MERMAID_BLOCK_SELECTOR).toBe('[data-markdown="mermaid-block"]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
type MermaidViewBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type MermaidViewport = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type MermaidPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type MermaidViewerController = {
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
fit: () => void;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type MermaidSvgBoundsSource = {
|
||||
viewBox?: string | null;
|
||||
width?: string | number | null;
|
||||
height?: string | number | null;
|
||||
};
|
||||
|
||||
type MermaidViewerSignatureSource = MermaidSvgBoundsSource & {
|
||||
renderMode?: string | null;
|
||||
svgMarkup?: string | null;
|
||||
};
|
||||
|
||||
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 (!match) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(match[1]);
|
||||
return isPositiveFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
|
||||
|
||||
const VIEW_BOX_PRECISION = 6;
|
||||
const ZOOM_STEP = 1.25;
|
||||
const WHEEL_ZOOM_BASE = 1.0015;
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 12;
|
||||
const DRAG_CLICK_SUPPRESSION_THRESHOLD_PX = 3;
|
||||
const DRAG_CLICK_SUPPRESSION_CLEAR_MS = 400;
|
||||
export const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
|
||||
|
||||
export const shouldRefreshMermaidViewers = (container: Pick<HTMLElement, 'querySelector'>): boolean => (
|
||||
container.querySelector(MERMAID_BLOCK_SELECTOR) !== null
|
||||
);
|
||||
|
||||
export const formatMermaidViewBox = (box: MermaidViewBox): string => (
|
||||
[box.x, box.y, box.width, box.height]
|
||||
.map((value) => {
|
||||
const rounded = Number(value.toFixed(VIEW_BOX_PRECISION));
|
||||
return Object.is(rounded, -0) ? '0' : String(rounded);
|
||||
})
|
||||
.join(' ')
|
||||
);
|
||||
|
||||
export const getMermaidSvgContentBox = (source: MermaidSvgBoundsSource): MermaidViewBox | null => {
|
||||
const viewBoxParts = source.viewBox
|
||||
?.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => Number.parseFloat(part));
|
||||
if (viewBoxParts?.length === 4) {
|
||||
const [x, y, width, height] = viewBoxParts;
|
||||
if (
|
||||
Number.isFinite(x)
|
||||
&& Number.isFinite(y)
|
||||
&& isPositiveFinite(width)
|
||||
&& isPositiveFinite(height)
|
||||
) {
|
||||
return { x, y, width, height };
|
||||
}
|
||||
}
|
||||
|
||||
const width = parseSvgNumber(source.width);
|
||||
const height = parseSvgNumber(source.height);
|
||||
if (width === null || height === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x: 0, y: 0, width, height };
|
||||
};
|
||||
|
||||
const hashMermaidSignaturePart = (value: string): string => {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
};
|
||||
|
||||
export const getMermaidViewerSignature = (source: MermaidViewerSignatureSource): string => {
|
||||
const renderMode = source.renderMode || 'unknown';
|
||||
const svgMarkup = source.svgMarkup ?? '';
|
||||
if (!svgMarkup) {
|
||||
const bounds = [source.viewBox ?? '', source.width ?? '', source.height ?? ''];
|
||||
return bounds.some((part) => part !== '') ? `${renderMode}:bounds:${bounds.join(':')}` : `${renderMode}:no-svg`;
|
||||
}
|
||||
|
||||
return [
|
||||
renderMode,
|
||||
hashMermaidSignaturePart(svgMarkup),
|
||||
].join(':');
|
||||
};
|
||||
|
||||
export const fitMermaidViewBox = (contentBox: MermaidViewBox, viewport: MermaidViewport): MermaidViewBox => {
|
||||
if (!isPositiveFinite(viewport.width) || !isPositiveFinite(viewport.height)) {
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
const contentAspect = contentBox.width / contentBox.height;
|
||||
const viewportAspect = viewport.width / viewport.height;
|
||||
if (contentAspect > viewportAspect) {
|
||||
const height = contentBox.width / viewportAspect;
|
||||
return {
|
||||
x: contentBox.x,
|
||||
y: contentBox.y - (height - contentBox.height) / 2,
|
||||
width: contentBox.width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
const width = contentBox.height * viewportAspect;
|
||||
return {
|
||||
x: contentBox.x - (width - contentBox.width) / 2,
|
||||
y: contentBox.y,
|
||||
width,
|
||||
height: contentBox.height,
|
||||
};
|
||||
};
|
||||
|
||||
export const panMermaidViewBox = ({
|
||||
currentBox,
|
||||
viewport,
|
||||
delta,
|
||||
}: {
|
||||
currentBox: MermaidViewBox;
|
||||
viewport: MermaidViewport;
|
||||
delta: MermaidPoint;
|
||||
}): MermaidViewBox => {
|
||||
if (
|
||||
!isPositiveFinite(viewport.width)
|
||||
|| !isPositiveFinite(viewport.height)
|
||||
|| !isPositiveFinite(currentBox.width)
|
||||
|| !isPositiveFinite(currentBox.height)
|
||||
) {
|
||||
return currentBox;
|
||||
}
|
||||
|
||||
return {
|
||||
x: currentBox.x - (delta.x / viewport.width) * currentBox.width,
|
||||
y: currentBox.y - (delta.y / viewport.height) * currentBox.height,
|
||||
width: currentBox.width,
|
||||
height: currentBox.height,
|
||||
};
|
||||
};
|
||||
|
||||
export const hasMermaidPointerDragMoved = (start: MermaidPoint, current: MermaidPoint): boolean => {
|
||||
const deltaX = current.x - start.x;
|
||||
const deltaY = current.y - start.y;
|
||||
return (deltaX * deltaX) + (deltaY * deltaY) > (DRAG_CLICK_SUPPRESSION_THRESHOLD_PX * DRAG_CLICK_SUPPRESSION_THRESHOLD_PX);
|
||||
};
|
||||
|
||||
export const zoomMermaidViewBoxAtPoint = ({
|
||||
currentBox,
|
||||
contentBox,
|
||||
viewport,
|
||||
pointer,
|
||||
zoomFactor,
|
||||
minScale,
|
||||
maxScale,
|
||||
}: {
|
||||
currentBox: MermaidViewBox;
|
||||
contentBox: MermaidViewBox;
|
||||
viewport: MermaidViewport;
|
||||
pointer: MermaidPoint;
|
||||
zoomFactor: number;
|
||||
minScale: number;
|
||||
maxScale: number;
|
||||
}): MermaidViewBox => {
|
||||
if (
|
||||
!isPositiveFinite(viewport.width)
|
||||
|| !isPositiveFinite(viewport.height)
|
||||
|| !isPositiveFinite(zoomFactor)
|
||||
|| !isPositiveFinite(currentBox.width)
|
||||
|| !isPositiveFinite(currentBox.height)
|
||||
|| !isPositiveFinite(contentBox.width)
|
||||
|| !isPositiveFinite(contentBox.height)
|
||||
|| !isPositiveFinite(minScale)
|
||||
|| !isPositiveFinite(maxScale)
|
||||
) {
|
||||
return currentBox;
|
||||
}
|
||||
|
||||
const min = Math.min(minScale, maxScale);
|
||||
const max = Math.max(minScale, maxScale);
|
||||
const fittedBox = fitMermaidViewBox(contentBox, viewport);
|
||||
const currentScale = fittedBox.width / currentBox.width;
|
||||
const nextScale = clamp(currentScale * zoomFactor, min, max);
|
||||
const nextWidth = fittedBox.width / nextScale;
|
||||
const nextHeight = nextWidth / (currentBox.width / currentBox.height);
|
||||
const pointerRatioX = clamp(pointer.x / viewport.width, 0, 1);
|
||||
const pointerRatioY = clamp(pointer.y / viewport.height, 0, 1);
|
||||
const svgPointX = currentBox.x + pointerRatioX * currentBox.width;
|
||||
const svgPointY = currentBox.y + pointerRatioY * currentBox.height;
|
||||
|
||||
return {
|
||||
x: svgPointX - pointerRatioX * nextWidth,
|
||||
y: svgPointY - pointerRatioY * nextHeight,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
};
|
||||
};
|
||||
|
||||
const controllerByBlock = new WeakMap<HTMLElement, MermaidViewerController>();
|
||||
|
||||
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => (
|
||||
block instanceof HTMLElement ? controllerByBlock.get(block) ?? null : null
|
||||
);
|
||||
|
||||
const getSvgViewport = (block: HTMLElement): HTMLElement | null => (
|
||||
block.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]')
|
||||
?? block.querySelector<HTMLElement>('[data-markdown="mermaid"]')
|
||||
);
|
||||
|
||||
const getBlockViewerSignature = (block: HTMLElement): string => {
|
||||
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
|
||||
const svgHost = block.querySelector<HTMLElement>('[data-markdown="mermaid"]');
|
||||
return getMermaidViewerSignature({
|
||||
renderMode: block.getAttribute('data-mermaid-render'),
|
||||
svgMarkup: svgHost?.getAttribute('data-md-original-svg') ?? svg?.outerHTML ?? null,
|
||||
viewBox: svg?.getAttribute('viewBox'),
|
||||
width: svg?.getAttribute('width'),
|
||||
height: svg?.getAttribute('height'),
|
||||
});
|
||||
};
|
||||
|
||||
const getViewportSize = (viewport: HTMLElement): MermaidViewport => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
return { width: rect.width, height: rect.height };
|
||||
};
|
||||
|
||||
const getPointerInViewport = (event: Pick<PointerEvent | WheelEvent, 'clientX' | 'clientY'>, viewport: HTMLElement): MermaidPoint => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
return {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
};
|
||||
};
|
||||
|
||||
const isPanExcludedTarget = (target: EventTarget | null): boolean => (
|
||||
target instanceof Element && Boolean(target.closest('button, a, [role="button"]'))
|
||||
);
|
||||
|
||||
const createMermaidViewerController = (block: HTMLElement): MermaidViewerController | null => {
|
||||
const viewport = getSvgViewport(block);
|
||||
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
|
||||
if (!viewport || !svg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentBox = getMermaidSvgContentBox({
|
||||
viewBox: svg.getAttribute('viewBox'),
|
||||
width: svg.getAttribute('width'),
|
||||
height: svg.getAttribute('height'),
|
||||
});
|
||||
if (!contentBox) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentBox = contentBox;
|
||||
let activePointerId: number | null = null;
|
||||
let dragStartPointer: MermaidPoint | null = null;
|
||||
let lastPointer: MermaidPoint | null = null;
|
||||
let clearClickSuppressionTimer: number | null = null;
|
||||
|
||||
const applyViewBox = (box: MermaidViewBox): void => {
|
||||
currentBox = box;
|
||||
svg.setAttribute('viewBox', formatMermaidViewBox(box));
|
||||
svg.removeAttribute('width');
|
||||
svg.removeAttribute('height');
|
||||
};
|
||||
|
||||
const fit = (): void => {
|
||||
applyViewBox(fitMermaidViewBox(contentBox, getViewportSize(viewport)));
|
||||
};
|
||||
|
||||
const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => {
|
||||
applyViewBox(zoomMermaidViewBoxAtPoint({
|
||||
currentBox,
|
||||
contentBox,
|
||||
viewport: getViewportSize(viewport),
|
||||
pointer,
|
||||
zoomFactor,
|
||||
minScale: MIN_SCALE,
|
||||
maxScale: MAX_SCALE,
|
||||
}));
|
||||
};
|
||||
|
||||
const zoomIn = (): void => {
|
||||
const size = getViewportSize(viewport);
|
||||
zoomAt({ x: size.width / 2, y: size.height / 2 }, ZOOM_STEP);
|
||||
};
|
||||
|
||||
const zoomOut = (): void => {
|
||||
const size = getViewportSize(viewport);
|
||||
zoomAt({ x: size.width / 2, y: size.height / 2 }, 1 / ZOOM_STEP);
|
||||
};
|
||||
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
zoomAt(getPointerInViewport(event, viewport), Math.pow(WHEEL_ZOOM_BASE, -event.deltaY));
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
if (event.button !== 0 || isPanExcludedTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
activePointerId = event.pointerId;
|
||||
dragStartPointer = { x: event.clientX, y: event.clientY };
|
||||
lastPointer = dragStartPointer;
|
||||
if (clearClickSuppressionTimer !== null) {
|
||||
window.clearTimeout(clearClickSuppressionTimer);
|
||||
clearClickSuppressionTimer = null;
|
||||
}
|
||||
block.removeAttribute('data-mermaid-suppress-click');
|
||||
viewport.setPointerCapture?.(event.pointerId);
|
||||
block.setAttribute('data-mermaid-panning', 'true');
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent): void => {
|
||||
if (activePointerId !== event.pointerId || !lastPointer) {
|
||||
return;
|
||||
}
|
||||
const nextPointer = { x: event.clientX, y: event.clientY };
|
||||
applyViewBox(panMermaidViewBox({
|
||||
currentBox,
|
||||
viewport: getViewportSize(viewport),
|
||||
delta: {
|
||||
x: nextPointer.x - lastPointer.x,
|
||||
y: nextPointer.y - lastPointer.y,
|
||||
},
|
||||
}));
|
||||
lastPointer = nextPointer;
|
||||
if (dragStartPointer && hasMermaidPointerDragMoved(dragStartPointer, nextPointer)) {
|
||||
block.setAttribute('data-mermaid-suppress-click', 'true');
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const stopPan = (event: PointerEvent): void => {
|
||||
if (activePointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
viewport.releasePointerCapture?.(event.pointerId);
|
||||
activePointerId = null;
|
||||
dragStartPointer = null;
|
||||
lastPointer = null;
|
||||
block.removeAttribute('data-mermaid-panning');
|
||||
if (block.hasAttribute('data-mermaid-suppress-click')) {
|
||||
clearClickSuppressionTimer = window.setTimeout(() => {
|
||||
block.removeAttribute('data-mermaid-suppress-click');
|
||||
clearClickSuppressionTimer = null;
|
||||
}, DRAG_CLICK_SUPPRESSION_CLEAR_MS);
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
fit,
|
||||
cleanup: () => {
|
||||
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);
|
||||
}
|
||||
block.removeAttribute('data-mermaid-panning');
|
||||
block.removeAttribute('data-mermaid-suppress-click');
|
||||
controllerByBlock.delete(block);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 cleanup = (): void => {
|
||||
for (const controller of controllers.values()) {
|
||||
controller.cleanup();
|
||||
}
|
||||
controllers.clear();
|
||||
signatures.clear();
|
||||
};
|
||||
|
||||
refresh();
|
||||
return { refresh, cleanup };
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
let markdownRendererModulePromise: Promise<typeof import('./MarkdownRendererImpl')> | null = null;
|
||||
|
||||
export const loadMarkdownRendererModule = () => {
|
||||
markdownRendererModulePromise ??= import('./MarkdownRendererImpl').catch((error) => {
|
||||
markdownRendererModulePromise = null;
|
||||
throw error;
|
||||
});
|
||||
return markdownRendererModulePromise;
|
||||
};
|
||||
|
||||
export const preloadMarkdownRenderer = () => {
|
||||
void loadMarkdownRendererModule().catch(() => undefined);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import UserTextPart from './parts/UserTextPart';
|
||||
import ToolPart from './parts/ToolPart';
|
||||
import AssistantTextPart from './parts/AssistantTextPart';
|
||||
import ReasoningPart, { MergedReasoningPart } from './parts/ReasoningPart';
|
||||
import ReasoningPart from './parts/ReasoningPart';
|
||||
import { MessageFilesDisplay } from '../FileAttachment';
|
||||
import { TurnChangedFilesDropdown } from '../TurnChangedFilesDropdown';
|
||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||
@@ -65,37 +65,88 @@ const getDisplayFileName = (file: string): string => {
|
||||
return segments.at(-1) ?? file;
|
||||
};
|
||||
|
||||
const TurnChangedFilePills = React.memo(({ files }: { files?: TurnChangedFile[] }) => {
|
||||
if (!files || files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const TurnChangedFileChipContent = React.memo(({ file, interactive = false }: { file: TurnChangedFile; interactive?: boolean }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex max-w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/30 px-2 py-1 text-xs leading-[1.35] text-muted-foreground',
|
||||
interactive && 'transition-colors hover:border-border/60 hover:bg-interactive-hover'
|
||||
)}
|
||||
>
|
||||
<FileTypeIcon filePath={file.file} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="max-w-52 truncate text-foreground/80" title={file.file}>{getDisplayFileName(file.file)}</span>
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||
<span style={{ color: 'var(--status-success)' }}>+{file.additions}</span>
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{file.deletions}</span>
|
||||
</span>
|
||||
</span>
|
||||
));
|
||||
|
||||
const TurnChangedFilePillButton = React.memo(({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: TurnChangedFile;
|
||||
onOpen: (file: string) => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 max-w-full cursor-pointer items-center rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
aria-label={t('chat.changedFiles.actions.openFileTitle', { path: file.file })}
|
||||
title={file.file}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpen(file.file);
|
||||
}}
|
||||
>
|
||||
<TurnChangedFileChipContent file={file} interactive />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
const StaticTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => (
|
||||
<>
|
||||
{files.map((file) => (
|
||||
<span key={file.file} className="inline-flex h-8 max-w-full items-center" title={file.file}>
|
||||
<TurnChangedFileChipContent file={file} />
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
));
|
||||
|
||||
const InteractiveTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => {
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
|
||||
const openContextDiff = useUIStore((state) => state.openContextDiff);
|
||||
|
||||
const openLastTurnDiff = React.useCallback((file: string) => {
|
||||
if (!isMobile && effectiveDirectory) {
|
||||
openContextDiff(effectiveDirectory, file, false, 'turn');
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToDiff(file, false, 'turn');
|
||||
}, [effectiveDirectory, isMobile, navigateToDiff, openContextDiff]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{files.map((file) => {
|
||||
return (
|
||||
<Tooltip key={file.file}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex h-8 max-w-full items-center">
|
||||
<span className="inline-flex max-w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/30 px-2 py-1 text-xs leading-[1.35] text-muted-foreground">
|
||||
<FileTypeIcon filePath={file.file} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="max-w-52 truncate text-foreground/80" title={file.file}>{getDisplayFileName(file.file)}</span>
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||
<span style={{ color: 'var(--status-success)' }}>+{file.additions}</span>
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{file.deletions}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{file.file}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{files.map((file) => (
|
||||
<TurnChangedFilePillButton key={file.file} file={file} onOpen={openLastTurnDiff} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const TurnChangedFilePills = React.memo(({ files, isInteractive }: { files?: TurnChangedFile[]; isInteractive: boolean }) => {
|
||||
if (!files || files.length === 0) return null;
|
||||
|
||||
return isInteractive ? <InteractiveTurnChangedFilePills files={files} /> : <StaticTurnChangedFilePills files={files} />;
|
||||
});
|
||||
|
||||
type SubtaskPartLike = Part & {
|
||||
type: 'subtask';
|
||||
description?: unknown;
|
||||
@@ -1159,7 +1210,6 @@ const AssistantMessageBody = React.memo(({
|
||||
const [isForkSubmitting, setIsForkSubmitting] = React.useState(false);
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
const collapsibleThinkingBlocks = useUIStore((state) => state.collapsibleThinkingBlocks);
|
||||
const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks);
|
||||
const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions);
|
||||
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
||||
const vscodeApi = useRuntimeAPIs().vscode;
|
||||
@@ -1661,15 +1711,6 @@ const AssistantMessageBody = React.memo(({
|
||||
// Group consecutive static tools (read, grep, glob, etc.) into compact rows.
|
||||
// Expandable tools (bash, edit, task) get individual rows.
|
||||
// Text renders inline at its natural position.
|
||||
// Reasoning: all reasoning parts for this message are merged into ONE block
|
||||
// at the position of the first reasoning part (VSCode Copilot pattern).
|
||||
const flatReasoningParts = visibleParts.filter((p) => {
|
||||
if (p.type !== 'reasoning') return false;
|
||||
const a = activityByPart.get(p);
|
||||
return a?.kind !== 'reasoning';
|
||||
});
|
||||
let reasoningMergeRendered = false;
|
||||
|
||||
let i = 0;
|
||||
while (i < visibleParts.length) {
|
||||
const part = visibleParts[i];
|
||||
@@ -1731,20 +1772,6 @@ const AssistantMessageBody = React.memo(({
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
);
|
||||
} else if (groupReasoningBlocks) {
|
||||
// Merged mode (VSCode pattern): one block for all reasoning parts.
|
||||
if (!reasoningMergeRendered) {
|
||||
reasoningMergeRendered = true;
|
||||
rendered.push(
|
||||
<MergedReasoningPart
|
||||
key={`reasoning-merged-${messageId}`}
|
||||
parts={flatReasoningParts}
|
||||
messageId={messageId}
|
||||
streamPhase={effectiveStreamPhase}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Per-part mode: each reasoning block at its natural position.
|
||||
rendered.push(
|
||||
@@ -1842,7 +1869,6 @@ const AssistantMessageBody = React.memo(({
|
||||
animateActivityRows,
|
||||
chatRenderMode,
|
||||
collapsibleThinkingBlocks,
|
||||
groupReasoningBlocks,
|
||||
collapsedPreviewCount,
|
||||
expandedTools,
|
||||
isMobile,
|
||||
@@ -2079,7 +2105,10 @@ const AssistantMessageBody = React.memo(({
|
||||
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
|
||||
) : null}
|
||||
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
|
||||
<TurnChangedFilePills files={turnGroupingContext?.changedFiles} />
|
||||
<TurnChangedFilePills
|
||||
files={turnGroupingContext?.changedFiles}
|
||||
isInteractive={turnGroupingContext?.isLatestTurn === true}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
|
||||
import { summarizeSelectionForNotes } from '@/lib/smallModel';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
@@ -518,7 +519,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
try {
|
||||
setIsAddingToNotes(true);
|
||||
const noteText = selectedTextMarkdown || selectedText;
|
||||
// Long selections are distilled into a compact note by the small model;
|
||||
// short ones (and any generation failure) go in verbatim.
|
||||
const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId);
|
||||
const projectData = await getProjectNotesAndTodos(currentProjectRef);
|
||||
const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText);
|
||||
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
|
||||
@@ -541,7 +544,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
} finally {
|
||||
setIsAddingToNotes(false);
|
||||
}
|
||||
}, [currentProjectRef, hideMenu, selectedText, selectedTextMarkdown, t]);
|
||||
}, [currentProjectRef, currentSessionId, hideMenu, selectedText, selectedTextMarkdown, t]);
|
||||
|
||||
if (!position.show) return null;
|
||||
|
||||
@@ -717,5 +720,3 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default TextSelectionMenu;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||
|
||||
describe('getMermaidDataUrlSourcePromise', () => {
|
||||
test('turns malformed data URLs into rejected promises', async () => {
|
||||
const sourcePromise = getMermaidDataUrlSourcePromise('data:text/plain;base64');
|
||||
|
||||
await sourcePromise.then(
|
||||
() => {
|
||||
throw new Error('expected malformed data URL to reject');
|
||||
},
|
||||
(error) => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(MermaidLoadFailure);
|
||||
expect(error.key).toBe('chat.toolOutputDialog.mermaid.dataUrlMalformed');
|
||||
expect(error.params).toBe(undefined);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mermaid load request ids', () => {
|
||||
test('invalidates stale async loads when a newer load starts', () => {
|
||||
const firstRequest = nextMermaidLoadRequestId(0);
|
||||
const secondRequest = nextMermaidLoadRequestId(firstRequest);
|
||||
|
||||
expect(isCurrentMermaidLoadRequest(secondRequest, firstRequest)).toBe(false);
|
||||
expect(isCurrentMermaidLoadRequest(secondRequest, secondRequest)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,9 @@ import { DiffViewToggle } from './DiffViewToggle';
|
||||
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
|
||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||
|
||||
interface ToolOutputDialogProps {
|
||||
popup: ToolPopupContent;
|
||||
@@ -35,6 +36,8 @@ interface ToolOutputDialogProps {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const mermaidLoadFailure = (key: I18nKey, params?: I18nParams): MermaidLoadFailure => new MermaidLoadFailure(key, params);
|
||||
|
||||
const getToolIcon = (toolName: string) => {
|
||||
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
|
||||
const tool = toolName.toLowerCase();
|
||||
@@ -97,7 +100,7 @@ const MERMAID_ASPECT_MAX_RETRIES = 3;
|
||||
|
||||
const DIALOG_CODE_TAG_PROPS = { style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } };
|
||||
|
||||
const MERMAID_CONTROLS = { download: false, copy: false, fullscreen: false, panZoom: true };
|
||||
const MERMAID_CONTROLS = { download: false, copy: false, showPanZoomControls: true };
|
||||
|
||||
type PierreThemeConfig = {
|
||||
theme: { light: string; dark: string };
|
||||
@@ -694,22 +697,11 @@ const MermaidPreviewDialog: React.FC<{
|
||||
return isSafeLocalPath(decoded) ? decoded : (isSafeLocalPath(stripped) ? stripped : null);
|
||||
}, []);
|
||||
|
||||
const decodeDataUrl = React.useCallback((value: string): string => {
|
||||
const commaIndex = value.indexOf(',');
|
||||
if (commaIndex < 0) {
|
||||
throw new Error('Malformed data URL');
|
||||
}
|
||||
|
||||
const metadata = value.slice(0, commaIndex).toLowerCase();
|
||||
const payload = value.slice(commaIndex + 1);
|
||||
if (metadata.includes(';base64')) {
|
||||
return atob(payload);
|
||||
}
|
||||
return decodeURIComponent(payload);
|
||||
}, []);
|
||||
|
||||
const loadMermaidSource = React.useCallback(async () => {
|
||||
const target = popup.mermaid;
|
||||
const requestId = nextMermaidLoadRequestId(requestIdRef.current);
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
if (!target?.url) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('chat.toolOutputDialog.mermaid.missingSource'));
|
||||
@@ -723,24 +715,21 @@ const MermaidPreviewDialog: React.FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
setStatus('loading');
|
||||
setErrorMessage('');
|
||||
|
||||
let sourcePromise: Promise<string>;
|
||||
if (target.url.startsWith('data:')) {
|
||||
sourcePromise = Promise.resolve(decodeDataUrl(target.url));
|
||||
sourcePromise = getMermaidDataUrlSourcePromise(target.url);
|
||||
} else if (target.url.toLowerCase().startsWith('file://')) {
|
||||
const normalizedPath = normalizeFilePath(target.url);
|
||||
if (!normalizedPath) {
|
||||
sourcePromise = Promise.reject(new Error('Invalid local file path for Mermaid preview.'));
|
||||
sourcePromise = Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.invalidLocalPath'));
|
||||
} else {
|
||||
sourcePromise = runtimeFetch('/api/fs/raw', { query: { path: normalizedPath } })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(new Error(`Failed to read diagram file (${response.status})`));
|
||||
return Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.readFileFailedWithStatus', { status: response.status }));
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
@@ -752,12 +741,12 @@ const MermaidPreviewDialog: React.FC<{
|
||||
const resolvedUrl = canParse ? new URL(target.url, window.location.origin) : null;
|
||||
|
||||
if (!resolvedUrl || (resolvedUrl.protocol !== 'http:' && resolvedUrl.protocol !== 'https:')) {
|
||||
sourcePromise = Promise.reject(new Error('Unsupported Mermaid URL protocol.'));
|
||||
sourcePromise = Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.unsupportedUrlProtocol'));
|
||||
} else {
|
||||
sourcePromise = fetch(resolvedUrl.toString())
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(new Error(`Failed to load diagram (${response.status})`));
|
||||
return Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.loadFailedWithStatus', { status: response.status }));
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
@@ -766,7 +755,7 @@ const MermaidPreviewDialog: React.FC<{
|
||||
|
||||
await sourcePromise
|
||||
.then((resolvedSource) => {
|
||||
if (requestIdRef.current !== requestId) {
|
||||
if (!isCurrentMermaidLoadRequest(requestIdRef.current, requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -774,13 +763,13 @@ const MermaidPreviewDialog: React.FC<{
|
||||
setStatus('ready');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (requestIdRef.current !== requestId) {
|
||||
if (!isCurrentMermaidLoadRequest(requestIdRef.current, requestId)) {
|
||||
return;
|
||||
}
|
||||
setStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : t('chat.toolOutputDialog.mermaid.loadFailed'));
|
||||
setErrorMessage(isMermaidLoadFailure(error) ? t(error.key, error.params) : t('chat.toolOutputDialog.mermaid.loadFailed'));
|
||||
});
|
||||
}, [decodeDataUrl, normalizeFilePath, popup.mermaid, t]);
|
||||
}, [normalizeFilePath, popup.mermaid, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!popup.open || !popup.mermaid) {
|
||||
@@ -896,10 +885,11 @@ const MermaidPreviewDialog: React.FC<{
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute inset-0 bg-black/40',
|
||||
'absolute inset-0',
|
||||
isTransitioning && 'transition-opacity duration-150 ease-out',
|
||||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-background) 70%, transparent)' }}
|
||||
onMouseDown={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
@@ -941,7 +931,13 @@ const MermaidPreviewDialog: React.FC<{
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="rounded-xl border border-border/30 bg-muted/20 p-3 space-y-3">
|
||||
<div
|
||||
className="rounded-xl border p-3 space-y-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--status-error-background)',
|
||||
borderColor: 'var(--status-error-border)',
|
||||
}}
|
||||
>
|
||||
<p className="typography-markdown" style={{ color: 'var(--status-error)' }}>
|
||||
{errorMessage || t('chat.toolOutputDialog.mermaid.renderFailed')}
|
||||
</p>
|
||||
@@ -966,8 +962,8 @@ const MermaidPreviewDialog: React.FC<{
|
||||
<SimpleMarkdownRenderer
|
||||
content={mermaidMarkdown}
|
||||
variant="tool"
|
||||
allowMermaidWheelZoom
|
||||
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
|
||||
allowMermaidWheelEvents
|
||||
className="markdown-mermaid-fullscreen h-full"
|
||||
mermaidControls={MERMAID_CONTROLS}
|
||||
enableFileReferences={false}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
export const isValidPart = (part: unknown): part is Part => {
|
||||
const isValidPart = (part: unknown): part is Part => {
|
||||
return Boolean(part && typeof part === 'object' && typeof (part as { type?: unknown }).type === 'string');
|
||||
};
|
||||
|
||||
@@ -67,13 +67,3 @@ export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions
|
||||
return !isPatchPart;
|
||||
});
|
||||
};
|
||||
|
||||
type PartWithTime = Part & { time?: { start?: number; end?: number } };
|
||||
|
||||
export const isFinalizedTextPart = (part: Part): boolean => {
|
||||
if (part.type !== 'text') {
|
||||
return false;
|
||||
}
|
||||
const time = (part as PartWithTime).time;
|
||||
return Boolean(time && typeof time.end !== 'undefined');
|
||||
};
|
||||
|
||||
@@ -45,26 +45,26 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
|
||||
## Current important behavior
|
||||
|
||||
- `read` and most search/fetch tools are treated as **static tools** and usually render via `StaticToolRow`.
|
||||
- `bash/edit/write/question/task` are **expandable tools** and render via `ToolPart`.
|
||||
- `perplexity` is currently treated as static and grouped into search/web-search style rows (through static grouping + short description extraction).
|
||||
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`.
|
||||
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
|
||||
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
|
||||
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
|
||||
|
||||
## "I want to change description for Perplexity" (example recipe)
|
||||
|
||||
If task is: "change text shown near Perplexity tool header/description":
|
||||
If task is: "change text shown near Read or Skill in compact mode":
|
||||
|
||||
1. Edit `ProgressiveGroup.tsx` -> `getToolShortDescription(activity)`.
|
||||
2. Update the branch that handles web-search tools (`websearch`, `web-search`, `search_web`, `codesearch`, `perplexity`, etc.).
|
||||
3. If needed, update group rendering in `StaticToolRow` (search/fetch specific rendering branches).
|
||||
2. Update the branch that handles `read` or `skill` in `StaticToolRow`.
|
||||
3. Keep all other tool header/output behavior in `ToolPart.tsx`.
|
||||
4. Keep icon changes (if any) in `toolPresentation.tsx`.
|
||||
|
||||
Why: in current pipeline Perplexity is static/grouped, so `StaticToolRow` is the primary path.
|
||||
Why: only navigation tools use the compact static path; all other tools need observable input and output.
|
||||
|
||||
## "I want tool to become expandable" (example)
|
||||
|
||||
1. Update `toolRenderUtils.ts`:
|
||||
- add/remove tool name in `EXPANDABLE_TOOL_NAMES`
|
||||
- add/remove a tool name from `STATIC_TOOL_NAMES` only when it has a reliable direct in-app navigation action
|
||||
2. Ensure `ToolPart.tsx` supports desired header + expanded output format for that tool.
|
||||
3. Validate both modes (`sorted` and `live`).
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { JsonSummaryView } from './JsonSummaryView';
|
||||
|
||||
describe('JsonSummaryView', () => {
|
||||
test('prioritizes a record identity and makes URLs navigable', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<JsonSummaryView
|
||||
data={{
|
||||
id: 'OPE-266',
|
||||
title: 'Refresh git status',
|
||||
url: 'https://linear.app/openchamber/issue/OPE-266',
|
||||
relations: { blocks: [] },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('OPE-266 · Refresh git status');
|
||||
expect(html).toContain('href="https://linear.app/openchamber/issue/OPE-266"');
|
||||
expect(html).toContain('Relations');
|
||||
expect(html).not.toContain('surface-elevated');
|
||||
});
|
||||
|
||||
test('summarizes record arrays as expandable sections', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<JsonSummaryView data={{ issues: [{ identifier: 'OPE-1', name: 'Example issue' }] }} />,
|
||||
);
|
||||
|
||||
expect(html).toContain('Issues (1)');
|
||||
expect(html).toContain('OPE-1 · Example issue');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const IDENTITY_KEYS = new Set(['id', 'identifier', 'title', 'name']);
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
);
|
||||
|
||||
const formatKey = (key: string) => key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/[_-]/g, ' ')
|
||||
.replace(/^./, (character) => character.toUpperCase());
|
||||
|
||||
const getIdentity = (record: Record<string, unknown>): string | null => {
|
||||
const id = typeof record.id === 'string' ? record.id : typeof record.identifier === 'string' ? record.identifier : '';
|
||||
const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : '';
|
||||
if (id && title) return `${id} · ${title}`;
|
||||
return title || id || null;
|
||||
};
|
||||
|
||||
const isUrl = (value: string): boolean => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const JsonSummaryValue = React.memo(({
|
||||
value,
|
||||
label,
|
||||
depth,
|
||||
}: {
|
||||
value: unknown;
|
||||
label?: string;
|
||||
depth: number;
|
||||
}) => {
|
||||
if (Array.isArray(value)) {
|
||||
const summary = label ? `${formatKey(label)} (${value.length})` : `(${value.length})`;
|
||||
return (
|
||||
<details open={depth < 2} className="group/json-summary">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1.5 typography-meta text-[var(--surface-foreground)] hover:text-[var(--surface-mutedForeground)]">
|
||||
<Icon name="arrow-right-s" className="h-3.5 w-3.5 shrink-0 transition-transform group-open/json-summary:rotate-90" />
|
||||
<span className="min-w-0 truncate font-medium">{summary}</span>
|
||||
</summary>
|
||||
<div className="relative ml-1 pl-3 pb-1">
|
||||
<span aria-hidden="true" className="pointer-events-none absolute bottom-1 left-0 top-0 w-px bg-[var(--tools-border)]" />
|
||||
<div className="space-y-1">
|
||||
{value.map((item, index) => <JsonSummaryValue key={index} value={item} depth={depth + 1} />)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
const identity = getIdentity(value);
|
||||
const entries = Object.entries(value).filter(([key]) => !IDENTITY_KEYS.has(key));
|
||||
const summary = label ? `${formatKey(label)}${identity ? ` · ${identity}` : ''}` : identity;
|
||||
const content = (
|
||||
<div className="space-y-1">
|
||||
{entries.map(([key, entry]) => <JsonSummaryValue key={key} label={key} value={entry} depth={depth + 1} />)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!label && depth === 0) {
|
||||
return <div className="space-y-2">{identity ? <div className="typography-meta font-medium text-[var(--surface-foreground)]">{identity}</div> : null}{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<details open={depth < 2} className="group/json-summary">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1.5 typography-meta text-[var(--surface-foreground)] hover:text-[var(--surface-mutedForeground)]">
|
||||
<Icon name="arrow-right-s" className="h-3.5 w-3.5 shrink-0 transition-transform group-open/json-summary:rotate-90" />
|
||||
<span className="min-w-0 truncate font-medium">{summary ?? (label ? formatKey(label) : '{}')}</span>
|
||||
</summary>
|
||||
<div className="relative ml-1 pl-3 pb-1">
|
||||
<span aria-hidden="true" className="pointer-events-none absolute bottom-1 left-0 top-0 w-px bg-[var(--tools-border)]" />
|
||||
{content}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
const text = value === null ? 'null' : typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value);
|
||||
const renderedValue = typeof value === 'string' && isUrl(value) ? (
|
||||
<a href={value} target="_blank" rel="noopener noreferrer" className="truncate text-[var(--status-info)] underline underline-offset-2 hover:opacity-80" title={value}>{value}</a>
|
||||
) : (
|
||||
<span className={cn('min-w-0 break-words', value === null ? 'text-[var(--surface-mutedForeground)]' : 'text-[var(--surface-foreground)]')}>{text}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(6rem,auto)_minmax(0,1fr)] gap-x-2 py-1 typography-meta">
|
||||
{label ? <span className="truncate text-[var(--surface-mutedForeground)]" title={label}>{formatKey(label)}</span> : <span />}
|
||||
{renderedValue}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
JsonSummaryValue.displayName = 'JsonSummaryValue';
|
||||
|
||||
export const JsonSummaryView = React.memo(({ data }: { data: unknown }) => (
|
||||
<div className="space-y-1">
|
||||
<JsonSummaryValue value={data} depth={0} />
|
||||
</div>
|
||||
));
|
||||
|
||||
JsonSummaryView.displayName = 'JsonSummaryView';
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MigratingPartProps {
|
||||
|
||||
isMigrating: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MigratingPart: React.FC<MigratingPartProps> = ({
|
||||
isMigrating,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
'w-full overflow-hidden',
|
||||
isMigrating && 'pointer-events-none',
|
||||
className
|
||||
)}
|
||||
style={isMigrating ? { animation: 'oc-migrate-up 220ms ease-out forwards' } : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MigratingPart);
|
||||
@@ -96,4 +96,20 @@ describe('ReasoningTimelineBlock', () => {
|
||||
// The ellipsis character marks that the text was truncated
|
||||
expect(markup).toContain('…');
|
||||
});
|
||||
|
||||
test('omits trailing empty HTML comments from the header summary', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<ReasoningTimelineBlock
|
||||
text={'Planning accessible icon labels with translations <!-- -->'}
|
||||
variant="thinking"
|
||||
blockId="reasoning-comment-test"
|
||||
showDuration={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('Planning accessible icon labels with translations');
|
||||
expect(markup).not.toContain('<!-- -->');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ const TOOL_ROW_DESCRIPTION_CLASS = cn('typography-meta', TOOL_ROW_TEXT_CLASS);
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
||||
|
||||
export type ReasoningVariant = 'thinking' | 'justification';
|
||||
type ReasoningVariant = 'thinking' | 'justification';
|
||||
|
||||
const cleanReasoningText = (text: string): string => {
|
||||
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||
@@ -40,6 +40,8 @@ const EXPANDED_CONTENT_TRANSITION = { duration: 0.2, ease: 'easeOut' as const };
|
||||
/** Strip common markdown syntax so the header preview reads as plain text. */
|
||||
const stripMarkdown = (text: string): string =>
|
||||
text
|
||||
// Empty HTML comments are frequently appended by model tool wrappers.
|
||||
.replace(/<!--\s*-->/g, '')
|
||||
// Fenced code blocks → keep inner text on one line
|
||||
.replace(/```[\w]*\n?([\s\S]*?)```/g, (_, inner: string) => inner.trim())
|
||||
// Inline code
|
||||
@@ -118,10 +120,14 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
: expansion.expanded;
|
||||
const [shouldRenderExpandedContent, setShouldRenderExpandedContent] = React.useState(defaultExpanded === true || canAutoExpand);
|
||||
const contentId = React.useId();
|
||||
const scrollRef = React.useRef<HTMLElement>(null);
|
||||
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
|
||||
@@ -160,12 +166,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
onContentChange?.('structural');
|
||||
}, [onContentChange, text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isStreaming && isExpanded && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [text, isStreaming, isExpanded]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isExpanded || isStreaming) {
|
||||
setShouldRenderExpandedContent(true);
|
||||
@@ -239,6 +239,11 @@ 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(
|
||||
@@ -280,6 +285,27 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasoningBody = (
|
||||
<>
|
||||
<div data-message-text-export-source="true">
|
||||
<MarkdownRenderer
|
||||
content={text}
|
||||
messageId={blockId}
|
||||
isAnimated={false}
|
||||
isStreaming={isStreaming}
|
||||
variant="reasoning"
|
||||
/>
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true">
|
||||
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-reasoning-block-id={blockId} data-message-text-export-root="true">
|
||||
<div
|
||||
@@ -379,32 +405,28 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
className="pointer-events-none absolute left-0 top-0 bottom-0 w-px"
|
||||
style={{ backgroundColor: 'var(--tools-border)' }}
|
||||
/>
|
||||
<ScrollableOverlay
|
||||
ref={scrollRef}
|
||||
as="div"
|
||||
outerClassName="max-h-80"
|
||||
className="p-0"
|
||||
useScrollShadow
|
||||
scrollShadowSize={36}
|
||||
userIntentOnly
|
||||
>
|
||||
<div data-message-text-export-source="true">
|
||||
<MarkdownRenderer
|
||||
content={text}
|
||||
messageId={blockId}
|
||||
isAnimated={false}
|
||||
isStreaming={isStreaming}
|
||||
variant="reasoning"
|
||||
/>
|
||||
{isStreaming ? (
|
||||
// While streaming, let the thinking grow inline — no
|
||||
// capped, independently-scrollable box. The chat's own
|
||||
// auto-follow then handles following / releasing, so the
|
||||
// box never captures the wheel or fights the user's
|
||||
// scroll. The max-height scroll box is applied only once
|
||||
// the thinking has finished (the branch below).
|
||||
<div className="p-0">
|
||||
{reasoningBody}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true">
|
||||
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ScrollableOverlay>
|
||||
) : (
|
||||
<ScrollableOverlay
|
||||
as="div"
|
||||
outerClassName="max-h-80"
|
||||
className="p-0"
|
||||
useScrollShadow
|
||||
scrollShadowSize={36}
|
||||
userIntentOnly
|
||||
>
|
||||
{reasoningBody}
|
||||
</ScrollableOverlay>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -456,87 +478,4 @@ const ReasoningPart = React.memo(({
|
||||
);
|
||||
});
|
||||
|
||||
type MergedReasoningPartProps = {
|
||||
parts: Part[];
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
messageId: string;
|
||||
streamPhase?: StreamPhase;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders ALL reasoning parts for a message as a single collapsible block,
|
||||
* merging their text and spanning their combined time range.
|
||||
* This matches the VSCode Copilot pattern of showing one "Thought" block per turn.
|
||||
*/
|
||||
export const MergedReasoningPart = React.memo(({
|
||||
parts,
|
||||
onContentChange,
|
||||
messageId,
|
||||
streamPhase,
|
||||
}: MergedReasoningPartProps) => {
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
|
||||
const mergedText = React.useMemo(() => {
|
||||
return parts
|
||||
.map((part) => {
|
||||
const p = part as PartWithText;
|
||||
return cleanReasoningText(p.text || p.content || '');
|
||||
})
|
||||
.filter((t) => t.length > 0)
|
||||
.join('\n\n');
|
||||
}, [parts]);
|
||||
|
||||
const mergedTime = React.useMemo(() => {
|
||||
let earliestStart: number | undefined;
|
||||
let latestEnd: number | undefined;
|
||||
|
||||
for (const part of parts) {
|
||||
const time = (part as PartWithText).time;
|
||||
if (typeof time?.start === 'number' && Number.isFinite(time.start)) {
|
||||
if (earliestStart === undefined || time.start < earliestStart) {
|
||||
earliestStart = time.start;
|
||||
}
|
||||
}
|
||||
if (typeof time?.end === 'number' && Number.isFinite(time.end)) {
|
||||
if (latestEnd === undefined || time.end > latestEnd) {
|
||||
latestEnd = time.end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return earliestStart !== undefined ? { start: earliestStart, end: latestEnd } : undefined;
|
||||
}, [parts]);
|
||||
|
||||
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
|
||||
const isStreaming = chatRenderMode === 'live' && canBeStreaming && parts.some(
|
||||
(part) => typeof (part as PartWithText).time?.end !== 'number',
|
||||
);
|
||||
|
||||
const throttledMergedText = useStreamingTextThrottle({
|
||||
text: mergedText,
|
||||
isStreaming,
|
||||
identityKey: `${messageId}:reasoning-merged`,
|
||||
});
|
||||
|
||||
const blockId = parts[0]?.id ?? `${messageId}-reasoning-merged`;
|
||||
|
||||
if (!throttledMergedText.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReasoningTimelineBlock
|
||||
text={throttledMergedText}
|
||||
variant="thinking"
|
||||
onContentChange={onContentChange}
|
||||
blockId={blockId}
|
||||
time={mergedTime}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const formatReasoningText = (text: string): string => cleanReasoningText(text);
|
||||
|
||||
export default ReasoningPart;
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 5x5 grid letter patterns (indices 0-24).
|
||||
* Grid layout:
|
||||
* 0 1 2 3 4
|
||||
* 5 6 7 8 9
|
||||
* 10 11 12 13 14
|
||||
* 15 16 17 18 19
|
||||
* 20 21 22 23 24
|
||||
*
|
||||
* Each letter is represented as an array of "on" cell indices.
|
||||
*/
|
||||
const LETTER_PATTERNS: Record<string, readonly number[]> = {
|
||||
// 0 1 2 3 4
|
||||
// 5 6 7 8 9
|
||||
// 10 11 12 13 14
|
||||
// 15 16 17 18 19
|
||||
// 20 21 22 23 24
|
||||
A: [1, 2, 3, 5, 9, 10, 11, 12, 13, 14, 15, 19, 20, 24],
|
||||
B: [0, 1, 2, 3, 5, 9, 10, 11, 12, 13, 15, 19, 20, 21, 22, 23],
|
||||
C: [1, 2, 3, 5, 10, 15, 21, 22, 23],
|
||||
D: [0, 1, 2, 3, 5, 9, 10, 14, 15, 19, 20, 21, 22, 23],
|
||||
E: [0, 1, 2, 3, 5, 10, 11, 12, 15, 20, 21, 22, 23],
|
||||
F: [0, 1, 2, 3, 5, 10, 11, 12, 15, 20],
|
||||
G: [1, 2, 3, 5, 10, 12, 13, 15, 18, 19, 21, 22, 23],
|
||||
H: [0, 4, 5, 9, 10, 11, 12, 13, 14, 15, 19, 20, 24],
|
||||
I: [1, 2, 3, 7, 12, 17, 21, 22, 23],
|
||||
J: [1, 2, 3, 8, 13, 15, 18, 21, 22],
|
||||
K: [0, 3, 5, 7, 10, 11, 15, 17, 20, 23],
|
||||
L: [0, 5, 10, 15, 20, 21, 22, 23],
|
||||
M: [0, 4, 5, 6, 8, 9, 10, 12, 14, 15, 19, 20, 24],
|
||||
N: [0, 4, 5, 6, 9, 10, 12, 14, 15, 18, 19, 20, 24],
|
||||
O: [1, 2, 3, 5, 9, 10, 14, 15, 19, 21, 22, 23],
|
||||
P: [0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 20],
|
||||
Q: [1, 2, 3, 5, 9, 10, 14, 15, 18, 19, 21, 22, 24],
|
||||
R: [0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 17, 20, 23],
|
||||
S: [1, 2, 3, 5, 11, 12, 13, 19, 21, 22, 23],
|
||||
T: [0, 1, 2, 3, 4, 7, 12, 17, 22],
|
||||
U: [0, 4, 5, 9, 10, 14, 15, 19, 21, 22, 23],
|
||||
V: [0, 4, 5, 9, 10, 14, 16, 18, 22],
|
||||
W: [0, 4, 5, 9, 10, 12, 14, 15, 16, 18, 19, 21, 23],
|
||||
X: [0, 4, 6, 8, 12, 16, 18, 20, 24],
|
||||
Y: [0, 4, 6, 8, 12, 17, 22],
|
||||
Z: [0, 1, 2, 3, 4, 8, 12, 16, 20, 21, 22, 23, 24],
|
||||
'0': [1, 2, 3, 5, 9, 10, 14, 15, 19, 21, 22, 23],
|
||||
'1': [2, 6, 7, 12, 17, 20, 21, 22, 23, 24],
|
||||
'2': [1, 2, 3, 9, 11, 12, 13, 16, 20, 21, 22, 23, 24],
|
||||
'3': [0, 1, 2, 3, 9, 11, 12, 13, 19, 20, 21, 22, 23],
|
||||
'4': [0, 4, 5, 9, 10, 11, 12, 13, 14, 19, 24],
|
||||
'5': [0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 19, 20, 21, 22, 23],
|
||||
'6': [1, 2, 3, 5, 10, 11, 12, 13, 15, 19, 21, 22, 23],
|
||||
'7': [0, 1, 2, 3, 4, 9, 13, 17, 22],
|
||||
'8': [1, 2, 3, 5, 9, 11, 12, 13, 15, 19, 21, 22, 23],
|
||||
'9': [1, 2, 3, 5, 9, 11, 12, 13, 19, 21, 22, 23],
|
||||
' ': [],
|
||||
};
|
||||
|
||||
// Build Set versions for O(1) lookups
|
||||
const LETTER_SETS: Record<string, Set<number>> = {};
|
||||
for (const [key, indices] of Object.entries(LETTER_PATTERNS)) {
|
||||
LETTER_SETS[key] = new Set(indices);
|
||||
}
|
||||
|
||||
/** Duration each letter is displayed (ms) */
|
||||
const LETTER_DURATION_MS = 800;
|
||||
/** Crossfade transition duration (ms) */
|
||||
const TRANSITION_MS = 500;
|
||||
/** Pause between full cycles (ms) */
|
||||
const CYCLE_PAUSE_MS = 1000;
|
||||
|
||||
/** Spacing between dot centers in SVG units */
|
||||
const DOT_SPACING = 4;
|
||||
/** Dot radius */
|
||||
const DOT_RADIUS = 1.2;
|
||||
|
||||
/**
|
||||
* Octagonal grid layout (7 rows):
|
||||
*
|
||||
* • • • row 0: 3 dots (cols 2-4)
|
||||
* • • • • • row 1: 5 dots (cols 1-5) → letter row 0
|
||||
* • • • • • • • row 2: 7 dots (cols 0-6) → letter row 1
|
||||
* • • • • • • • row 3: 7 dots (cols 0-6) → letter row 2
|
||||
* • • • • • • • row 4: 7 dots (cols 0-6) → letter row 3
|
||||
* • • • • • row 5: 5 dots (cols 1-5) → letter row 4
|
||||
* • • • row 6: 3 dots (cols 2-4)
|
||||
*
|
||||
* Letter indices (0-24) map to the inner 5x5 zone:
|
||||
* rows 1-5, cols 1-5
|
||||
*/
|
||||
const OCTAGON_ROWS: { row: number; cols: number[] }[] = [
|
||||
{ row: 0, cols: [2, 3, 4] },
|
||||
{ row: 1, cols: [1, 2, 3, 4, 5] },
|
||||
{ row: 2, cols: [0, 1, 2, 3, 4, 5, 6] },
|
||||
{ row: 3, cols: [0, 1, 2, 3, 4, 5, 6] },
|
||||
{ row: 4, cols: [0, 1, 2, 3, 4, 5, 6] },
|
||||
{ row: 5, cols: [1, 2, 3, 4, 5] },
|
||||
{ row: 6, cols: [2, 3, 4] },
|
||||
];
|
||||
|
||||
interface OctCell {
|
||||
id: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
/** Index into the 5x5 letter grid (0-24), or -1 for border-only dots */
|
||||
letterIndex: number;
|
||||
// Stable random timing
|
||||
shimmerDuration: number;
|
||||
shimmerDelay: number;
|
||||
idleDuration: number;
|
||||
idleDelay: number;
|
||||
}
|
||||
|
||||
const CELLS: OctCell[] = [];
|
||||
let cellId = 0;
|
||||
for (const { row, cols } of OCTAGON_ROWS) {
|
||||
for (const col of cols) {
|
||||
const cx = col * DOT_SPACING;
|
||||
const cy = row * DOT_SPACING;
|
||||
|
||||
// Letter zone: rows 1-5 (octagon), cols 1-5 (octagon)
|
||||
// maps to 5x5 letter index
|
||||
let letterIndex = -1;
|
||||
const letterRow = row - 1;
|
||||
const letterCol = col - 1;
|
||||
if (letterRow >= 0 && letterRow < 5 && letterCol >= 0 && letterCol < 5) {
|
||||
letterIndex = letterRow * 5 + letterCol;
|
||||
}
|
||||
|
||||
CELLS.push({
|
||||
id: cellId++,
|
||||
cx,
|
||||
cy,
|
||||
letterIndex,
|
||||
shimmerDuration: 3 + Math.random() * 3,
|
||||
shimmerDelay: Math.random() * 3,
|
||||
idleDuration: 1 + Math.random(),
|
||||
idleDelay: Math.random() * 1.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const VIEW_SIZE = 6 * DOT_SPACING + DOT_RADIUS * 2;
|
||||
const VIEW_OFFSET = -DOT_RADIUS;
|
||||
|
||||
interface SessionActiveSpinnerProps {
|
||||
className?: string;
|
||||
/** Text to spell out letter by letter. Falls back to idle pulse when empty/undefined. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle mode: random pulsing octagonal dot grid.
|
||||
* Text mode: cycles through characters of `text`, morphing between letter shapes.
|
||||
*/
|
||||
export function SessionActiveSpinner({ className, text }: SessionActiveSpinnerProps) {
|
||||
const normalizedText = text?.toUpperCase().replace(/[^A-Z0-9 ]/g, '') || '';
|
||||
const hasText = normalizedText.length > 0;
|
||||
|
||||
const [charIndex, setCharIndex] = React.useState(0);
|
||||
const [phase, setPhase] = React.useState<'hold' | 'morph'>('hold');
|
||||
|
||||
// Intro fade: foreground starts invisible and fades in
|
||||
const [introReady, setIntroReady] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setIntroReady(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
// Reset on text change
|
||||
React.useEffect(() => {
|
||||
setCharIndex(0);
|
||||
setPhase('hold');
|
||||
}, [normalizedText]);
|
||||
|
||||
// Letter cycling timer
|
||||
React.useEffect(() => {
|
||||
if (!hasText) return;
|
||||
|
||||
const total = normalizedText.length;
|
||||
|
||||
if (phase === 'hold') {
|
||||
const isLastChar = charIndex === total - 1;
|
||||
const delay = LETTER_DURATION_MS + (isLastChar ? CYCLE_PAUSE_MS : 0);
|
||||
const timer = setTimeout(() => setPhase('morph'), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCharIndex((prev) => (prev + 1) % total);
|
||||
setPhase('hold');
|
||||
}, TRANSITION_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hasText, charIndex, normalizedText, phase]);
|
||||
|
||||
// Compute current and next letter sets for morphing
|
||||
const total = normalizedText.length;
|
||||
const currentSet = hasText
|
||||
? (LETTER_SETS[normalizedText[charIndex]] ?? LETTER_SETS[' '])
|
||||
: null;
|
||||
const nextIndex = hasText ? (charIndex + 1) % total : 0;
|
||||
const nextSet = hasText
|
||||
? (LETTER_SETS[normalizedText[nextIndex]] ?? LETTER_SETS[' '])
|
||||
: null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`${VIEW_OFFSET} ${VIEW_OFFSET} ${VIEW_SIZE} ${VIEW_SIZE}`}
|
||||
data-component="session-active-spinner"
|
||||
className={className}
|
||||
fill="var(--foreground)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Background layer: all dots with shimmer animation */}
|
||||
{CELLS.map((cell) => (
|
||||
<circle
|
||||
key={cell.id}
|
||||
cx={cell.cx}
|
||||
cy={cell.cy}
|
||||
r={DOT_RADIUS}
|
||||
style={{
|
||||
animation: `${currentSet ? 'pulse-opacity-dim' : 'pulse-opacity'} ${currentSet ? cell.shimmerDuration : cell.idleDuration}s ease-in-out infinite`,
|
||||
animationDelay: `${currentSet ? cell.shimmerDelay : cell.idleDelay}s`,
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Foreground layer: morphing letter dots (only on letter-zone cells) */}
|
||||
<g fill="var(--primary)">
|
||||
{currentSet && nextSet && CELLS.map((cell) => {
|
||||
if (cell.letterIndex < 0) return null;
|
||||
|
||||
const inCurrent = currentSet.has(cell.letterIndex);
|
||||
const inNext = nextSet.has(cell.letterIndex);
|
||||
|
||||
if (!inCurrent && !inNext) return null;
|
||||
|
||||
let opacity: number;
|
||||
if (!introReady) {
|
||||
opacity = 0;
|
||||
} else if (phase === 'hold') {
|
||||
opacity = inCurrent ? 1 : 0;
|
||||
} else {
|
||||
if (inCurrent && inNext) {
|
||||
opacity = 1;
|
||||
} else if (inCurrent) {
|
||||
opacity = 0;
|
||||
} else {
|
||||
opacity = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={`fg-${cell.id}`}
|
||||
cx={cell.cx}
|
||||
cy={cell.cy}
|
||||
r={DOT_RADIUS}
|
||||
style={{
|
||||
opacity,
|
||||
transition: `opacity ${TRANSITION_MS}ms ease-in-out`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -30,9 +30,12 @@ import {
|
||||
formatEditOutput,
|
||||
detectLanguageFromOutput,
|
||||
formatInputForDisplay,
|
||||
renderTodoOutput,
|
||||
tryParseJsonOutput,
|
||||
coerceToText,
|
||||
} from '../toolRenderers';
|
||||
import { JsonTreeViewer } from '@/components/ui/JsonTreeViewer';
|
||||
import { JsonSummaryView } from './JsonSummaryView';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
|
||||
import { MinDurationShineText } from './MinDurationShineText';
|
||||
@@ -43,7 +46,7 @@ import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId';
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getDiffPatchEntries, getPatchText } from './toolDiffUtils';
|
||||
import { getDiffPatchEntries, getPatchText, type DiffPatchEntry } from './toolDiffUtils';
|
||||
|
||||
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
|
||||
const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS);
|
||||
@@ -226,14 +229,30 @@ const scheduleDeferredToolBodyMount = (fn: () => void) => {
|
||||
};
|
||||
|
||||
const useDeferredExpandedContent = (isExpanded: boolean) => {
|
||||
const [shouldRender, setShouldRender] = React.useState(false);
|
||||
// If the tool is expanded when the row first mounts (e.g. "show tools open
|
||||
// by default", or scrolling a default-open tool back into a virtualized
|
||||
// view), render the body SYNCHRONOUSLY so the virtualizer measures the real
|
||||
// height immediately. Deferring it would let the row mount short and grow a
|
||||
// frame later, which makes the virtualizer compensate scroll and lurch the
|
||||
// viewport past several messages on slow scroll. Only defer LATER
|
||||
// user-initiated expansions, where instant single-item feedback isn't worth
|
||||
// blocking the click on a heavy body render.
|
||||
const [shouldRender, setShouldRender] = React.useState(isExpanded);
|
||||
const mountedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded) {
|
||||
mountedRef.current = true;
|
||||
setShouldRender(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mountedRef.current) {
|
||||
mountedRef.current = true;
|
||||
setShouldRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
return scheduleDeferredToolBodyMount(() => {
|
||||
setShouldRender(true);
|
||||
});
|
||||
@@ -832,17 +851,17 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
const renderedOutput = getToolOutputText(output, part, metadata);
|
||||
const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
|
||||
const jsonResult = React.useMemo(() => tryParseJsonOutput(renderedOutput), [renderedOutput]);
|
||||
const [jsonViewMode, setJsonViewMode] = React.useState<'formatted' | 'raw'>('formatted');
|
||||
const [jsonViewMode, setJsonViewMode] = React.useState<'summary' | 'formatted' | 'raw'>('summary');
|
||||
const [copiedJson, setCopiedJson] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setJsonViewMode('formatted');
|
||||
setJsonViewMode('summary');
|
||||
setCopiedJson(false);
|
||||
}, [renderedOutput]);
|
||||
|
||||
const handleToggleJsonView = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const handleJsonViewChange = React.useCallback((view: 'summary' | 'formatted' | 'raw', event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
setJsonViewMode((prev) => prev === 'formatted' ? 'raw' : 'formatted');
|
||||
setJsonViewMode(view);
|
||||
}, []);
|
||||
|
||||
const handleCopyOutput = React.useCallback(async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -865,13 +884,35 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-md bg-[var(--surface-elevated)]/80 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleToggleJsonView}
|
||||
className={cn('h-6 w-6 rounded-md text-muted-foreground hover:text-foreground', jsonViewMode === 'summary' && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]')}
|
||||
onClick={(event) => handleJsonViewChange('summary', event)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label={jsonViewMode === 'formatted' ? t('chat.toolPart.showRawJson') : t('chat.toolPart.showFormattedJson')}
|
||||
title={jsonViewMode === 'formatted' ? t('chat.toolPart.showRawJson') : t('chat.toolPart.showFormattedJson')}
|
||||
aria-label={t('chat.toolPart.showNavigableJson')}
|
||||
title={t('chat.toolPart.showNavigableJson')}
|
||||
>
|
||||
<Icon name={jsonViewMode === 'formatted' ? 'code-box' : 'list-check-2'} className="h-3.5 w-3.5" />
|
||||
<Icon name="list-unordered" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6 rounded-md text-muted-foreground hover:text-foreground', jsonViewMode === 'formatted' && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]')}
|
||||
onClick={(event) => handleJsonViewChange('formatted', event)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label={t('chat.toolPart.showFormattedJson')}
|
||||
title={t('chat.toolPart.showFormattedJson')}
|
||||
>
|
||||
<Icon name="node-tree" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6 rounded-md text-muted-foreground hover:text-foreground', jsonViewMode === 'raw' && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]')}
|
||||
onClick={(event) => handleJsonViewChange('raw', event)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
aria-label={t('chat.toolPart.showRawJson')}
|
||||
title={t('chat.toolPart.showRawJson')}
|
||||
>
|
||||
<Icon name="code-box" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -885,7 +926,9 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
<Icon name={copiedJson ? 'check' : 'file-copy'} className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{jsonViewMode === 'formatted' ? (
|
||||
{jsonViewMode === 'summary' ? (
|
||||
<JsonSummaryView data={jsonResult.data} />
|
||||
) : jsonViewMode === 'formatted' ? (
|
||||
<JsonTreeViewer
|
||||
data={jsonResult.data}
|
||||
initiallyExpandedDepth={1}
|
||||
@@ -1638,6 +1681,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
onShowPopup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
|
||||
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
|
||||
const stateWithData = state as ToolStateWithMetadata;
|
||||
@@ -1683,6 +1727,13 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
}, [input, part.tool]);
|
||||
const hasInputText = !hideToolInputPreview && inputTextContent.trim().length > 0;
|
||||
const isWriteLikeTool = part.tool === 'write' || part.tool === 'create' || part.tool === 'file_write';
|
||||
const isTodoTool = part.tool === 'todowrite' || part.tool === 'todoread';
|
||||
const todoContent = React.useMemo(() => {
|
||||
if (Array.isArray(input?.todos)) {
|
||||
return JSON.stringify(input.todos);
|
||||
}
|
||||
return outputString;
|
||||
}, [input?.todos, outputString]);
|
||||
const writeLikeInputPatch = React.useMemo(() => {
|
||||
if (!isWriteLikeTool || !hasInputText) {
|
||||
return undefined;
|
||||
@@ -1716,6 +1767,36 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
);
|
||||
|
||||
const renderResultContent = () => {
|
||||
const getEntryAbsolutePath = (entry: DiffPatchEntry) => (
|
||||
entry.title.startsWith('/') ? entry.title : `${currentDirectory}/${entry.title}`.replace(/\/+/g, '/')
|
||||
);
|
||||
const openEntryFile = (entry: DiffPatchEntry, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
const line = extractFirstChangedLineFromDiff(entry.patch);
|
||||
const absolutePath = getEntryAbsolutePath(entry);
|
||||
if (runtime?.editor && runtime.runtime.isVSCode) {
|
||||
void runtime.editor.openFile(absolutePath, line);
|
||||
return;
|
||||
}
|
||||
useUIStore.getState().openContextFileAtLine(currentDirectory, absolutePath, line ?? 1, 1);
|
||||
};
|
||||
const openEntryDiff = (entry: DiffPatchEntry, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
const line = extractFirstChangedLineFromDiff(entry.patch);
|
||||
const absolutePath = getEntryAbsolutePath(entry);
|
||||
if (runtime?.editor && runtime.runtime.isVSCode) {
|
||||
void runtime.editor.openDiff('', absolutePath, `${getRelativePath(absolutePath, currentDirectory)} (changes)`, { line, patch: entry.patch });
|
||||
return;
|
||||
}
|
||||
const store = useUIStore.getState();
|
||||
const relativePath = getRelativePath(absolutePath, currentDirectory);
|
||||
if (store.isMobile) {
|
||||
store.navigateToDiff(relativePath);
|
||||
store.setRightSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
store.openContextDiff(currentDirectory, relativePath);
|
||||
};
|
||||
const renderDiagnosticsSection = () => {
|
||||
if (!diagnosticSection) {
|
||||
return null;
|
||||
@@ -1788,7 +1869,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
color: 'var(--status-error)',
|
||||
borderColor: 'var(--status-error-border)',
|
||||
}}>
|
||||
{state.error}
|
||||
{coerceToText(state.error)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1804,14 +1885,14 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
{questionInput.questions.map((q, index) => (
|
||||
<div key={index} className="space-y-0.5">
|
||||
{q.header ? (
|
||||
<div className="typography-micro text-muted-foreground">{q.header}</div>
|
||||
<div className="typography-micro text-muted-foreground">{coerceToText(q.header)}</div>
|
||||
) : null}
|
||||
<div className="typography-meta text-foreground">{q.question}</div>
|
||||
<div className="typography-meta text-foreground">{coerceToText(q.question)}</div>
|
||||
{Array.isArray(q.options) && q.options.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{q.options.map((opt) => (
|
||||
<span key={opt.label} className="typography-micro px-1.5 py-0.5 rounded bg-muted/30 border border-border/30 text-muted-foreground">
|
||||
{opt.label}
|
||||
<span key={coerceToText(opt.label)} className="typography-micro px-1.5 py-0.5 rounded bg-muted/30 border border-border/30 text-muted-foreground">
|
||||
{coerceToText(opt.label)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -1829,7 +1910,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
if (part.tool === 'task' && hasStringOutput) {
|
||||
return renderScrollableBlock(
|
||||
<div className="w-full min-w-0">
|
||||
<SimpleMarkdownRenderer content={outputString} variant="tool" onShowPopup={onShowPopup} />
|
||||
<SimpleMarkdownRenderer content={coerceToText(outputString)} variant="tool" onShowPopup={onShowPopup} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1839,11 +1920,31 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
<div className="space-y-3">
|
||||
{diffEntries.map((entry) => (
|
||||
<div key={entry.id} className="w-full min-w-0">
|
||||
{diffEntries.length > 1 ? (
|
||||
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground rounded-lg mb-1">
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1 px-2 py-1">
|
||||
<div className="min-w-0 flex-1 typography-meta font-medium text-muted-foreground">
|
||||
{renderPathLikeGitChanges(entry.title)}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(event) => openEntryFile(entry, event)}
|
||||
aria-label={t('chat.toolPart.openFileAtFirstChange')}
|
||||
title={t('chat.toolPart.openFileAtFirstChange')}
|
||||
>
|
||||
<Icon name="file-edit" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(event) => openEntryDiff(entry, event)}
|
||||
aria-label={t('chat.toolPart.openFileDiff')}
|
||||
title={t('chat.toolPart.openFileDiff')}
|
||||
>
|
||||
<Icon name="git-pull-request" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{entry.renderMode === 'diff' ? (
|
||||
<DiffPreview
|
||||
diff={entry.patch}
|
||||
@@ -1878,7 +1979,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
if (hasStringOutput && outputString.trim()) {
|
||||
return renderScrollableBlock(
|
||||
<ToolScrollableTextOutput
|
||||
output={outputString}
|
||||
output={coerceToText(outputString)}
|
||||
part={part}
|
||||
metadata={metadata}
|
||||
input={input}
|
||||
@@ -1896,6 +1997,47 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
);
|
||||
};
|
||||
|
||||
if (isTodoTool) {
|
||||
if (state.status === 'error' && 'error' in state) {
|
||||
return (
|
||||
<div className="relative pr-2 pb-2 pt-2 space-y-2 pl-4">
|
||||
<div className="typography-meta font-medium text-muted-foreground/80 mb-1">{t('chat.toolPart.error')}</div>
|
||||
<div className="typography-meta p-2 rounded-xl border" style={{
|
||||
backgroundColor: 'var(--status-error-background)',
|
||||
color: 'var(--status-error)',
|
||||
borderColor: 'var(--status-error-border)',
|
||||
}}>
|
||||
{state.error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const todoOutput = renderTodoOutput(todoContent, {
|
||||
total: t('chat.todo.total'),
|
||||
inProgress: t('chat.todo.inProgress'),
|
||||
pending: t('chat.todo.pending'),
|
||||
completed: t('chat.todo.completed'),
|
||||
cancelled: t('chat.todo.cancelled'),
|
||||
}, { unstyled: true });
|
||||
|
||||
return (
|
||||
<div className="relative pr-2 pb-2 pt-2 space-y-2 pl-4">
|
||||
{renderScrollableBlock(
|
||||
todoOutput ?? (
|
||||
<ToolScrollableTextOutput
|
||||
output={todoContent}
|
||||
part={part}
|
||||
metadata={metadata}
|
||||
input={input}
|
||||
/>
|
||||
),
|
||||
{ className: 'p-2', maxHeightClass: 'max-h-[46vh]' },
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1956,7 +2098,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
color: 'var(--status-error)',
|
||||
borderColor: 'var(--status-error-border)',
|
||||
}}>
|
||||
{state.error}
|
||||
{coerceToText(state.error)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -2055,7 +2197,10 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
const input = stateWithData.input;
|
||||
const time = stateWithData.time;
|
||||
|
||||
const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>({});
|
||||
const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>(() => ({
|
||||
start: typeof time?.start === 'number' ? time.start : undefined,
|
||||
end: typeof time?.end === 'number' ? time.end : undefined,
|
||||
}));
|
||||
const [localStartAt, setLocalStartAt] = React.useState<number | undefined>(undefined);
|
||||
const [localFinalizedAt, setLocalFinalizedAt] = React.useState<number | undefined>(undefined);
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { prepareUserMarkdownContent } from './userTextPartContent';
|
||||
|
||||
describe('prepareUserMarkdownContent', () => {
|
||||
test('keeps fenced code < and -> unescaped for the markdown renderer', () => {
|
||||
const content = prepareUserMarkdownContent({
|
||||
textContent: '```rust\nlet values: Vec<i32> = vec![];\nlet next = old -> new;\n```',
|
||||
skillNames: new Set(),
|
||||
});
|
||||
|
||||
expect(content).toContain('Vec<i32>');
|
||||
expect(content).toContain('old -> new');
|
||||
expect(content).not.toContain('<');
|
||||
expect(content).not.toContain('->');
|
||||
});
|
||||
|
||||
test('escapes raw HTML outside fences so tags display as text', () => {
|
||||
const content = prepareUserMarkdownContent({
|
||||
textContent: 'Use <b>bold</b> and <script>alert("x")</script>',
|
||||
skillNames: new Set(),
|
||||
});
|
||||
|
||||
expect(content).toContain('<b>bold</b>');
|
||||
expect(content).toContain('<script>alert("x")</script>');
|
||||
expect(content).not.toContain('<b>bold</b>');
|
||||
expect(content).not.toContain('<script>');
|
||||
});
|
||||
|
||||
test('adds hard line breaks outside fences but not inside', () => {
|
||||
const content = prepareUserMarkdownContent({
|
||||
textContent: 'first\nsecond\n```ts\nconst x = 1\nconst y = 2\n```\nthird',
|
||||
skillNames: new Set(),
|
||||
});
|
||||
|
||||
expect(content).toContain('first \nsecond \n```ts\n');
|
||||
expect(content).toContain('const x = 1\nconst y = 2\n``` \nthird');
|
||||
expect(content).not.toContain('const x = 1 \nconst y = 2');
|
||||
});
|
||||
|
||||
test('preserves mention conversion', () => {
|
||||
const content = prepareUserMarkdownContent({
|
||||
textContent: '@agent hello\n/skill-name',
|
||||
agentMention: { name: 'build-agent', token: '@agent' },
|
||||
skillNames: new Set(['skill-name']),
|
||||
});
|
||||
|
||||
expect(content).toContain('[@agent](#openchamber-agent:build-agent)');
|
||||
expect(content).toContain('[/skill-name](#openchamber-skill:skill-name)');
|
||||
expect(content).toContain('hello \n[/skill-name]');
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,10 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { getDirectoryForFilePath } from '@/lib/path-utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
buildAgentHref,
|
||||
buildAgentMentionUrl,
|
||||
buildSkillHref,
|
||||
parseSkillHref,
|
||||
} from '@/lib/messages/inlineMessageLinks';
|
||||
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -25,31 +24,10 @@ type UserTextPartProps = {
|
||||
agentMention?: AgentMentionInfo;
|
||||
};
|
||||
|
||||
const SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g;
|
||||
|
||||
const escapeHtml = (text: string): string => {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
// In Markdown a single "\n" is a soft break (rendered as a space). Users type plain
|
||||
// text where each newline is meant literally, so convert soft breaks into hard breaks
|
||||
// (two trailing spaces) outside of fenced code blocks, where newlines are already literal.
|
||||
const applyHardLineBreaks = (markdown: string): string => {
|
||||
return markdown
|
||||
.split(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/g)
|
||||
.map((segment, index) => (index % 2 === 1 ? segment : segment.replace(/ *\n/g, ' \n')))
|
||||
.join('');
|
||||
};
|
||||
|
||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text;
|
||||
@@ -145,26 +123,11 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
}, []);
|
||||
|
||||
const processedMarkdownContent = React.useMemo(() => {
|
||||
let content = textContent;
|
||||
|
||||
// Step 1: First escape HTML to protect against XSS and ensure HTML tags display as text
|
||||
content = escapeHtml(content);
|
||||
|
||||
// Step 2: Insert agent mention links with an internal href so markdown renders them as mentions, not external links.
|
||||
if (agentMention?.token && content.includes(agentMention.token)) {
|
||||
const mentionMarkdown = `[${agentMention.token}](${buildAgentHref(agentMention.name)})`;
|
||||
content = content.replace(agentMention.token, mentionMarkdown);
|
||||
}
|
||||
|
||||
content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string) => {
|
||||
if (!skillByName.has(skillName)) return match;
|
||||
return `${prefix}[/${skillName}](${buildSkillHref(skillName)})`;
|
||||
return prepareUserMarkdownContent({
|
||||
textContent,
|
||||
agentMention,
|
||||
skillNames: new Set(skillByName.keys()),
|
||||
});
|
||||
|
||||
// Step 4: Preserve user newlines (markdown soft breaks would otherwise collapse to spaces)
|
||||
content = applyHardLineBreaks(content);
|
||||
|
||||
return content;
|
||||
}, [agentMention, skillByName, textContent]);
|
||||
|
||||
const plainTextContent = React.useMemo(() => {
|
||||
@@ -267,10 +230,11 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
"[&_[data-component='markdown-code']]:bg-transparent",
|
||||
"[&_[data-component='markdown-code']>*:first-child]:hidden",
|
||||
"[&_[data-component='markdown-code']>div]:inline",
|
||||
"[&_[data-component='markdown-code']>div]:p-0",
|
||||
"[&_[data-component='markdown-code']_pre]:inline",
|
||||
"[&_[data-component='markdown-code']_code]:inline",
|
||||
]
|
||||
"[&_[data-component='markdown-code']>div]:p-0",
|
||||
"[&_[data-component='markdown-code']_pre]:inline",
|
||||
"[&_[data-component='markdown-code']_code]:inline",
|
||||
"[&_[data-md-code-line-numbers]]:hidden",
|
||||
]
|
||||
)}
|
||||
disableLinkSafety
|
||||
enableFileReferences={false}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Renders large code/read outputs without mounting one highlighter per line:
|
||||
* 1. ONE worker tokenization of the whole block (off the main thread)
|
||||
* 2. virtua to only render visible rows
|
||||
* 2. @tanstack/react-virtual to only render visible rows
|
||||
*
|
||||
* Tokenizing the whole block at once also preserves cross-line syntax context
|
||||
* (multi-line strings/comments) that per-line highlighting loses. Colors resolve
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Virtualizer } from 'virtua';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
||||
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||
@@ -114,28 +114,41 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const viewportHeight = `min(${lines.length * ROW_HEIGHT}px, ${maxHeight})`;
|
||||
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: lines.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 20,
|
||||
});
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="typography-code font-mono w-full min-w-0"
|
||||
style={{ ...(syntaxVars as React.CSSProperties), height: viewportHeight, maxHeight, overflow: 'auto' }}
|
||||
>
|
||||
<Virtualizer
|
||||
data={lines}
|
||||
itemSize={ROW_HEIGHT}
|
||||
bufferSize={ROW_HEIGHT * 20}
|
||||
scrollRef={parentRef}
|
||||
>
|
||||
{(line, index) => (
|
||||
<Row
|
||||
key={index}
|
||||
line={line}
|
||||
html={highlighted?.[index]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={lineStyles?.(line)}
|
||||
/>
|
||||
)}
|
||||
</Virtualizer>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualItems.map((item) => {
|
||||
const line = lines[item.index];
|
||||
if (!line) return null;
|
||||
return (
|
||||
<div
|
||||
key={item.index}
|
||||
data-index={item.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
<Row
|
||||
line={line}
|
||||
html={highlighted?.[item.index]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={lineStyles?.(line)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { coerceToText, renderTodoOutput } from '../../toolRenderers';
|
||||
|
||||
describe('coerceToText (issue #2011)', () => {
|
||||
test('returns strings unchanged', () => {
|
||||
expect(coerceToText('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
test('coerces plain objects to JSON strings', () => {
|
||||
// The exact shape that produced React error #31: object with {TODO} key
|
||||
const result = coerceToText({ TODO: 'Review the diff' });
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toContain('TODO');
|
||||
expect(result).toContain('Review the diff');
|
||||
});
|
||||
|
||||
test('coerces nested objects to JSON strings', () => {
|
||||
const result = coerceToText({ todos: [{ TODO: 'a' }, { content: 'b' }] });
|
||||
expect(typeof result).toBe('string');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toBeTruthy();
|
||||
});
|
||||
|
||||
test('coerces numbers and booleans', () => {
|
||||
expect(coerceToText(42)).toBe('42');
|
||||
expect(coerceToText(true)).toBe('true');
|
||||
expect(coerceToText(false)).toBe('false');
|
||||
});
|
||||
|
||||
test('returns fallback for null/undefined', () => {
|
||||
expect(coerceToText(null)).toBe('');
|
||||
expect(coerceToText(undefined)).toBe('');
|
||||
expect(coerceToText(null, 'oops')).toBe('oops');
|
||||
});
|
||||
|
||||
test('handles circular structures without throwing', () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
obj.self = obj;
|
||||
// Must not throw, must not recurse forever
|
||||
const result = coerceToText(obj);
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTodoOutput (issue #2011)', () => {
|
||||
const labels = {
|
||||
total: 'Total',
|
||||
inProgress: 'In progress',
|
||||
pending: 'Pending',
|
||||
completed: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
test('returns null for invalid JSON', () => {
|
||||
expect(renderTodoOutput('not json', labels)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when parsed value is not an array', () => {
|
||||
expect(renderTodoOutput(JSON.stringify({ foo: 'bar' }), labels)).toBeNull();
|
||||
});
|
||||
|
||||
test('renders valid todo arrays', () => {
|
||||
const output = JSON.stringify([
|
||||
{ id: '1', content: 'Do the thing', status: 'pending', priority: 'high' },
|
||||
]);
|
||||
const result = renderTodoOutput(output, labels);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test('filters out todos with non-string content (the {TODO} object case)', () => {
|
||||
// The exact pathological shape from the issue: a todo where content
|
||||
// is an object instead of a string. Previously this triggered
|
||||
// React error #31 when rendered as {todo.content}.
|
||||
const output = JSON.stringify([
|
||||
{ id: '1', content: { TODO: 'Review the diff' }, status: 'pending' },
|
||||
{ id: '2', content: 'Real string content', status: 'completed' },
|
||||
]);
|
||||
// Must not throw. Either returns valid React element (with bad row
|
||||
// filtered out) or null.
|
||||
const result = renderTodoOutput(output, labels);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when all todos have non-string content', () => {
|
||||
const output = JSON.stringify([
|
||||
{ id: '1', content: { TODO: 'x' }, status: 'pending' },
|
||||
{ id: '2', content: { foo: 'bar' }, status: 'completed' },
|
||||
]);
|
||||
expect(renderTodoOutput(output, labels)).toBeNull();
|
||||
});
|
||||
|
||||
test('filters out todos with non-string status', () => {
|
||||
const output = JSON.stringify([
|
||||
{ id: '1', content: 'Valid', status: { broken: true } },
|
||||
{ id: '2', content: 'Valid', status: 'pending' },
|
||||
]);
|
||||
const result = renderTodoOutput(output, labels);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { parseGeneratedJsonResult } from './generatedJsonResult';
|
||||
|
||||
describe('parseGeneratedJsonResult', () => {
|
||||
test('parses a full pull request JSON result', () => {
|
||||
expect(parseGeneratedJsonResult('{"title":"Side task","body":"Details"}')).toEqual({
|
||||
kind: 'pr',
|
||||
title: 'Side task',
|
||||
body: 'Details',
|
||||
raw: JSON.stringify({ title: 'Side task', body: 'Details' }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
test('parses a full fenced JSON result', () => {
|
||||
expect(parseGeneratedJsonResult('```json\n{"subject":"Fix parser","highlights":["Narrow detection"]}\n```')).toEqual({
|
||||
kind: 'commit',
|
||||
subject: 'Fix parser',
|
||||
highlights: ['Narrow detection'],
|
||||
raw: JSON.stringify({ subject: 'Fix parser', highlights: ['Narrow detection'] }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores JSON examples embedded in markdown prose', () => {
|
||||
const markdown = [
|
||||
'Recommended endpoint:',
|
||||
'',
|
||||
'```json',
|
||||
'{',
|
||||
' "title": "Side task",',
|
||||
' "prompt": "Investigate X"',
|
||||
'}',
|
||||
'```',
|
||||
'',
|
||||
'This should stay markdown.',
|
||||
].join('\n');
|
||||
|
||||
expect(parseGeneratedJsonResult(markdown)).toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user