Merge remote-tracking branch 'origin/main' into port-2736
# Conflicts: # packages/ui/src/components/chat/message/parts/ReasoningPart.tsx
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
|
||||
/**
|
||||
* Non-blocking notice that the OpenChamber session expired mid-work. It never
|
||||
* takes the screen on its own: work stays visible and interactive, and only
|
||||
* the explicit "Log in" click hands control to the session gate's full login
|
||||
* flow (password, passkey, desktop shell — all already there).
|
||||
*/
|
||||
export const AuthExpiredBanner: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const authState = useAuthSessionStore((store) => store.state);
|
||||
const markReauthenticating = useAuthSessionStore((store) => store.markReauthenticating);
|
||||
|
||||
if (authState !== 'expired') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
// Below the header on purpose: the header row can be a window-drag region
|
||||
// on desktop, where nothing under the cursor is clickable.
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 z-[200] flex justify-center px-4"
|
||||
style={{ top: 'calc(var(--oc-header-height, 56px) + 8px)' }}
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
className="oc-glass-popover oc-glass-floating pointer-events-auto flex items-center gap-3 rounded-lg px-3 py-2"
|
||||
>
|
||||
<Icon name="lock" className="size-4 flex-shrink-0" style={{ color: 'var(--status-error)' }} />
|
||||
<span className="typography-ui-label text-foreground">{t('sessionAuth.expired.banner')}</span>
|
||||
<Button size="xs" variant="outline" onClick={markReauthenticating} className="normal-case">
|
||||
{t('sessionAuth.expired.loginAction')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -303,6 +303,19 @@ mock.module('@/lib/passkeys', () => ({
|
||||
registerCurrentDevicePasskey: mock(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
const authSessionStore = {
|
||||
state: 'ok' as const,
|
||||
markAuthenticated: mock(() => undefined),
|
||||
};
|
||||
|
||||
mock.module('@/lib/runtime-auth-expiry', () => ({
|
||||
installAuthSessionFocusWatch: mock(() => undefined),
|
||||
useAuthSessionStore: Object.assign(
|
||||
(selector: (store: typeof authSessionStore) => unknown) => selector(authSessionStore),
|
||||
{ getState: () => authSessionStore },
|
||||
),
|
||||
}));
|
||||
|
||||
const { SessionAuthGate } = await import('./SessionAuthGate');
|
||||
|
||||
const flushEffects = async () => {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { installAuthSessionFocusWatch, useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||
import { AuthExpiredBanner } from './AuthExpiredBanner';
|
||||
import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
|
||||
@@ -351,6 +353,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null);
|
||||
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const hasResyncedRef = React.useRef(skipAuth);
|
||||
const hasBootstrapResyncedRef = React.useRef(skipAuth);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -557,6 +560,27 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
}
|
||||
}, [skipAuth, state]);
|
||||
|
||||
// Mid-session expiry: the banner asks for a re-login by flipping the shared
|
||||
// auth store to 'reauthenticating'; the gate answers with its own status
|
||||
// check, which lands in the full 'locked' flow on a genuine 401. A
|
||||
// successful login resolves the store back to 'ok'.
|
||||
const authSessionState = useAuthSessionStore((store) => store.state);
|
||||
React.useEffect(() => {
|
||||
if (!skipAuth) installAuthSessionFocusWatch();
|
||||
}, [skipAuth]);
|
||||
React.useEffect(() => {
|
||||
if (skipAuth) return;
|
||||
if (authSessionState === 'reauthenticating') {
|
||||
void checkStatusRef.current?.();
|
||||
}
|
||||
}, [authSessionState, skipAuth]);
|
||||
React.useEffect(() => {
|
||||
if (skipAuth) return;
|
||||
if (state === 'authenticated' && useAuthSessionStore.getState().state !== 'ok') {
|
||||
useAuthSessionStore.getState().markAuthenticated();
|
||||
}
|
||||
}, [skipAuth, state]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state === 'locked' && passwordInputRef.current) {
|
||||
passwordInputRef.current.focus();
|
||||
@@ -570,10 +594,18 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
}
|
||||
if (state === 'authenticated' && !hasResyncedRef.current) {
|
||||
hasResyncedRef.current = true;
|
||||
// First authentication of this page load is bootstrap: adopt the
|
||||
// persisted workspace pointers. A re-login after mid-session expiry is
|
||||
// not — this window already has its own workspace, and the shared
|
||||
// settings document may carry another window's pointers.
|
||||
const isBootstrapResync = !hasBootstrapResyncedRef.current;
|
||||
hasBootstrapResyncedRef.current = true;
|
||||
void (async () => {
|
||||
await initializeAppearancePreferences();
|
||||
await syncDesktopSettings();
|
||||
await applyPersistedDirectoryPreferences();
|
||||
await syncDesktopSettings({ adoptWorkspace: isBootstrapResync });
|
||||
if (isBootstrapResync) {
|
||||
await applyPersistedDirectoryPreferences();
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [skipAuth, state]);
|
||||
@@ -983,5 +1015,10 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<>
|
||||
{skipAuth ? null : <AuthExpiredBanner />}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { browserUrlLabel } from '@/lib/browser/url';
|
||||
import type { BrowserHistoryEntry } from '@/lib/browser/history';
|
||||
|
||||
/**
|
||||
* Addresses already visited in this project, offered under the address bar.
|
||||
*
|
||||
* Kept deliberately plain: it is a short list of places, so it borrows the
|
||||
* app's dropdown surface rather than introducing a second look for the same
|
||||
* idea. Selection is driven from the address bar's own keyboard handling, which
|
||||
* is why the highlighted row arrives as a prop instead of being tracked here.
|
||||
*/
|
||||
export const BrowserAddressSuggestions: React.FC<{
|
||||
entries: readonly BrowserHistoryEntry[];
|
||||
activeIndex: number;
|
||||
onSelect: (url: string) => void;
|
||||
onForget: (url: string) => void;
|
||||
onHighlight: (index: number) => void;
|
||||
}> = ({ entries, activeIndex, onSelect, onForget, onHighlight }) => {
|
||||
const { t } = useI18n();
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="oc-glass-popover oc-glass-floating absolute inset-x-0 top-full z-50 mt-1 overflow-hidden rounded-xl p-1"
|
||||
role="listbox"
|
||||
aria-label={t('contextPanel.browser.history.label')}
|
||||
>
|
||||
{entries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.url}
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
className={cn(
|
||||
'group flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1',
|
||||
index === activeIndex ? 'bg-interactive-hover' : 'hover:bg-interactive-hover',
|
||||
)}
|
||||
// Pointer down rather than click: the address bar loses focus first,
|
||||
// and a blur that closes the list would cancel the click.
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
onSelect(entry.url);
|
||||
}}
|
||||
onPointerEnter={() => onHighlight(index)}
|
||||
>
|
||||
<Icon name="global" className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate typography-micro text-foreground">
|
||||
{entry.title || browserUrlLabel(entry.url)}
|
||||
</div>
|
||||
<div className="truncate typography-micro text-muted-foreground">{entry.url}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity',
|
||||
'hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100',
|
||||
)}
|
||||
aria-label={t('contextPanel.browser.history.forget')}
|
||||
title={t('contextPanel.browser.history.forget')}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onForget(entry.url);
|
||||
}}
|
||||
>
|
||||
<Icon name="close" className="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
FILL_VIEWPORT,
|
||||
VIEWPORT_PRESETS,
|
||||
clampViewportSize,
|
||||
presetViewport,
|
||||
rotateViewport,
|
||||
viewportSize,
|
||||
type BrowserViewport,
|
||||
} from '@/lib/browser/viewport';
|
||||
|
||||
export type BrowserColorScheme = 'system' | 'light' | 'dark';
|
||||
|
||||
/**
|
||||
* Size and appearance controls for the previewed page.
|
||||
*
|
||||
* Shown only when asked for. The width and height boxes are the source of
|
||||
* truth; the preset list is a shortcut into them, which is why choosing a
|
||||
* preset and then typing a size are the same action from here on.
|
||||
*/
|
||||
export const BrowserDeviceBar: React.FC<{
|
||||
viewport: BrowserViewport;
|
||||
onViewportChange: (viewport: BrowserViewport) => void;
|
||||
colorScheme: BrowserColorScheme;
|
||||
onColorSchemeChange: (scheme: BrowserColorScheme) => void;
|
||||
scale: number;
|
||||
}> = ({ viewport, onViewportChange, colorScheme, onColorSchemeChange, scale }) => {
|
||||
const { t } = useI18n();
|
||||
const size = viewportSize(viewport);
|
||||
const presetId = viewport.kind === 'preset' ? viewport.id : '';
|
||||
|
||||
const commitSize = (side: 'width' | 'height', raw: string) => {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
const current = size ?? { width: 1280, height: 800 };
|
||||
onViewportChange({
|
||||
kind: 'custom',
|
||||
width: clampViewportSize(side === 'width' ? parsed : current.width),
|
||||
height: clampViewportSize(side === 'height' ? parsed : current.height),
|
||||
});
|
||||
};
|
||||
|
||||
const inputClass = cn(
|
||||
'h-6 w-14 rounded-full border border-border/50 bg-[var(--surface-elevated)] px-2 text-center',
|
||||
'typography-micro tabular-nums text-foreground outline-none focus:border-[var(--interactive-focus-ring)]',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 border-b border-border bg-[var(--surface-background)] px-2 py-1">
|
||||
<select
|
||||
value={presetId}
|
||||
onChange={(event) => {
|
||||
const next = presetViewport(event.target.value);
|
||||
onViewportChange(next ?? FILL_VIEWPORT);
|
||||
}}
|
||||
aria-label={t('contextPanel.browser.device.preset')}
|
||||
className={cn(
|
||||
'h-6 shrink-0 rounded-full border border-border/50 bg-[var(--surface-elevated)] px-2',
|
||||
'typography-micro text-foreground outline-none focus:border-[var(--interactive-focus-ring)]',
|
||||
)}
|
||||
>
|
||||
<option value="">{t('contextPanel.browser.device.responsive')}</option>
|
||||
{VIEWPORT_PRESETS.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>{preset.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
value={size ? String(size.width) : ''}
|
||||
onChange={(event) => commitSize('width', event.target.value)}
|
||||
placeholder="—"
|
||||
inputMode="numeric"
|
||||
aria-label={t('contextPanel.browser.device.width')}
|
||||
className={inputClass}
|
||||
/>
|
||||
<span className="typography-micro text-muted-foreground">×</span>
|
||||
<input
|
||||
value={size ? String(size.height) : ''}
|
||||
onChange={(event) => commitSize('height', event.target.value)}
|
||||
placeholder="—"
|
||||
inputMode="numeric"
|
||||
aria-label={t('contextPanel.browser.device.height')}
|
||||
className={inputClass}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="w-6 shrink-0 rounded-full px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onViewportChange(rotateViewport(viewport))}
|
||||
disabled={!size}
|
||||
aria-label={t('contextPanel.browser.device.rotate')}
|
||||
>
|
||||
<Icon name="refresh" className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('contextPanel.browser.device.rotate')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Only worth saying when the page is not shown at its real size. */}
|
||||
{size && scale < 1 ? (
|
||||
<span className="shrink-0 typography-micro tabular-nums text-muted-foreground">
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{(['system', 'light', 'dark'] as const).map((scheme) => (
|
||||
<Button
|
||||
key={scheme}
|
||||
type="button"
|
||||
variant={colorScheme === scheme ? 'secondary' : 'ghost'}
|
||||
size="xs"
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2.5 typography-micro',
|
||||
colorScheme === scheme ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => onColorSchemeChange(scheme)}
|
||||
aria-pressed={colorScheme === scheme}
|
||||
>
|
||||
{t(scheme === 'system'
|
||||
? 'contextPanel.browser.device.schemeSystem'
|
||||
: scheme === 'light'
|
||||
? 'contextPanel.browser.device.schemeLight'
|
||||
: 'contextPanel.browser.device.schemeDark')}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { fetchDevServers, mergeDevServerCandidates, type DevServerDiscovery } from '@/lib/browser/devServers';
|
||||
import { clearAnnouncedDevServers, useAnnouncedDevServers } from '@/lib/browser/announcedServers';
|
||||
import { browserUrlLabel, isLoopbackUrl } from '@/lib/browser/url';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
|
||||
/**
|
||||
* What the panel shows before anything is loaded.
|
||||
*
|
||||
* Rather than an inert placeholder, this lists the servers actually running,
|
||||
* which is almost always what the user came here to open. Discovery failure is
|
||||
* stated plainly instead of being rendered as "nothing is running" — the two
|
||||
* mean very different things to someone whose dev server is definitely up.
|
||||
*/
|
||||
|
||||
/** The base path a server is served under, or '' when it sits at the root. */
|
||||
const pathLabel = (url: string): string => {
|
||||
try {
|
||||
const path = new URL(url).pathname;
|
||||
return path === '/' ? '' : path;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/** Re-checked while the panel is open: a project's servers appear seconds apart. */
|
||||
const REFRESH_INTERVAL_MS = 2_000;
|
||||
|
||||
/**
|
||||
* True when the listed servers are on another machine and this client has no
|
||||
* way to reach them. The desktop shell tunnels a local port for exactly this
|
||||
* case; a browser tab has no equivalent, and its `localhost` is its own.
|
||||
*/
|
||||
const isUnreachableFromHere = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (window.__OPENCHAMBER_ELECTRON__) return false;
|
||||
const baseUrl = getRuntimeApiBaseUrl();
|
||||
if (!baseUrl) return false;
|
||||
try {
|
||||
return !isLoopbackUrl(new URL(baseUrl, window.location.href).toString());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const BrowserEmptyState: React.FC<{
|
||||
onOpen: (url: string) => void;
|
||||
directory?: string;
|
||||
}> = ({ onOpen, directory = '' }) => {
|
||||
const { t } = useI18n();
|
||||
const [discovery, setDiscovery] = React.useState<DevServerDiscovery>({ kind: 'loading' });
|
||||
const announced = useAnnouncedDevServers(directory);
|
||||
const [remoteOnly] = React.useState(isUnreachableFromHere);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const controller = new AbortController();
|
||||
|
||||
const poll = () => {
|
||||
void fetchDevServers(controller.signal).then((result) => {
|
||||
if (!active) return;
|
||||
setDiscovery(result);
|
||||
// One look is a snapshot of whichever servers happened to be up first.
|
||||
timer = setTimeout(poll, REFRESH_INTERVAL_MS);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const candidates = React.useMemo(() => mergeDevServerCandidates({
|
||||
announced,
|
||||
discovered: discovery.kind === 'ready' ? discovery.servers : null,
|
||||
}), [announced, discovery]);
|
||||
|
||||
return (
|
||||
// The whole panel must not scroll: a centred column that overflows clips its
|
||||
// own top, and no amount of scrolling reaches it. Only the list of servers
|
||||
// scrolls, and it shrinks to whatever room is left before it does.
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-5 overflow-hidden bg-background p-6 text-center">
|
||||
<OpenChamberLogo width={110} height={110} className="shrink-0 opacity-20" />
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<span className="typography-ui-header text-foreground">{t('contextPanel.browser.empty')}</span>
|
||||
<span className="typography-micro text-muted-foreground">{t('contextPanel.browser.emptyHint')}</span>
|
||||
</div>
|
||||
|
||||
{candidates.length > 0 ? (
|
||||
<div className="flex min-h-0 w-full max-w-sm flex-col gap-1">
|
||||
<span className="shrink-0 typography-micro text-left text-muted-foreground">
|
||||
{announced.length > 0
|
||||
? t('contextPanel.browser.devServers.justStarted')
|
||||
: t('contextPanel.browser.devServers.title')}
|
||||
</span>
|
||||
{remoteOnly ? (
|
||||
<span className="shrink-0 pb-1 text-left typography-micro text-muted-foreground">
|
||||
{t('contextPanel.browser.devServers.remoteOnly')}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-col gap-1 overflow-y-auto pr-0.5">
|
||||
{candidates.map((candidate) => (
|
||||
<Button
|
||||
key={candidate.port}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full shrink-0 justify-start gap-2"
|
||||
onClick={() => {
|
||||
// The offer is answered; leaving it up would keep suggesting
|
||||
// servers behind a page the user is already looking at.
|
||||
clearAnnouncedDevServers(directory);
|
||||
onOpen(candidate.url);
|
||||
}}
|
||||
>
|
||||
<Icon name="global" className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{browserUrlLabel(candidate.url) || candidate.url}</span>
|
||||
<span className="ml-auto truncate typography-micro text-muted-foreground">
|
||||
{pathLabel(candidate.url)}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{candidates.length === 0 && discovery.kind === 'unavailable' ? (
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{t('contextPanel.browser.devServers.unavailable')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,926 @@
|
||||
import React from 'react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { invokeDesktopCommand } from '@/lib/desktopNative';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { BLANK_URL, isLoopbackUrl, isStartingServerFailure, normalizeBrowserUrl } from '@/lib/browser/url';
|
||||
import { probeLoopbackStatus } from '@/lib/browser/devServers';
|
||||
import {
|
||||
cancelAnnotationSession,
|
||||
runAnnotationSession,
|
||||
type AnnotationHost,
|
||||
type PageCapture,
|
||||
} from '@/lib/browser/annotationSession';
|
||||
import { resolveAnnotationOverlayTheme } from '@/lib/browser/overlayTheme';
|
||||
import { registerBrowserController } from '@/lib/browser/controlClient';
|
||||
import { suggestFromHistory } from '@/lib/browser/history';
|
||||
import { selectBrowserHistory, useBrowserHistoryStore } from '@/stores/useBrowserHistoryStore';
|
||||
import {
|
||||
DevTunnelUnavailableError,
|
||||
resolveBrowsableUrl,
|
||||
shouldTunnelLoopbackUrl,
|
||||
toDisplayUrl,
|
||||
} from '@/lib/browser/devTunnel';
|
||||
import {
|
||||
buildClickScript,
|
||||
buildInspectScript,
|
||||
buildScrollScript,
|
||||
buildSnapshotScript,
|
||||
buildTypeScript,
|
||||
} from '@/lib/browser/pageActions';
|
||||
import { BrowserToolbar } from './BrowserToolbar';
|
||||
import { BrowserDeviceBar, type BrowserColorScheme } from './BrowserDeviceBar';
|
||||
import {
|
||||
FILL_VIEWPORT,
|
||||
fitViewport,
|
||||
isViewportMode,
|
||||
viewportForMode,
|
||||
viewportSummary,
|
||||
type BrowserViewport,
|
||||
} from '@/lib/browser/viewport';
|
||||
import { BrowserEmptyState } from './BrowserEmptyState';
|
||||
import { useAnnotationAttach, useAnnotationOverlayLabels } from './useAnnotationAttach';
|
||||
import { readEventPayload, useWebviewNavigation } from './useWebviewNavigation';
|
||||
|
||||
export type BrowserPaneProps = {
|
||||
initialUrl: string;
|
||||
directory: string;
|
||||
tabID: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Chromium is the only host that can give us a real page: cookies, service
|
||||
* workers, HMR sockets, DevTools, and same-document access for annotation. When
|
||||
* it is unavailable the surface degrades to a plain iframe that can display a
|
||||
* page but cannot inspect one, rather than pretending otherwise.
|
||||
*/
|
||||
const isChromiumHost = (): boolean => (
|
||||
typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__)
|
||||
);
|
||||
|
||||
/** How long to keep waiting for a dev server that is still coming up. */
|
||||
const DEV_SERVER_WAIT_MS = 40_000;
|
||||
/** Chromium's zoom is exponential: factor = 1.2 ^ level. */
|
||||
const ZOOM_STEP = 0.5;
|
||||
const ZOOM_MIN = -3;
|
||||
const ZOOM_MAX = 4;
|
||||
const BROWSER_PARTITION = 'persist:openchamber-browser';
|
||||
/** Kept small: this rides along with every snapshot. */
|
||||
const CONSOLE_PROBLEM_LIMIT = 20;
|
||||
const DEV_SERVER_RETRY_DELAY_MS = 600;
|
||||
/**
|
||||
* A shorter budget for a server that *answers* but with a 5xx, which is what a
|
||||
* dev gateway does while the app behind it is still starting. Kept short and
|
||||
* applied only before the first good load, so a genuine server error — a build
|
||||
* failure page, say — is shown promptly instead of being hidden behind a
|
||||
* spinner.
|
||||
*/
|
||||
const GATEWAY_WAIT_MS = 20_000;
|
||||
|
||||
const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const webviewRef = React.useRef<WebviewElement | null>(null);
|
||||
// Tracked in state as well as a ref: effects that attach listeners must re-run
|
||||
// when the view appears, which a stable ref cannot tell them.
|
||||
const [webviewElement, setWebviewElement] = React.useState<WebviewElement | null>(null);
|
||||
const attachWebview = React.useCallback((node: WebviewElement | null) => {
|
||||
webviewRef.current = node;
|
||||
setWebviewElement(node);
|
||||
}, []);
|
||||
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
|
||||
|
||||
// Captured once: the webview owns its history from here on, and re-deriving
|
||||
// this from props would drag the view back to where the tab started.
|
||||
const initialUrlRef = React.useRef(normalizeBrowserUrl(initialUrl));
|
||||
const startUrl = initialUrlRef.current !== BLANK_URL ? initialUrlRef.current : '';
|
||||
|
||||
// The view is created with its final URL already in `src`, never navigated
|
||||
// into place afterwards. A tab opened in the background renders hidden, where
|
||||
// an imperative navigation is lost, and mutating `src` after the element
|
||||
// exists is not reliably honoured either — both leave a panel that never
|
||||
// loads. `null` means "still resolving", and the view is not rendered yet.
|
||||
const [initialSrc, setInitialSrc] = React.useState<string | null>(startUrl ? null : BLANK_URL);
|
||||
|
||||
const [address, setAddress] = React.useState(startUrl);
|
||||
const [isAnnotating, setIsAnnotating] = React.useState(false);
|
||||
const [isWaitingForServer, setIsWaitingForServer] = React.useState(false);
|
||||
const [zoomLevel, setZoomLevel] = React.useState(0);
|
||||
const [showDeviceBar, setShowDeviceBar] = React.useState(false);
|
||||
const [viewport, setViewport] = React.useState<BrowserViewport>(FILL_VIEWPORT);
|
||||
// Read inside agent actions, which are not re-created when the viewport
|
||||
// changes and would otherwise report whatever it was when they were built.
|
||||
const viewportRef = React.useRef(viewport);
|
||||
viewportRef.current = viewport;
|
||||
/**
|
||||
* Errors and warnings the page logged, reported with the next snapshot.
|
||||
*
|
||||
* A page that looks right and is throwing looks identical to one that is
|
||||
* fine, and finding out otherwise used to mean opening DevTools by hand.
|
||||
*/
|
||||
const consoleProblemsRef = React.useRef<Array<{ level: string; message: string; source: string }>>([]);
|
||||
const [colorScheme, setColorScheme] = React.useState<BrowserColorScheme>('system');
|
||||
const [stageSize, setStageSize] = React.useState({ width: 0, height: 0 });
|
||||
const stageRef = React.useRef<HTMLDivElement | null>(null);
|
||||
/** When the current run of retries began, per URL. */
|
||||
const retryRef = React.useRef<{ url: string; startedAt: number } | null>(null);
|
||||
/** Set once this tab has seen a page that was not a startup error. */
|
||||
const servedOkRef = React.useRef(false);
|
||||
const openedAtRef = React.useRef(Date.now());
|
||||
|
||||
const persistUrl = React.useCallback((url: string) => {
|
||||
if (!url || url === BLANK_URL || !directory || !tabID) return;
|
||||
setContextPanelTabTargetPath(directory, tabID, url);
|
||||
}, [directory, tabID, setContextPanelTabTargetPath]);
|
||||
|
||||
const navigation = useWebviewNavigation(webviewElement, {
|
||||
initialUrl: startUrl,
|
||||
onUrlChange: React.useCallback((url: string) => {
|
||||
const display = toDisplayUrl(url);
|
||||
setAddress(display);
|
||||
persistUrl(display);
|
||||
}, [persistUrl]),
|
||||
});
|
||||
|
||||
/** Set when a remote dev server could not be reached from this machine. */
|
||||
const [tunnelFailedUrl, setTunnelFailedUrl] = React.useState<string | null>(null);
|
||||
const attachAnnotation = useAnnotationAttach(directory);
|
||||
const overlayLabels = useAnnotationOverlayLabels();
|
||||
const isLoading = navigation.status.kind === 'loading';
|
||||
|
||||
const history = useBrowserHistoryStore(selectBrowserHistory(directory));
|
||||
const recordHistoryVisit = useBrowserHistoryStore((state) => state.recordVisit);
|
||||
const forgetHistoryVisit = useBrowserHistoryStore((state) => state.forget);
|
||||
// Recorded once a page has actually loaded, and with the title it reported:
|
||||
// an address that failed to open is not somewhere to offer going back to.
|
||||
React.useEffect(() => {
|
||||
if (navigation.status.kind !== 'ready') return;
|
||||
recordHistoryVisit(directory, {
|
||||
url: toDisplayUrl(navigation.status.url),
|
||||
title: navigation.status.title,
|
||||
});
|
||||
}, [directory, navigation.status, recordHistoryVisit]);
|
||||
const suggestions = React.useMemo(
|
||||
() => suggestFromHistory(history, address),
|
||||
[history, address],
|
||||
);
|
||||
|
||||
const loadUrl = React.useCallback((value: string) => {
|
||||
const next = normalizeBrowserUrl(value);
|
||||
if (next === BLANK_URL) return;
|
||||
// The address bar shows what the user asked for; a tunnel only changes
|
||||
// where the bytes come from, and surfacing 127.0.0.1:<random> would be
|
||||
// confusing and useless to copy.
|
||||
setAddress(next);
|
||||
setTunnelFailedUrl(null);
|
||||
void resolveBrowsableUrl(next).then((target) => {
|
||||
const webview = webviewRef.current;
|
||||
if (!webview) {
|
||||
setInitialSrc(target);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
webview.loadURL(target);
|
||||
} catch {
|
||||
// Not attached yet: hand the navigation to the attribute, which
|
||||
// Chromium applies once the view attaches.
|
||||
setInitialSrc(target);
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
// Loading the address here anyway would answer from this machine while
|
||||
// showing the remote one's address. Say what happened instead.
|
||||
if (error instanceof DevTunnelUnavailableError) setTunnelFailedUrl(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Resolving through the tunnel is what lets a persisted loopback URL reach a
|
||||
// dev server on a remote host; locally it returns the URL unchanged.
|
||||
React.useEffect(() => {
|
||||
if (!startUrl) return;
|
||||
let active = true;
|
||||
void resolveBrowsableUrl(startUrl)
|
||||
.then((target) => { if (active) setInitialSrc(target); })
|
||||
.catch((error: unknown) => {
|
||||
if (!active) return;
|
||||
if (error instanceof DevTunnelUnavailableError) {
|
||||
// The view still needs a src or the panel stays blank forever; it
|
||||
// gets a blank one, with the failure stated over it.
|
||||
setTunnelFailedUrl(startUrl);
|
||||
setInitialSrc(BLANK_URL);
|
||||
return;
|
||||
}
|
||||
setInitialSrc(startUrl);
|
||||
});
|
||||
return () => { active = false; };
|
||||
// Only ever the initial navigation; later changes come from the user.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const annotationHost = React.useMemo<AnnotationHost>(() => ({
|
||||
executeJavaScript: async (code: string, userGesture?: boolean) => {
|
||||
const webview = webviewRef.current;
|
||||
if (!webview) throw new Error('Browser view is not available');
|
||||
return webview.executeJavaScript(code, userGesture);
|
||||
},
|
||||
capturePage: async (): Promise<PageCapture | null> => {
|
||||
const webview = webviewRef.current;
|
||||
if (!webview) return null;
|
||||
const webContentsId = webview.getWebContentsId();
|
||||
if (!Number.isFinite(webContentsId)) return null;
|
||||
return await invokeDesktopCommand<PageCapture>('desktop_browser_capture_page', { webContentsId });
|
||||
},
|
||||
}), []);
|
||||
|
||||
const handleAnnotate = React.useCallback(() => {
|
||||
if (isAnnotating) {
|
||||
setIsAnnotating(false);
|
||||
void cancelAnnotationSession(annotationHost);
|
||||
return;
|
||||
}
|
||||
if (!navigation.url) {
|
||||
toast.error(t('contextPanel.browser.annotate.noPage'));
|
||||
return;
|
||||
}
|
||||
|
||||
const theme = resolveAnnotationOverlayTheme(
|
||||
currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
|
||||
);
|
||||
|
||||
setIsAnnotating(true);
|
||||
void runAnnotationSession({
|
||||
host: annotationHost,
|
||||
theme,
|
||||
labels: overlayLabels,
|
||||
})
|
||||
.then(async (result) => {
|
||||
setIsAnnotating(false);
|
||||
if (!result) return;
|
||||
await attachAnnotation(result);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsAnnotating(false);
|
||||
toast.error(t('contextPanel.browser.annotate.failed'));
|
||||
});
|
||||
}, [annotationHost, attachAnnotation, currentTheme, isAnnotating, navigation.url, overlayLabels, t]);
|
||||
|
||||
// Escape leaves annotation mode from the app side too: the overlay owns the
|
||||
// in-page Escape, but the panel can be focused instead.
|
||||
React.useEffect(() => {
|
||||
if (!isAnnotating) return;
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setIsAnnotating(false);
|
||||
void cancelAnnotationSession(annotationHost);
|
||||
};
|
||||
window.addEventListener('keydown', handler, true);
|
||||
return () => window.removeEventListener('keydown', handler, true);
|
||||
}, [annotationHost, isAnnotating]);
|
||||
|
||||
// Agent-driven actions. Waiting for the page to settle after a navigation is
|
||||
// deliberate: a snapshot taken mid-load describes a page that no longer
|
||||
// exists by the time the agent reads it.
|
||||
const waitForIdle = React.useCallback(async (timeoutMs = 8_000): Promise<boolean> => {
|
||||
const startedAt = Date.now();
|
||||
for (;;) {
|
||||
const webview = webviewRef.current;
|
||||
if (!webview) return false;
|
||||
let busy = false;
|
||||
try {
|
||||
busy = webview.isLoading();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!busy) return true;
|
||||
// A page with a looping video or a long-lived stream can report loading
|
||||
// indefinitely. Give up waiting and act on it anyway rather than letting
|
||||
// the whole action expire.
|
||||
if (Date.now() - startedAt > timeoutMs) return false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runControlAction = React.useCallback(async (
|
||||
action: string,
|
||||
parameters: Record<string, unknown>,
|
||||
): Promise<unknown> => {
|
||||
const webview = webviewRef.current;
|
||||
if (!webview) throw new Error('The browser panel is not ready');
|
||||
|
||||
// Showing the bar when the agent sizes the page keeps the change visible:
|
||||
// the user should see which layout is being looked at, not just that it
|
||||
// suddenly narrowed.
|
||||
const applyViewportParameter = (): void => {
|
||||
if (!isViewportMode(parameters.viewport)) return;
|
||||
setViewport(viewportForMode(parameters.viewport));
|
||||
setShowDeviceBar(true);
|
||||
};
|
||||
|
||||
if (action === 'browser.back' || action === 'browser.forward') {
|
||||
const goingBack = action === 'browser.back';
|
||||
const canMove = goingBack ? webview.canGoBack() : webview.canGoForward();
|
||||
if (!canMove) {
|
||||
throw new Error(goingBack
|
||||
? 'There is nothing to go back to in this tab'
|
||||
: 'There is nothing to go forward to in this tab');
|
||||
}
|
||||
if (goingBack) webview.goBack();
|
||||
else webview.goForward();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
await waitForIdle();
|
||||
let title = '';
|
||||
try { title = webview.getTitle() || ''; } catch { title = ''; }
|
||||
return { url: toDisplayUrl(webview.getURL()), title };
|
||||
}
|
||||
|
||||
if (action === 'browser.capture') {
|
||||
// A user may close the panel after browser.open. Chromium then removes
|
||||
// the zero-width webview's composited surface and capturePage() fails
|
||||
// with UnknownVizError. Reveal this existing browser tab again and let
|
||||
// the layout paint before asking Electron for the image.
|
||||
useUIStore.getState().openContextBrowser(directory, webview.getURL());
|
||||
const surfaceDeadline = Date.now() + 1_200;
|
||||
let previousWidth = 0;
|
||||
let stableSamples = 0;
|
||||
while (stableSamples < 2 && Date.now() < surfaceDeadline) {
|
||||
const width = webview.getBoundingClientRect().width;
|
||||
stableSamples = width >= 2 && Math.abs(width - previousWidth) < 0.5
|
||||
? stableSamples + 1
|
||||
: 0;
|
||||
previousWidth = width;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
// Wait for a settled page first: a screenshot of a half-painted layout is
|
||||
// worse than none, because it looks like a finished one.
|
||||
await waitForIdle();
|
||||
const capture = await annotationHost.capturePage();
|
||||
if (!capture) throw new Error('The page could not be captured');
|
||||
let title = '';
|
||||
try { title = webview.getTitle() || ''; } catch { title = ''; }
|
||||
return {
|
||||
...capture,
|
||||
url: toDisplayUrl(webview.getURL()),
|
||||
title,
|
||||
viewport: viewportSummary(viewportRef.current),
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'browser.resize') {
|
||||
if (!isViewportMode(parameters.viewport)) throw new Error('viewport is required');
|
||||
applyViewportParameter();
|
||||
// Let the resize land before reporting it, so a snapshot that follows
|
||||
// describes the new layout rather than the old one.
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await waitForIdle();
|
||||
return { viewport: viewportSummary(viewportForMode(parameters.viewport)) };
|
||||
}
|
||||
|
||||
if (action === 'browser.open') {
|
||||
const url = typeof parameters.url === 'string' ? parameters.url : '';
|
||||
if (!url) throw new Error('url is required');
|
||||
applyViewportParameter();
|
||||
loadUrl(url);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const settled = await waitForIdle(25_000);
|
||||
let title = '';
|
||||
try {
|
||||
title = webview.getTitle() || '';
|
||||
} catch {
|
||||
title = '';
|
||||
}
|
||||
// `settled: false` means the page is still fetching, not that opening
|
||||
// failed — the agent can snapshot it and decide for itself.
|
||||
return {
|
||||
url: normalizeBrowserUrl(url),
|
||||
title,
|
||||
opened: true,
|
||||
settled,
|
||||
viewport: viewportSummary(viewportRef.current),
|
||||
};
|
||||
}
|
||||
|
||||
await waitForIdle();
|
||||
|
||||
const asOptionalString = (value: unknown): string | undefined => (
|
||||
typeof value === 'string' && value ? value : undefined
|
||||
);
|
||||
|
||||
const buildScript = (): string | null => {
|
||||
switch (action) {
|
||||
case 'browser.snapshot':
|
||||
return buildSnapshotScript({ selector: asOptionalString(parameters.selector) });
|
||||
case 'browser.click':
|
||||
return buildClickScript({
|
||||
selector: asOptionalString(parameters.selector),
|
||||
text: asOptionalString(parameters.text),
|
||||
});
|
||||
case 'browser.type':
|
||||
return buildTypeScript({
|
||||
selector: String(parameters.selector ?? ''),
|
||||
value: String(parameters.value ?? ''),
|
||||
submit: parameters.submit === true,
|
||||
});
|
||||
case 'browser.inspect':
|
||||
return buildInspectScript({ selector: String(parameters.selector ?? '') });
|
||||
case 'browser.scroll':
|
||||
return buildScrollScript({
|
||||
selector: asOptionalString(parameters.selector),
|
||||
direction: asOptionalString(parameters.direction),
|
||||
});
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const script = buildScript();
|
||||
|
||||
if (!script) throw new Error(`Unsupported browser action: ${action}`);
|
||||
|
||||
const result = await webview.executeJavaScript(script, true);
|
||||
if (!result || typeof result !== 'object') {
|
||||
throw new Error('The page returned no result');
|
||||
}
|
||||
const record = result as Record<string, unknown>;
|
||||
if (record.ok !== true) {
|
||||
throw new Error(typeof record.error === 'string' && record.error ? record.error : 'Browser action failed');
|
||||
}
|
||||
// A snapshot has to say which layout it describes, or the agent cannot tell
|
||||
// a mobile rendering from a desktop one.
|
||||
if (action === 'browser.snapshot') {
|
||||
const problems = consoleProblemsRef.current;
|
||||
return {
|
||||
...record,
|
||||
viewport: viewportSummary(viewportRef.current),
|
||||
...(problems.length > 0 ? { consoleProblems: [...problems] } : {}),
|
||||
};
|
||||
}
|
||||
// A click or a submit commonly starts a navigation; let it land so the
|
||||
// agent's next snapshot sees the page the action produced.
|
||||
if (action === 'browser.click' || (action === 'browser.type' && parameters.submit === true)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
await waitForIdle();
|
||||
}
|
||||
return result;
|
||||
}, [annotationHost, directory, loadUrl, waitForIdle]);
|
||||
|
||||
React.useEffect(
|
||||
() => registerBrowserController({ run: runControlAction }),
|
||||
[runControlAction],
|
||||
);
|
||||
|
||||
// Leaving the tab must not strand an overlay or live style overrides on the page.
|
||||
React.useEffect(() => {
|
||||
const host = annotationHost;
|
||||
return () => { void cancelAnnotationSession(host); };
|
||||
}, [annotationHost]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!webviewElement) return;
|
||||
const onConsoleMessage = (event: Event) => {
|
||||
const detail = event as unknown as { level?: number; message?: string; sourceId?: string; line?: number };
|
||||
// 2 is warning, 3 is error; anything quieter is the page talking to itself.
|
||||
if (typeof detail.level !== 'number' || detail.level < 2) return;
|
||||
const source = detail.sourceId ? `${detail.sourceId}${detail.line ? `:${detail.line}` : ''}` : '';
|
||||
consoleProblemsRef.current.push({
|
||||
level: detail.level >= 3 ? 'error' : 'warning',
|
||||
message: String(detail.message ?? '').slice(0, 400),
|
||||
source,
|
||||
});
|
||||
if (consoleProblemsRef.current.length > CONSOLE_PROBLEM_LIMIT) {
|
||||
consoleProblemsRef.current.splice(0, consoleProblemsRef.current.length - CONSOLE_PROBLEM_LIMIT);
|
||||
}
|
||||
};
|
||||
// Each page gets its own record; carrying the last one over would blame a
|
||||
// new page for the previous page's failures.
|
||||
const onStartLoading = () => { consoleProblemsRef.current = []; };
|
||||
|
||||
webviewElement.addEventListener('console-message', onConsoleMessage);
|
||||
webviewElement.addEventListener('did-start-loading', onStartLoading);
|
||||
return () => {
|
||||
webviewElement.removeEventListener('console-message', onConsoleMessage);
|
||||
webviewElement.removeEventListener('did-start-loading', onStartLoading);
|
||||
};
|
||||
}, [webviewElement]);
|
||||
|
||||
/**
|
||||
* Keeps loopback navigations on the machine the page came from.
|
||||
*
|
||||
* A tunnelled page can send the view to another local port — a docs server
|
||||
* behind a dev gateway, an API on its own port. That navigation happens
|
||||
* inside the view, so nothing resolved it, and it would be looked for on this
|
||||
* machine instead of the host.
|
||||
*
|
||||
* A link or a script navigation is caught before it happens. A server
|
||||
* redirect cannot be: by the time the view reports it, it is already loading.
|
||||
* That one is recovered from its failure instead, once per address, so a port
|
||||
* that genuinely is not there still fails honestly.
|
||||
*/
|
||||
const retunneledUrlsRef = React.useRef(new Set<string>());
|
||||
// Asking for an address again is a fresh request, so the recovery budget
|
||||
// comes back with it. The automatic retry deliberately does not reset it.
|
||||
const loadUrlFromUser = React.useCallback((value: string) => {
|
||||
retunneledUrlsRef.current.clear();
|
||||
loadUrl(value);
|
||||
}, [loadUrl]);
|
||||
React.useEffect(() => {
|
||||
if (!webviewElement) return;
|
||||
|
||||
const onWillNavigate = (event: Event) => {
|
||||
const detail = readEventPayload<{ url?: string }>(event);
|
||||
const target = typeof detail.url === 'string' ? detail.url : '';
|
||||
if (!target || !shouldTunnelLoopbackUrl(target)) return;
|
||||
event.preventDefault();
|
||||
loadUrl(target);
|
||||
};
|
||||
|
||||
const onFailLoad = (event: Event) => {
|
||||
const detail = readEventPayload<{
|
||||
errorCode?: number;
|
||||
validatedURL?: string;
|
||||
isMainFrame?: boolean;
|
||||
}>(event);
|
||||
if (detail.isMainFrame === false) return;
|
||||
// Superseded navigations are not failures.
|
||||
if (detail.errorCode === -3) return;
|
||||
const target = typeof detail.validatedURL === 'string' ? detail.validatedURL : '';
|
||||
if (!target || !shouldTunnelLoopbackUrl(target)) return;
|
||||
if (retunneledUrlsRef.current.has(target)) return;
|
||||
retunneledUrlsRef.current.add(target);
|
||||
loadUrl(target);
|
||||
};
|
||||
|
||||
webviewElement.addEventListener('will-navigate', onWillNavigate);
|
||||
webviewElement.addEventListener('did-fail-load', onFailLoad);
|
||||
return () => {
|
||||
webviewElement.removeEventListener('will-navigate', onWillNavigate);
|
||||
webviewElement.removeEventListener('did-fail-load', onFailLoad);
|
||||
};
|
||||
}, [loadUrl, webviewElement]);
|
||||
|
||||
// Popups open in place; a detached window would escape the panel entirely.
|
||||
React.useEffect(() => {
|
||||
if (!webviewElement) return;
|
||||
const onNewWindow = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ url?: string }>).detail;
|
||||
event.preventDefault();
|
||||
if (detail?.url) loadUrl(detail.url);
|
||||
};
|
||||
webviewElement.addEventListener('new-window', onNewWindow);
|
||||
return () => webviewElement.removeEventListener('new-window', onNewWindow);
|
||||
}, [loadUrl, webviewElement]);
|
||||
|
||||
const applyZoom = React.useCallback((level: number) => {
|
||||
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level));
|
||||
setZoomLevel(next);
|
||||
try {
|
||||
webviewRef.current?.setZoomLevel(next);
|
||||
} catch {
|
||||
// Not attached yet; the next change applies it.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearBrowsingData = React.useCallback((what: 'cookies' | 'cache') => {
|
||||
void invokeDesktopCommand('desktop_browser_clear_data', {
|
||||
partition: BROWSER_PARTITION,
|
||||
cookies: what === 'cookies',
|
||||
cache: what === 'cache',
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t(what === 'cookies'
|
||||
? 'contextPanel.browser.clearedCookies'
|
||||
: 'contextPanel.browser.clearedCache'));
|
||||
// Cleared storage only shows in a page that reloads without it.
|
||||
try { webviewRef.current?.reloadIgnoringCache(); } catch { /* not attached */ }
|
||||
})
|
||||
.catch(() => toast.error(t('contextPanel.browser.clearFailed')));
|
||||
}, [t]);
|
||||
|
||||
// The stage is measured rather than assumed: the panel is resizable, and a
|
||||
// viewport that fitted a moment ago may not fit now.
|
||||
React.useEffect(() => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const rect = entries[0]?.contentRect;
|
||||
if (rect) setStageSize({ width: rect.width, height: rect.height });
|
||||
});
|
||||
observer.observe(stage);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const applyColorScheme = React.useCallback((scheme: BrowserColorScheme) => {
|
||||
setColorScheme(scheme);
|
||||
const webview = webviewRef.current;
|
||||
if (!webview) return;
|
||||
let webContentsId = -1;
|
||||
try {
|
||||
webContentsId = webview.getWebContentsId();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void invokeDesktopCommand('desktop_browser_set_color_scheme', { webContentsId, scheme })
|
||||
.catch((error: unknown) => {
|
||||
setColorScheme('system');
|
||||
toast.error(error instanceof Error ? error.message : t('contextPanel.browser.device.schemeFailed'));
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
const handleReload = React.useCallback(() => {
|
||||
try {
|
||||
if (isLoading) webviewRef.current?.stop();
|
||||
else webviewRef.current?.reload();
|
||||
} catch {
|
||||
// Not attached yet.
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
// A page opened the moment its dev server launched is not ready twice over:
|
||||
// first nothing is listening at all, then a gateway answers while the app
|
||||
// behind it is still starting. Neither is an error the user can act on, and
|
||||
// both used to leave them pressing reload. Both are waited out here.
|
||||
const status = navigation.status;
|
||||
React.useEffect(() => {
|
||||
const reloadSoon = (): (() => void) => {
|
||||
setIsWaitingForServer(true);
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
webviewRef.current?.reload();
|
||||
} catch {
|
||||
// View went away; the next mount starts over.
|
||||
}
|
||||
}, DEV_SERVER_RETRY_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
};
|
||||
|
||||
// Nothing is listening yet.
|
||||
if (status.kind === 'failed') {
|
||||
if (!isStartingServerFailure(status.code, status.url)) {
|
||||
setIsWaitingForServer(false);
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const run = retryRef.current?.url === status.url
|
||||
? retryRef.current
|
||||
: { url: status.url, startedAt: now };
|
||||
retryRef.current = run;
|
||||
if (now - run.startedAt > DEV_SERVER_WAIT_MS) {
|
||||
setIsWaitingForServer(false);
|
||||
return;
|
||||
}
|
||||
return reloadSoon();
|
||||
}
|
||||
|
||||
// Mid-navigation: leave whatever state the previous decision set, so a
|
||||
// retry does not flash the page behind the waiting screen and back.
|
||||
if (status.kind === 'loading') return;
|
||||
|
||||
retryRef.current = null;
|
||||
|
||||
if (status.kind !== 'ready' || !status.url || !isLoopbackUrl(status.url)) {
|
||||
servedOkRef.current = true;
|
||||
setIsWaitingForServer(false);
|
||||
return;
|
||||
}
|
||||
if (servedOkRef.current || Date.now() - openedAtRef.current > GATEWAY_WAIT_MS) {
|
||||
servedOkRef.current = true;
|
||||
setIsWaitingForServer(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// The page loaded, but a 5xx here means the server answered on behalf of an
|
||||
// app that is not up yet. Checked by status rather than by reading the page:
|
||||
// guessing from its contents would mean encoding what each dev server's
|
||||
// error page looks like, which is exactly the trap this panel came out of.
|
||||
let cancelled = false;
|
||||
let cancelReload: (() => void) | null = null;
|
||||
// Probe the address on the host, not the local tunnel port: the check runs
|
||||
// on the server, where our ephemeral port means nothing. Asking about it
|
||||
// failed every time, which read as "settled" and left the page on the error
|
||||
// until a manual reload.
|
||||
void probeLoopbackStatus(toDisplayUrl(status.url)).then((httpStatus) => {
|
||||
if (cancelled) return;
|
||||
if (httpStatus === null || httpStatus < 500) {
|
||||
servedOkRef.current = true;
|
||||
setIsWaitingForServer(false);
|
||||
return;
|
||||
}
|
||||
cancelReload = reloadSoon();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelReload?.();
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
const failed = navigation.status.kind === 'failed' && !isWaitingForServer ? navigation.status : null;
|
||||
const layout = fitViewport(viewport, stageSize);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col bg-background">
|
||||
<BrowserToolbar
|
||||
address={address}
|
||||
onAddressChange={setAddress}
|
||||
onSubmit={loadUrlFromUser}
|
||||
suggestions={suggestions}
|
||||
onForgetSuggestion={(url) => forgetHistoryVisit(directory, url)}
|
||||
onBack={() => { try { webviewRef.current?.goBack(); } catch { /* not attached */ } }}
|
||||
onForward={() => { try { webviewRef.current?.goForward(); } catch { /* not attached */ } }}
|
||||
onReload={handleReload}
|
||||
onOpenExternal={() => void openExternalUrl(navigation.url || address)}
|
||||
canGoBack={navigation.canGoBack}
|
||||
canGoForward={navigation.canGoForward}
|
||||
isLoading={isLoading}
|
||||
onAnnotate={handleAnnotate}
|
||||
isAnnotating={isAnnotating}
|
||||
onOpenDevTools={() => { try { webviewRef.current?.openDevTools(); } catch { /* not attached */ } }}
|
||||
onHardReload={() => { try { webviewRef.current?.reloadIgnoringCache(); } catch { /* not attached */ } }}
|
||||
onZoomIn={() => applyZoom(zoomLevel + ZOOM_STEP)}
|
||||
onZoomOut={() => applyZoom(zoomLevel - ZOOM_STEP)}
|
||||
onZoomReset={() => applyZoom(0)}
|
||||
zoomPercent={Math.round(Math.pow(1.2, zoomLevel) * 100)}
|
||||
onClearCookies={() => clearBrowsingData('cookies')}
|
||||
onClearCache={() => clearBrowsingData('cache')}
|
||||
onToggleDeviceBar={() => setShowDeviceBar((current) => !current)}
|
||||
isDeviceBarOpen={showDeviceBar}
|
||||
/>
|
||||
{showDeviceBar ? (
|
||||
<BrowserDeviceBar
|
||||
viewport={viewport}
|
||||
onViewportChange={setViewport}
|
||||
colorScheme={colorScheme}
|
||||
onColorSchemeChange={applyColorScheme}
|
||||
scale={layout?.scale ?? 1}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
ref={stageRef}
|
||||
className={cn(
|
||||
'relative min-h-0 flex-1 bg-background',
|
||||
// A sized viewport sits on a backdrop so its edges are visible; at
|
||||
// fill there is nothing to frame.
|
||||
layout && 'flex items-center justify-center overflow-hidden bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
{initialSrc !== null ? (
|
||||
<webview
|
||||
ref={attachWebview}
|
||||
src={initialSrc}
|
||||
partition="persist:openchamber-browser"
|
||||
allowpopups
|
||||
style={layout
|
||||
? {
|
||||
// Laid out at the chosen size and scaled visually: the page must
|
||||
// measure itself at the width being tested, not at the panel's.
|
||||
width: `${layout.width}px`,
|
||||
height: `${layout.height}px`,
|
||||
transform: `scale(${layout.scale})`,
|
||||
border: 'none',
|
||||
flex: 'none',
|
||||
boxShadow: '0 2px 18px rgba(0,0,0,.28)',
|
||||
}
|
||||
: { width: '100%', height: '100%', border: 'none' }}
|
||||
/>
|
||||
) : null}
|
||||
{initialSrc !== null && !startUrl && !navigation.url && !isLoading ? (
|
||||
<BrowserEmptyState onOpen={loadUrlFromUser} directory={directory} />
|
||||
) : null}
|
||||
{isWaitingForServer ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background p-6 text-center">
|
||||
<span className="typography-ui-header text-foreground">{t('contextPanel.browser.waitingForServer')}</span>
|
||||
<span className="typography-micro text-muted-foreground">{t('contextPanel.browser.waitingForServerHint')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{tunnelFailedUrl ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background p-6 text-center">
|
||||
<span className="typography-ui-header text-foreground">{t('contextPanel.browser.tunnelFailed')}</span>
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{t('contextPanel.browser.tunnelFailedHint', { url: tunnelFailedUrl })}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{failed && !tunnelFailedUrl ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background p-6 text-center">
|
||||
<span className="typography-ui-header text-foreground">
|
||||
{failed.crashed ? t('contextPanel.browser.crashed') : t('contextPanel.browser.loadFailed')}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{failed.crashed
|
||||
? t('contextPanel.browser.crashedHint')
|
||||
: failed.description || t('contextPanel.browser.loadFailedUnknown')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-0.5 overflow-hidden">
|
||||
<div className="h-full w-1/3 animate-[browser-progress_1.1s_ease-in-out_infinite] bg-[var(--primary)]" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Non-Chromium runtimes get a plain iframe. Same-origin policy makes the page
|
||||
* opaque to us here: no navigation events, no annotation, no console. The
|
||||
* toolbar reflects that instead of offering controls that would silently fail.
|
||||
*/
|
||||
const IframeBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
|
||||
const { t } = useI18n();
|
||||
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
|
||||
const normalized = normalizeBrowserUrl(initialUrl);
|
||||
const startUrl = normalized !== BLANK_URL ? normalized : '';
|
||||
|
||||
const [address, setAddress] = React.useState(startUrl);
|
||||
const [loadedUrl, setLoadedUrl] = React.useState(startUrl);
|
||||
const [history, setHistory] = React.useState<string[]>(startUrl ? [startUrl] : []);
|
||||
const [historyIndex, setHistoryIndex] = React.useState(startUrl ? 0 : -1);
|
||||
const [reloadNonce, bumpReload] = React.useReducer((value: number) => value + 1, 0);
|
||||
|
||||
const persistUrl = React.useCallback((url: string) => {
|
||||
if (!url || url === BLANK_URL || !directory || !tabID) return;
|
||||
setContextPanelTabTargetPath(directory, tabID, url);
|
||||
}, [directory, tabID, setContextPanelTabTargetPath]);
|
||||
|
||||
const visitedAddresses = useBrowserHistoryStore(selectBrowserHistory(directory));
|
||||
const recordHistoryVisit = useBrowserHistoryStore((state) => state.recordVisit);
|
||||
const forgetHistoryVisit = useBrowserHistoryStore((state) => state.forget);
|
||||
const suggestions = React.useMemo(
|
||||
() => suggestFromHistory(visitedAddresses, address),
|
||||
[visitedAddresses, address],
|
||||
);
|
||||
|
||||
const navigate = React.useCallback((value: string) => {
|
||||
const next = normalizeBrowserUrl(value);
|
||||
if (next === BLANK_URL) return;
|
||||
setAddress(next);
|
||||
setLoadedUrl(next);
|
||||
persistUrl(next);
|
||||
// The page is opaque here, so there is no load event and no title to wait
|
||||
// for; what was asked for is the only thing this runtime can record.
|
||||
recordHistoryVisit(directory, { url: next });
|
||||
setHistory((current) => {
|
||||
const kept = historyIndex >= 0 ? current.slice(0, historyIndex + 1) : [];
|
||||
if (kept[kept.length - 1] === next) {
|
||||
setHistoryIndex(kept.length - 1);
|
||||
return kept;
|
||||
}
|
||||
setHistoryIndex(kept.length);
|
||||
return [...kept, next];
|
||||
});
|
||||
}, [directory, historyIndex, persistUrl, recordHistoryVisit]);
|
||||
|
||||
const goTo = React.useCallback((index: number) => {
|
||||
const next = history[index];
|
||||
if (!next) return;
|
||||
setHistoryIndex(index);
|
||||
setAddress(next);
|
||||
setLoadedUrl(next);
|
||||
persistUrl(next);
|
||||
}, [history, persistUrl]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col bg-background">
|
||||
<BrowserToolbar
|
||||
address={address}
|
||||
onAddressChange={setAddress}
|
||||
onSubmit={navigate}
|
||||
suggestions={suggestions}
|
||||
onForgetSuggestion={(url) => forgetHistoryVisit(directory, url)}
|
||||
onBack={() => goTo(historyIndex - 1)}
|
||||
onForward={() => goTo(historyIndex + 1)}
|
||||
onReload={bumpReload}
|
||||
onOpenExternal={() => void openExternalUrl(loadedUrl || address)}
|
||||
canGoBack={historyIndex > 0}
|
||||
canGoForward={historyIndex >= 0 && historyIndex < history.length - 1}
|
||||
isLoading={false}
|
||||
/>
|
||||
<div className="relative min-h-0 flex-1 bg-background">
|
||||
{loadedUrl ? (
|
||||
<iframe
|
||||
key={`${loadedUrl}|${reloadNonce}`}
|
||||
src={loadedUrl}
|
||||
title={t('contextPanel.browser.frameTitle')}
|
||||
className="h-full w-full border-none bg-white"
|
||||
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
|
||||
/>
|
||||
) : (
|
||||
<BrowserEmptyState onOpen={navigate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BrowserPane: React.FC<BrowserPaneProps> = (props) => {
|
||||
const [chromium] = React.useState(isChromiumHost);
|
||||
return chromium ? <WebviewBrowser {...props} /> : <IframeBrowser {...props} />;
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { BrowserAddressSuggestions } from './BrowserAddressSuggestions';
|
||||
import type { BrowserHistoryEntry } from '@/lib/browser/history';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
|
||||
type ToolbarButtonProps = {
|
||||
icon: IconName;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
pressed?: boolean;
|
||||
};
|
||||
|
||||
const ToolbarButton: React.FC<ToolbarButtonProps> = ({ icon, label, onClick, disabled, pressed }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={pressed ? 'secondary' : 'ghost'}
|
||||
size="xs"
|
||||
className={cn(
|
||||
'w-6 shrink-0 rounded-full px-0 text-muted-foreground',
|
||||
'hover:text-foreground',
|
||||
pressed && 'text-foreground',
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
>
|
||||
<Icon name={icon} className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
export type BrowserToolbarProps = {
|
||||
address: string;
|
||||
/** Addresses already visited in this project, offered while typing. */
|
||||
suggestions?: readonly BrowserHistoryEntry[];
|
||||
onForgetSuggestion?: (url: string) => void;
|
||||
onAddressChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onBack: () => void;
|
||||
onForward: () => void;
|
||||
onReload: () => void;
|
||||
onOpenExternal: () => void;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
isLoading: boolean;
|
||||
/** These need a real Chromium host; hidden without one. */
|
||||
onAnnotate?: () => void;
|
||||
onOpenDevTools?: () => void;
|
||||
isAnnotating?: boolean;
|
||||
onHardReload?: () => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
onZoomReset?: () => void;
|
||||
/** Whole percent, e.g. 110. Controls hide at 100 to keep the bar quiet. */
|
||||
zoomPercent?: number;
|
||||
onClearCookies?: () => void;
|
||||
onClearCache?: () => void;
|
||||
onToggleDeviceBar?: () => void;
|
||||
isDeviceBarOpen?: boolean;
|
||||
};
|
||||
|
||||
export const BrowserToolbar: React.FC<BrowserToolbarProps> = ({
|
||||
address,
|
||||
suggestions = [],
|
||||
onForgetSuggestion,
|
||||
onAddressChange,
|
||||
onSubmit,
|
||||
onBack,
|
||||
onForward,
|
||||
onReload,
|
||||
onOpenExternal,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
isLoading,
|
||||
onAnnotate,
|
||||
onOpenDevTools,
|
||||
isAnnotating,
|
||||
onHardReload,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onZoomReset,
|
||||
zoomPercent = 100,
|
||||
onClearCookies,
|
||||
onClearCache,
|
||||
onToggleDeviceBar,
|
||||
isDeviceBarOpen,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isAddressFocused, setIsAddressFocused] = React.useState(false);
|
||||
const [activeSuggestion, setActiveSuggestion] = React.useState(-1);
|
||||
const visibleSuggestions = isAddressFocused ? suggestions : [];
|
||||
|
||||
// A new list is a new choice; keeping an old index would highlight whatever
|
||||
// happens to sit in that position now.
|
||||
React.useEffect(() => {
|
||||
setActiveSuggestion(-1);
|
||||
}, [address, isAddressFocused]);
|
||||
|
||||
const submitAddress = (value: string) => {
|
||||
setIsAddressFocused(false);
|
||||
setActiveSuggestion(-1);
|
||||
onSubmit(value);
|
||||
};
|
||||
|
||||
const onAddressKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (visibleSuggestions.length === 0) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const step = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const count = visibleSuggestions.length;
|
||||
// Wraps through "nothing selected", so the typed address stays reachable.
|
||||
setActiveSuggestion((current) => {
|
||||
const next = current + step;
|
||||
if (next < -1) return count - 1;
|
||||
if (next >= count) return -1;
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && activeSuggestion >= 0) {
|
||||
event.preventDefault();
|
||||
setActiveSuggestion(-1);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && activeSuggestion >= 0) {
|
||||
event.preventDefault();
|
||||
const chosen = visibleSuggestions[activeSuggestion];
|
||||
if (chosen) submitAddress(chosen.url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 border-b border-border bg-[var(--surface-background)] px-2 py-1">
|
||||
<ToolbarButton icon="arrow-left" label={t('contextPanel.browser.back')} onClick={onBack} disabled={!canGoBack} />
|
||||
<ToolbarButton icon="arrow-right" label={t('contextPanel.browser.forward')} onClick={onForward} disabled={!canGoForward} />
|
||||
<ToolbarButton
|
||||
icon="refresh"
|
||||
label={isLoading ? t('contextPanel.browser.stop') : t('contextPanel.browser.reload')}
|
||||
onClick={onReload}
|
||||
/>
|
||||
{onHardReload ? (
|
||||
<ToolbarButton icon="restart" label={t('contextPanel.browser.hardReload')} onClick={onHardReload} />
|
||||
) : null}
|
||||
<form
|
||||
className="relative min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitAddress(address);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={address}
|
||||
onChange={(event) => onAddressChange(event.target.value)}
|
||||
onFocus={() => setIsAddressFocused(true)}
|
||||
onBlur={() => setIsAddressFocused(false)}
|
||||
onKeyDown={onAddressKeyDown}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={visibleSuggestions.length > 0}
|
||||
aria-controls="openchamber-browser-address-suggestions"
|
||||
className={cn(
|
||||
'h-6 w-full rounded-full border border-border/50 bg-[var(--surface-elevated)] px-3',
|
||||
'typography-micro text-foreground outline-none focus:border-[var(--interactive-focus-ring)]',
|
||||
)}
|
||||
aria-label={t('contextPanel.browser.addressAria')}
|
||||
/>
|
||||
<div id="openchamber-browser-address-suggestions">
|
||||
<BrowserAddressSuggestions
|
||||
entries={visibleSuggestions}
|
||||
activeIndex={activeSuggestion}
|
||||
onSelect={submitAddress}
|
||||
onForget={(url) => onForgetSuggestion?.(url)}
|
||||
onHighlight={setActiveSuggestion}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
{onZoomOut && onZoomIn ? (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<ToolbarButton icon="subtract" label={t('contextPanel.browser.zoomOut')} onClick={onZoomOut} />
|
||||
{zoomPercent !== 100 && onZoomReset ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="shrink-0 rounded-full px-1.5 typography-micro tabular-nums text-muted-foreground"
|
||||
onClick={onZoomReset}
|
||||
aria-label={t('contextPanel.browser.zoomReset')}
|
||||
>
|
||||
{zoomPercent}%
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('contextPanel.browser.zoomReset')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<ToolbarButton icon="add" label={t('contextPanel.browser.zoomIn')} onClick={onZoomIn} />
|
||||
</div>
|
||||
) : null}
|
||||
{onClearCookies ? (
|
||||
<ToolbarButton icon="delete-bin" label={t('contextPanel.browser.clearCookies')} onClick={onClearCookies} />
|
||||
) : null}
|
||||
{onClearCache ? (
|
||||
<ToolbarButton icon="database-2" label={t('contextPanel.browser.clearCache')} onClick={onClearCache} />
|
||||
) : null}
|
||||
{onToggleDeviceBar ? (
|
||||
<ToolbarButton
|
||||
icon="smartphone"
|
||||
label={t('contextPanel.browser.deviceToolbar')}
|
||||
onClick={onToggleDeviceBar}
|
||||
pressed={isDeviceBarOpen}
|
||||
/>
|
||||
) : null}
|
||||
{onAnnotate ? (
|
||||
<ToolbarButton
|
||||
icon="markup"
|
||||
label={t('contextPanel.browser.annotate.toggle')}
|
||||
onClick={onAnnotate}
|
||||
pressed={isAnnotating}
|
||||
/>
|
||||
) : null}
|
||||
{onOpenDevTools ? (
|
||||
<ToolbarButton icon="terminal-box" label={t('contextPanel.browser.devTools')} onClick={onOpenDevTools} />
|
||||
) : null}
|
||||
<ToolbarButton icon="external-link" label={t('contextPanel.browser.openExternal')} onClick={onOpenExternal} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { formatBrowserAnnotationPrompt } from '@/lib/browser/annotationPrompt';
|
||||
import type { AnnotationSessionResult } from '@/lib/browser/annotationSession';
|
||||
import type { BrowserAnnotationOverlayLabels } from '@/lib/browser/annotationOverlay';
|
||||
|
||||
/**
|
||||
* Attaches a finished annotation to the active composer.
|
||||
*
|
||||
* The screenshot is attached before the text so that a failed image upload
|
||||
* cannot leave a prompt claiming an attachment that never arrived — the text
|
||||
* states what actually happened.
|
||||
*/
|
||||
export const useAnnotationAttach = (directory: string) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||
const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
|
||||
|
||||
return React.useCallback(async (result: AnnotationSessionResult): Promise<void> => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
if (!sessionKey) {
|
||||
toast.error(t('contextPanel.browser.annotate.noSession'));
|
||||
return;
|
||||
}
|
||||
|
||||
let screenshotAttached = false;
|
||||
if (result.screenshot) {
|
||||
try {
|
||||
await addAttachedFile(result.screenshot);
|
||||
screenshotAttached = true;
|
||||
} catch {
|
||||
screenshotAttached = false;
|
||||
}
|
||||
}
|
||||
|
||||
addInlineCommentDraft({ directory, sessionKey }, {
|
||||
source: 'preview-annotation',
|
||||
fileLabel: result.payload.pageUrl || 'browser',
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
code: formatBrowserAnnotationPrompt({
|
||||
payload: result.payload,
|
||||
screenshotAttached,
|
||||
intro: t('contextPanel.browser.annotate.intro'),
|
||||
}),
|
||||
language: 'markdown',
|
||||
text: '',
|
||||
});
|
||||
|
||||
toast.success(t('contextPanel.browser.annotate.attached'));
|
||||
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, directory, newSessionDraftOpen, t]);
|
||||
};
|
||||
|
||||
/** Overlay labels, resolved through i18n so the in-page UI follows the app locale. */
|
||||
export const useAnnotationOverlayLabels = (): BrowserAnnotationOverlayLabels => {
|
||||
const { t } = useI18n();
|
||||
return React.useMemo(() => ({
|
||||
select: t('contextPanel.browser.annotate.tool.element'),
|
||||
marquee: t('contextPanel.browser.annotate.tool.region'),
|
||||
draw: t('contextPanel.browser.annotate.tool.draw'),
|
||||
commentPlaceholder: t('contextPanel.browser.annotate.commentPlaceholder'),
|
||||
submit: t('contextPanel.browser.annotate.submit'),
|
||||
}), [t]);
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import React from 'react';
|
||||
|
||||
import { IDLE_NAV_STATUS, type BrowserNavStatus } from '@/lib/browser/contract';
|
||||
import { useBrowserFaviconStore } from '@/stores/useBrowserFaviconStore';
|
||||
import {
|
||||
INITIAL_CRASH_RECOVERY_STATE,
|
||||
planCrashRecovery,
|
||||
type CrashRecoveryState,
|
||||
} from '@/lib/browser/crashRecovery';
|
||||
|
||||
/**
|
||||
* Translates `<webview>` lifecycle events into a single navigation status.
|
||||
*
|
||||
* Chromium reports failures and successes through separate events that can
|
||||
* arrive in either order, and it emits `did-fail-load` for sub-resources as
|
||||
* well as for the main frame. Both are handled here so the panel only ever
|
||||
* sees one authoritative state:
|
||||
*
|
||||
* - Sub-frame failures are ignored; only the main frame changes the status.
|
||||
* - `ERR_ABORTED` is not a failure. It is what Chromium reports when a
|
||||
* navigation is superseded by the next one, and treating it as an error puts
|
||||
* an error screen over a page that is loading perfectly well.
|
||||
*
|
||||
* Takes the element rather than a ref so the listeners attach when the view
|
||||
* actually appears. A ref is stable, so an effect keyed on it runs once — and
|
||||
* silently attaches nothing at all when the view mounts a render later.
|
||||
*
|
||||
* `<webview>` puts its event payload directly on the event object rather than
|
||||
* under `detail`, so reading `detail` yields a failure with no code and no
|
||||
* description — an error screen that says nothing. Both shapes are read here.
|
||||
*
|
||||
* A lost renderer is handled here too. It is reported by neither of the above:
|
||||
* the page simply stops existing, and the panel would otherwise stay blank with
|
||||
* no indication that anything happened.
|
||||
*/
|
||||
|
||||
/** Chromium's code for "this navigation was replaced by another one". */
|
||||
const ERR_ABORTED = -3;
|
||||
|
||||
/**
|
||||
* `about:blank` is the view's resting state, not somewhere the user went. It
|
||||
* arrives through `did-navigate` like any other address, and taking it at face
|
||||
* value puts it in the address bar and makes the panel look like it is showing
|
||||
* a page — which hides the empty state that would otherwise offer somewhere to
|
||||
* go.
|
||||
*/
|
||||
const isRealPageUrl = (url: unknown): url is string => (
|
||||
typeof url === 'string' && url.length > 0 && url !== 'about:blank'
|
||||
);
|
||||
|
||||
type FailLoadDetail = {
|
||||
errorCode?: number;
|
||||
errorDescription?: string;
|
||||
validatedURL?: string;
|
||||
isMainFrame?: boolean;
|
||||
};
|
||||
|
||||
/** Reads a webview event payload, whichever shape this Electron version uses. */
|
||||
export const readEventPayload = <T extends object>(event: Event): Partial<T> => {
|
||||
const record = event as unknown as { detail?: unknown };
|
||||
if (record.detail && typeof record.detail === 'object') return record.detail as Partial<T>;
|
||||
return event as unknown as Partial<T>;
|
||||
};
|
||||
|
||||
export type WebviewNavigation = {
|
||||
readonly status: BrowserNavStatus;
|
||||
readonly url: string;
|
||||
readonly title: string;
|
||||
readonly canGoBack: boolean;
|
||||
readonly canGoForward: boolean;
|
||||
};
|
||||
|
||||
export const useWebviewNavigation = (
|
||||
webview: WebviewElement | null,
|
||||
{ initialUrl, onUrlChange }: { initialUrl: string; onUrlChange: (url: string) => void },
|
||||
): WebviewNavigation => {
|
||||
const [status, setStatus] = React.useState<BrowserNavStatus>(
|
||||
initialUrl ? { kind: 'loading', url: initialUrl } : IDLE_NAV_STATUS,
|
||||
);
|
||||
const [url, setUrl] = React.useState(initialUrl);
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [canGoBack, setCanGoBack] = React.useState(false);
|
||||
const [canGoForward, setCanGoForward] = React.useState(false);
|
||||
|
||||
const urlChangeRef = React.useRef(onUrlChange);
|
||||
urlChangeRef.current = onUrlChange;
|
||||
|
||||
// Survives re-attaches so a view that keeps crashing cannot restart its own
|
||||
// budget by being remounted.
|
||||
const crashStateRef = React.useRef<CrashRecoveryState>(INITIAL_CRASH_RECOVERY_STATE);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!webview) return;
|
||||
|
||||
const readCurrentUrl = (): string => {
|
||||
try {
|
||||
const value = webview.getURL();
|
||||
return isRealPageUrl(value) ? value : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const syncHistory = () => {
|
||||
try {
|
||||
setCanGoBack(webview.canGoBack());
|
||||
setCanGoForward(webview.canGoForward());
|
||||
} catch {
|
||||
// Webview not attached yet; the next event resyncs.
|
||||
}
|
||||
};
|
||||
|
||||
const commitUrl = (next: string) => {
|
||||
if (!isRealPageUrl(next)) return;
|
||||
setUrl(next);
|
||||
urlChangeRef.current(next);
|
||||
};
|
||||
|
||||
const onStartLoading = () => {
|
||||
const current = readCurrentUrl();
|
||||
setStatus({ kind: 'loading', url: current });
|
||||
};
|
||||
|
||||
const onStopLoading = () => {
|
||||
const current = readCurrentUrl();
|
||||
let pageTitle = '';
|
||||
try {
|
||||
pageTitle = webview.getTitle() || '';
|
||||
} catch {
|
||||
pageTitle = '';
|
||||
}
|
||||
setTitle(pageTitle);
|
||||
commitUrl(current);
|
||||
syncHistory();
|
||||
// A failure already produced a terminal status; do not overwrite it with
|
||||
// the `did-stop-loading` that always follows.
|
||||
setStatus((previous) => (
|
||||
previous.kind === 'failed' && previous.url === current
|
||||
? previous
|
||||
: { kind: 'ready', url: current, title: pageTitle }
|
||||
));
|
||||
};
|
||||
|
||||
const onNavigate = (event: Event) => {
|
||||
const detail = readEventPayload<{ url?: string }>(event);
|
||||
if (isRealPageUrl(detail.url)) {
|
||||
commitUrl(detail.url);
|
||||
syncHistory();
|
||||
}
|
||||
};
|
||||
|
||||
const onFaviconUpdated = (event: Event) => {
|
||||
const detail = readEventPayload<{ favicons?: string[] }>(event);
|
||||
const icon = Array.isArray(detail.favicons) ? detail.favicons.find(Boolean) : '';
|
||||
const page = readCurrentUrl();
|
||||
if (icon && page) useBrowserFaviconStore.getState().resolve(page, icon);
|
||||
};
|
||||
|
||||
const onTitleUpdated = (event: Event) => {
|
||||
const detail = readEventPayload<{ title?: string }>(event);
|
||||
if (typeof detail.title === 'string') setTitle(detail.title);
|
||||
};
|
||||
|
||||
const onFailLoad = (event: Event) => {
|
||||
const detail = readEventPayload<FailLoadDetail>(event);
|
||||
if (detail.isMainFrame === false) return;
|
||||
const code = typeof detail.errorCode === 'number' ? detail.errorCode : 0;
|
||||
if (code === ERR_ABORTED) return;
|
||||
setStatus({
|
||||
kind: 'failed',
|
||||
url: isRealPageUrl(detail.validatedURL) ? detail.validatedURL : readCurrentUrl(),
|
||||
code,
|
||||
description: typeof detail.errorDescription === 'string' ? detail.errorDescription : '',
|
||||
});
|
||||
};
|
||||
|
||||
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const onCrashed = () => {
|
||||
const target = readCurrentUrl();
|
||||
const plan = planCrashRecovery(crashStateRef.current, Date.now());
|
||||
if (!plan) {
|
||||
// Out of attempts: say what happened rather than reload again. The
|
||||
// toolbar's own reload stays available, which is the user's call.
|
||||
setStatus({ kind: 'failed', url: target, code: 0, description: '', crashed: true });
|
||||
return;
|
||||
}
|
||||
crashStateRef.current = plan.state;
|
||||
setStatus({ kind: 'loading', url: target });
|
||||
recoveryTimer = setTimeout(() => {
|
||||
recoveryTimer = null;
|
||||
try {
|
||||
webview.reload();
|
||||
} catch {
|
||||
setStatus({ kind: 'failed', url: target, code: 0, description: '', crashed: true });
|
||||
}
|
||||
}, plan.delayMs);
|
||||
};
|
||||
|
||||
webview.addEventListener('did-start-loading', onStartLoading);
|
||||
webview.addEventListener('did-stop-loading', onStopLoading);
|
||||
webview.addEventListener('did-navigate', onNavigate);
|
||||
webview.addEventListener('did-navigate-in-page', onNavigate);
|
||||
webview.addEventListener('page-title-updated', onTitleUpdated);
|
||||
webview.addEventListener('page-favicon-updated', onFaviconUpdated);
|
||||
webview.addEventListener('did-fail-load', onFailLoad);
|
||||
// Electron renamed this event; older builds still emit only the old name.
|
||||
webview.addEventListener('render-process-gone', onCrashed);
|
||||
webview.addEventListener('crashed', onCrashed);
|
||||
|
||||
// The webview may already be settled by the time this effect runs. Only
|
||||
// treat it as settled when a page is actually loaded: a freshly created
|
||||
// view reports "not loading" before its guest attaches, and settling on
|
||||
// that would declare an empty page ready and hide the real one behind an
|
||||
// empty state.
|
||||
try {
|
||||
if (!webview.isLoading() && readCurrentUrl()) onStopLoading();
|
||||
} catch {
|
||||
// Not attached yet.
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (recoveryTimer !== null) clearTimeout(recoveryTimer);
|
||||
webview.removeEventListener('render-process-gone', onCrashed);
|
||||
webview.removeEventListener('crashed', onCrashed);
|
||||
webview.removeEventListener('did-start-loading', onStartLoading);
|
||||
webview.removeEventListener('did-stop-loading', onStopLoading);
|
||||
webview.removeEventListener('did-navigate', onNavigate);
|
||||
webview.removeEventListener('did-navigate-in-page', onNavigate);
|
||||
webview.removeEventListener('page-title-updated', onTitleUpdated);
|
||||
webview.removeEventListener('page-favicon-updated', onFaviconUpdated);
|
||||
webview.removeEventListener('did-fail-load', onFailLoad);
|
||||
};
|
||||
}, [webview]);
|
||||
|
||||
return { status, url, title, canGoBack, canGoForward };
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
|
||||
mock.module('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
||||
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: React.PropsWithChildren) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: React.PropsWithChildren) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
const { AppLinkConfirmDialog } = await import('./AppLinkConfirmDialog');
|
||||
const {
|
||||
getAppLinkConfirmationSnapshot,
|
||||
openAppLinkWithConfirmation,
|
||||
settleAppLinkConfirmation,
|
||||
} = await import('./appLinkConfirmation');
|
||||
|
||||
describe('AppLinkConfirmDialog', () => {
|
||||
beforeEach(() => {
|
||||
if (getAppLinkConfirmationSnapshot()) {
|
||||
settleAppLinkConfirmation('cancel');
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps cancel visible and focused beside both open choices', () => {
|
||||
void openAppLinkWithConfirmation('obsidian://open?vault=Notebook');
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<AppLinkConfirmDialog />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('>Cancel</button>');
|
||||
expect(markup).toContain('autofocus=""');
|
||||
expect(markup).toContain('>Open once</button>');
|
||||
expect(markup).toContain('>Trust and open</button>');
|
||||
|
||||
settleAppLinkConfirmation('cancel');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getUrlScheme } from '@/lib/url';
|
||||
|
||||
import {
|
||||
getAppLinkConfirmationSnapshot,
|
||||
settleAppLinkConfirmation,
|
||||
subscribeAppLinkConfirmation,
|
||||
type AppLinkConfirmationChoice,
|
||||
} from './appLinkConfirmation';
|
||||
|
||||
/**
|
||||
* App-level dialog confirming application deep links (obsidian://, vscode://,
|
||||
* ...) rendered in chat markdown before the OS is asked to open them.
|
||||
* Dismissing via the close button, Escape, or the backdrop cancels the open.
|
||||
*/
|
||||
export const AppLinkConfirmDialog = () => {
|
||||
const { t } = useI18n();
|
||||
const request = React.useSyncExternalStore(
|
||||
subscribeAppLinkConfirmation,
|
||||
getAppLinkConfirmationSnapshot,
|
||||
getAppLinkConfirmationSnapshot,
|
||||
);
|
||||
|
||||
const url = request?.url ?? '';
|
||||
const scheme = getUrlScheme(url) ?? '';
|
||||
|
||||
const settle = React.useCallback((choice: AppLinkConfirmationChoice) => {
|
||||
settleAppLinkConfirmation(choice);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(request)}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
settle('cancel');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('chat.appLink.confirm.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{scheme
|
||||
? t('chat.appLink.confirm.description', { scheme: `${scheme}://` })
|
||||
: t('chat.appLink.confirm.descriptionPlain')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="rounded-lg bg-[var(--surface-muted)] px-3 py-2 text-[13px] leading-relaxed break-all text-[var(--surface-foreground)]">
|
||||
{url}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" autoFocus onClick={() => settle('cancel')}>
|
||||
{t('chat.appLink.confirm.cancel')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => settle('trust')}>
|
||||
{t('chat.appLink.confirm.trustAndOpen')}
|
||||
</Button>
|
||||
<Button variant="default" onClick={() => settle('open')}>
|
||||
{t('chat.appLink.confirm.open')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,8 @@ import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
||||
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import MessageBody from './message/MessageBody';
|
||||
import type { AgentMentionInfo } from './message/types';
|
||||
import type { StreamPhase, ToolPopupContent } from './message/types';
|
||||
@@ -21,7 +21,7 @@ import { deriveMessageRole } from './message/messageRole';
|
||||
import { filterVisibleParts, normalizeParts } from './message/partUtils';
|
||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||
import { isHiddenUserMessage } from './message/hiddenUserMessage';
|
||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||
import { flattenAssistantTextParts, flattenUserTextParts } from '@/lib/messages/messageText';
|
||||
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
@@ -131,8 +131,6 @@ interface ChatMessageProps {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
};
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
animationHandlers?: AnimationHandlers;
|
||||
scrollToBottom?: () => void;
|
||||
turnGroupingContext?: TurnGroupingContext;
|
||||
assistantHeaderMessageId?: string;
|
||||
@@ -147,8 +145,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
message,
|
||||
previousMessage,
|
||||
nextMessage,
|
||||
onContentChange,
|
||||
animationHandlers,
|
||||
turnGroupingContext,
|
||||
assistantHeaderMessageId,
|
||||
isInActiveTurn = false,
|
||||
@@ -202,6 +198,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
|
||||
const isUser = messageRole.isUser;
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader);
|
||||
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
|
||||
|
||||
@@ -460,13 +457,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
}, [chatRenderMode, isMessageCompleted, isUser, visibleParts]);
|
||||
|
||||
|
||||
const assistantTextParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return [];
|
||||
}
|
||||
return visibleParts.filter((part) => part.type === 'text');
|
||||
}, [isUser, visibleParts]);
|
||||
|
||||
const toolParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return [];
|
||||
@@ -548,19 +538,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const shouldHideUserMessage = isUser && displayParts.length === 0;
|
||||
|
||||
// Message is considered to have an "open step" if info.finish is not yet present
|
||||
const hasOpenStep = typeof messageFinish !== 'string';
|
||||
|
||||
const shouldCoordinateRendering = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return false;
|
||||
}
|
||||
if (assistantTextParts.length === 0 || toolParts.length === 0) {
|
||||
return hasOpenStep;
|
||||
}
|
||||
return true;
|
||||
}, [assistantTextParts.length, toolParts.length, hasOpenStep, isUser]);
|
||||
|
||||
const themeVariant = currentTheme?.metadata.variant;
|
||||
const isDarkTheme = React.useMemo(() => {
|
||||
if (themeVariant) {
|
||||
@@ -703,67 +680,29 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
}
|
||||
if (errorName === 'SessionRetry') {
|
||||
return {
|
||||
text: `Opencode failed to send a message. Retry attempt info: \n\`${detail}\``,
|
||||
variant: 'info' as const,
|
||||
text: `Opencode failed to send a message. Retry attempt info: ${detail}`,
|
||||
};
|
||||
}
|
||||
if (isLikelyProviderAuthFailure(detail)) {
|
||||
return {
|
||||
text: PROVIDER_AUTH_FAILURE_MESSAGE,
|
||||
variant: 'error' as const,
|
||||
};
|
||||
}
|
||||
if (detail.trim().toLowerCase() === 'aborted') {
|
||||
return {
|
||||
text: 'The running turn was stopped before OpenCode could send the next message.',
|
||||
variant: 'info' as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: `Opencode failed to send message with error:\n\`${detail}\``,
|
||||
variant: 'error' as const,
|
||||
text: `Opencode failed to send message with error: ${detail}`,
|
||||
};
|
||||
}, [isUser, message.info]);
|
||||
|
||||
const assistantErrorText = assistantError?.text;
|
||||
const assistantErrorVariant = assistantError?.variant;
|
||||
|
||||
const messageTextContent = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
const shellOutputs = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const output = part.shellAction?.output;
|
||||
return typeof output === 'string' ? output.trim() : '';
|
||||
})
|
||||
.filter((output) => output.length > 0);
|
||||
|
||||
if (shellOutputs.length > 0) {
|
||||
return shellOutputs.join('\n\n');
|
||||
}
|
||||
|
||||
const shellCommands = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const command = part.shellAction?.command;
|
||||
return typeof command === 'string' ? command.trim() : '';
|
||||
})
|
||||
.filter((command) => command.length > 0);
|
||||
|
||||
if (shellCommands.length > 0) {
|
||||
return shellCommands.join('\n');
|
||||
}
|
||||
|
||||
const textParts = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const text = part.text || part.content || '';
|
||||
return text.trim();
|
||||
})
|
||||
.filter((text) => text.length > 0);
|
||||
|
||||
const combined = textParts.join('\n');
|
||||
return combined.replace(/\n\s*\n+/g, '\n');
|
||||
return flattenUserTextParts(displayParts);
|
||||
}
|
||||
|
||||
if (assistantErrorText && assistantErrorText.trim().length > 0) {
|
||||
@@ -853,35 +792,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
});
|
||||
}, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]);
|
||||
|
||||
const resolvedAnimationHandlers = animationHandlers ?? null;
|
||||
const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
|
||||
|
||||
const animationCompletedRef = React.useRef(false);
|
||||
const hasRequestedReservationRef = React.useRef(false);
|
||||
const animationStartNotifiedRef = React.useRef(false);
|
||||
const hasTriggeredReservationOnceRef = React.useRef(false);
|
||||
const hasEverStreamedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
animationCompletedRef.current = false;
|
||||
hasRequestedReservationRef.current = false;
|
||||
animationStartNotifiedRef.current = false;
|
||||
hasTriggeredReservationOnceRef.current = false;
|
||||
hasAnnouncedAuxiliaryScrollRef.current = false;
|
||||
hasEverStreamedRef.current = false;
|
||||
}, [message.info.id]);
|
||||
|
||||
const handleAuxiliaryContentComplete = React.useCallback(() => {
|
||||
if (isUser) {
|
||||
return;
|
||||
}
|
||||
if (hasAnnouncedAuxiliaryScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
hasAnnouncedAuxiliaryScrollRef.current = true;
|
||||
onContentChange?.('structural');
|
||||
}, [isUser, onContentChange]);
|
||||
|
||||
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
|
||||
|
||||
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
|
||||
@@ -904,114 +820,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
hasEverStreamedRef.current = true;
|
||||
}
|
||||
|
||||
const hasReasoningParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return false;
|
||||
}
|
||||
return visibleParts.some((part) => part.type === 'reasoning');
|
||||
}, [isUser, visibleParts]);
|
||||
|
||||
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current;
|
||||
const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolvedAnimationHandlers?.onStreamingCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldReserveAnimationSpace) {
|
||||
if (hasRequestedReservationRef.current) {
|
||||
if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) {
|
||||
resolvedAnimationHandlers.onReasoningBlock();
|
||||
} else if (resolvedAnimationHandlers?.onReservationCancelled) {
|
||||
resolvedAnimationHandlers.onReservationCancelled();
|
||||
}
|
||||
hasRequestedReservationRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTriggeredReservationOnceRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTriggeredReservationOnceRef.current = true;
|
||||
resolvedAnimationHandlers.onStreamingCandidate();
|
||||
hasRequestedReservationRef.current = true;
|
||||
}, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolvedAnimationHandlers?.onAnimationStart) {
|
||||
return;
|
||||
}
|
||||
if (!allowAnimation) {
|
||||
return;
|
||||
}
|
||||
if (animationStartNotifiedRef.current) {
|
||||
return;
|
||||
}
|
||||
resolvedAnimationHandlers.onAnimationStart();
|
||||
animationStartNotifiedRef.current = true;
|
||||
}, [resolvedAnimationHandlers, allowAnimation]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = resolvedAnimationHandlers?.onAnimatedHeightChange;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace;
|
||||
if (!shouldTrackHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = messageContainerRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
|
||||
handler(element.getBoundingClientRect().height);
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId: number | null = null;
|
||||
const notifyHeight = (height: number) => {
|
||||
if (typeof window === 'undefined') {
|
||||
handler(height);
|
||||
return;
|
||||
}
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
handler(height);
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
notifyHeight(entry.contentRect.height);
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
notifyHeight(element.getBoundingClientRect().height);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
|
||||
|
||||
if (shouldHideUserMessage) {
|
||||
return null;
|
||||
@@ -1044,7 +853,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
respectReducedMotion
|
||||
>
|
||||
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
|
||||
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined)}>
|
||||
{/* peek: the action row under the bubble is suppressed, so
|
||||
reserve its gap to the next message here, OUTSIDE the
|
||||
bubble background. */}
|
||||
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined, chatSurfaceMode === 'peek' ? 'pb-3' : undefined)}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--chat-user-message-bg)',
|
||||
@@ -1070,13 +882,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={false}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
@@ -1084,7 +894,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
contextPinPending={pinPending}
|
||||
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'}
|
||||
stickyUserHeaderEnabled={stickyUserHeader}
|
||||
/>
|
||||
@@ -1107,13 +916,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={false}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
@@ -1121,7 +928,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
contextPinPending={pinPending}
|
||||
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
userActionsMode="external-actions"
|
||||
stickyUserHeaderEnabled={stickyUserHeader}
|
||||
/>
|
||||
@@ -1154,17 +960,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={shouldShowHeader}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
agentMention={agentMention}
|
||||
turnGroupingContext={turnGroupingContext}
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
reviewTransferDirection={reviewTransferDirection}
|
||||
footerProviderID={headerProviderID}
|
||||
footerModelName={headerModelName}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
@@ -11,6 +10,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
|
||||
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
|
||||
|
||||
type CommandSource = 'openchamber' | 'opencode' | 'skill';
|
||||
|
||||
@@ -65,8 +65,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
}, ref) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const sessionMessages = useSessionMessages(currentSessionId ?? '');
|
||||
const hasMessagesInCurrentSession = sessionMessages.length > 0;
|
||||
const hasSession = Boolean(currentSessionId);
|
||||
const hasNewSessionDraft = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const canStartSessionCommand = hasSession || hasNewSessionDraft;
|
||||
@@ -84,7 +82,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
const keyboardNavigationRef = React.useRef(false);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
|
||||
const ignoreClickRef = React.useRef(false);
|
||||
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
|
||||
const pointerMovedRef = React.useRef(false);
|
||||
@@ -139,7 +137,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
}));
|
||||
|
||||
const builtInCommands: CommandInfo[] = [
|
||||
...(hasSession && !hasMessagesInCurrentSession
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
|
||||
: []
|
||||
),
|
||||
@@ -152,6 +150,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
: []
|
||||
),
|
||||
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
),
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
@@ -195,10 +197,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
];
|
||||
const allCommands = mergeCommandAutocompleteItems(builtInCommands, customCommands, skillCommands);
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const filtered = (searchQuery
|
||||
const filtered = searchQuery
|
||||
? allCommands.filter(cmd => commandMatchesSearch(cmd, searchQuery))
|
||||
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
: allCommands;
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase());
|
||||
@@ -211,9 +212,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
setCommands(filtered);
|
||||
} catch {
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const builtInCommands: CommandInfo[] = [
|
||||
...(hasSession && !hasMessagesInCurrentSession
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
|
||||
: []
|
||||
),
|
||||
@@ -226,6 +226,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
: []
|
||||
),
|
||||
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
),
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
@@ -268,12 +272,12 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
),
|
||||
];
|
||||
|
||||
const filtered = (searchQuery
|
||||
const filtered = searchQuery
|
||||
? builtInCommands.filter(cmd =>
|
||||
fuzzyMatch(cmd.name, searchQuery) ||
|
||||
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
|
||||
)
|
||||
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
: builtInCommands;
|
||||
|
||||
setCommands(filtered);
|
||||
} finally {
|
||||
@@ -282,7 +286,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
};
|
||||
|
||||
loadCommands();
|
||||
}, [searchQuery, hasMessagesInCurrentSession, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
|
||||
}, [searchQuery, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
@@ -376,6 +380,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
const isSystem = command.isBuiltIn;
|
||||
const isOpenChamberBadge = command.isOpenChamber;
|
||||
return (
|
||||
<AutocompleteRowTooltip description={command.description} active={!isMobile && index === selectedIndex}>
|
||||
<div
|
||||
key={command.id}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
@@ -471,13 +476,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{command.description && !isMobile && (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AutocompleteRowTooltip>
|
||||
);
|
||||
})}
|
||||
{commands.length === 0 && (
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import React from "react";
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDirectorySync } from "@/sync/sync-context";
|
||||
import type { Todo } from "@opencode-ai/sdk/v2/client";
|
||||
import { useUIStore } from "@/stores/useUIStore";
|
||||
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
|
||||
import { isVSCodeRuntime } from "@/lib/desktop";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
// The bar that sits in the composer stack: pending-changes accessory, abort
|
||||
// status, and the todos dropdown. Deliberately a separate component from
|
||||
// StatusRow — that one is the floating assistant-status chip above the
|
||||
// composer, and sharing markup meant every restyle of the chip (glass,
|
||||
// placement) silently restyled this bar and its dropdown too.
|
||||
|
||||
type TodoItem = Todo & { id?: string };
|
||||
|
||||
const COMPOSER_STATUS_BAR_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "composer-status-bar" };
|
||||
|
||||
const statusConfig = {
|
||||
in_progress: { textClassName: "text-foreground" },
|
||||
pending: { textClassName: "text-foreground" },
|
||||
completed: { textClassName: "text-muted-foreground line-through" },
|
||||
cancelled: { textClassName: "text-muted-foreground line-through" },
|
||||
};
|
||||
|
||||
const priorityClassName = {
|
||||
high: "text-[var(--status-warning)]",
|
||||
medium: "text-muted-foreground",
|
||||
low: "text-muted-foreground/70",
|
||||
};
|
||||
|
||||
const priorityIcon = {
|
||||
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
};
|
||||
|
||||
const statusLabelKey = {
|
||||
in_progress: "chat.statusRow.todo.status.inProgress",
|
||||
pending: "chat.statusRow.todo.status.pending",
|
||||
completed: "chat.statusRow.todo.status.completed",
|
||||
cancelled: "chat.statusRow.todo.status.cancelled",
|
||||
};
|
||||
|
||||
const priorityLabelKey = {
|
||||
high: "chat.statusRow.todo.priority.high",
|
||||
medium: "chat.statusRow.todo.priority.medium",
|
||||
low: "chat.statusRow.todo.priority.low",
|
||||
};
|
||||
|
||||
// SAFETY: todo.status / todo.priority arrive from the SDK as open strings;
|
||||
// lookups treat them as candidate keys and every call site falls back to a
|
||||
// default entry when the value is outside the known set.
|
||||
const knownStatus = (status: string) =>
|
||||
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
|
||||
status as keyof typeof statusConfig;
|
||||
const knownPriority = (priority: string) =>
|
||||
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
|
||||
priority as keyof typeof priorityClassName;
|
||||
|
||||
const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => {
|
||||
const { t } = useI18n();
|
||||
const config = statusConfig[knownStatus(todo.status)] || statusConfig.pending;
|
||||
// SAFETY: the label keys are literal members of the i18n dictionary; the
|
||||
// lookup narrows an open SDK string with a known fallback, and t() accepts
|
||||
// only the generated key union.
|
||||
const statusKey = (statusLabelKey[knownStatus(todo.status)] ?? statusLabelKey.pending) as Parameters<typeof t>[0];
|
||||
// SAFETY: same literal-member narrowing as statusKey above.
|
||||
const priorityKey = (priorityLabelKey[knownPriority(todo.priority)] ?? priorityLabelKey.medium) as Parameters<typeof t>[0];
|
||||
|
||||
const statusIcon =
|
||||
todo.status === "in_progress" ? (
|
||||
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true" />
|
||||
) : todo.status === "completed" ? (
|
||||
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true" />
|
||||
) : (
|
||||
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-0 py-0.5 gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex-shrink-0">{statusIcon}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
{t(statusKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className={cn("flex-1 typography-ui-label", config.textClassName)}>
|
||||
{todo.content}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
|
||||
priorityClassName[knownPriority(todo.priority)] ?? priorityClassName.medium,
|
||||
)}
|
||||
>
|
||||
{priorityIcon[knownPriority(todo.priority)] ?? priorityIcon.medium}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{t(priorityKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EMPTY_TODOS: TodoItem[] = [];
|
||||
|
||||
interface ComposerStatusBarProps {
|
||||
showTodos?: boolean;
|
||||
leftAccessory?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
|
||||
showTodos = true,
|
||||
leftAccessory,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
const liveTodos = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
|
||||
return state.todo[currentSessionId] ?? EMPTY_TODOS;
|
||||
},
|
||||
[currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const persistedSessionTodos = useTodosPersistStore(
|
||||
React.useCallback(
|
||||
(state) => (showTodos && currentSessionId && currentSessionDirectory
|
||||
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
|
||||
: undefined),
|
||||
[currentSessionDirectory, currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const todos: TodoItem[] = React.useMemo(() => {
|
||||
if (!currentSessionId) return EMPTY_TODOS;
|
||||
if (liveTodos.length > 0) return liveTodos;
|
||||
return persistedSessionTodos ?? EMPTY_TODOS;
|
||||
}, [liveTodos, persistedSessionTodos, currentSessionId]);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const isCompact = isMobile || isVSCodeRuntime();
|
||||
|
||||
const visibleTodos = React.useMemo(() => {
|
||||
return todos.filter((todo) => todo.status !== "cancelled");
|
||||
}, [todos]);
|
||||
|
||||
const activeTodo = React.useMemo(() => {
|
||||
return (
|
||||
visibleTodos.find((todo) => todo.status === "in_progress") ||
|
||||
visibleTodos.find((todo) => todo.status === "pending") ||
|
||||
null
|
||||
);
|
||||
}, [visibleTodos]);
|
||||
|
||||
const progress = React.useMemo(() => {
|
||||
const total = todos.filter((todo) => todo.status !== "cancelled").length;
|
||||
const completed = todos.filter((todo) => todo.status === "completed").length;
|
||||
return { completed, total };
|
||||
}, [todos]);
|
||||
|
||||
const statusSummary = React.useMemo(() => {
|
||||
const active = visibleTodos.filter((todo) => todo.status === "in_progress").length;
|
||||
const left = visibleTodos.filter((todo) => todo.status === "in_progress" || todo.status === "pending").length;
|
||||
return { active, left };
|
||||
}, [visibleTodos]);
|
||||
|
||||
const hasTodoContent = showTodos && statusSummary.left > 0;
|
||||
const hasLeftAccessory = Boolean(leftAccessory);
|
||||
const hasContent = hasTodoContent || hasLeftAccessory;
|
||||
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
// SAFETY: mousedown targets are DOM nodes; contains() only needs Node.
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isExpanded]);
|
||||
|
||||
const toggleExpanded = () => setIsExpanded((prev) => !prev);
|
||||
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
|
||||
active: statusSummary.active,
|
||||
left: statusSummary.left,
|
||||
});
|
||||
|
||||
const todoTrigger = hasTodoContent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
|
||||
aria-label={todoSummaryLabel}
|
||||
title={todoSummaryLabel}
|
||||
>
|
||||
{!isCompact && activeTodo ? (
|
||||
<span className="composer-status-bar__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
|
||||
{activeTodo.content}
|
||||
</span>
|
||||
) : (
|
||||
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
|
||||
)}
|
||||
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
|
||||
{statusSummary.active}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Icon name="time" className="h-3.5 w-3.5" />
|
||||
{statusSummary.left}
|
||||
</span>
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2" style={COMPOSER_STATUS_BAR_CONTAINER_STYLE}>
|
||||
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
|
||||
{/* Left: abort status | pending-changes accessory */}
|
||||
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
|
||||
{leftAccessory ?? null}
|
||||
</div>
|
||||
|
||||
{/* Right: todos dropdown */}
|
||||
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory && "pr-1.5")} ref={popoverRef}>
|
||||
{todoTrigger}
|
||||
|
||||
{isExpanded && hasTodoContent && (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "min(28rem, calc(100cqw - 4ch))",
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
color: "var(--surface-elevated-foreground)",
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-0 bottom-full mb-1 z-50",
|
||||
"w-max min-w-[200px] rounded-xl p-1",
|
||||
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
|
||||
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
|
||||
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
|
||||
"duration-150",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
|
||||
<span>{t('chat.statusRow.tasksTitle')}</span>
|
||||
<span className="typography-meta tabular-nums">
|
||||
{progress.completed}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-1 max-h-[200px] overflow-y-auto">
|
||||
{visibleTodos.map((todo, index) => (
|
||||
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,18 +2,30 @@ import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
||||
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
|
||||
import {
|
||||
useWorkerHighlightedLines,
|
||||
type WorkerHighlightedLinesResult,
|
||||
} from '@/components/code/useWorkerHighlightedLines';
|
||||
import { parseDiffToUnified } from './message/toolRenderers';
|
||||
|
||||
// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls
|
||||
// back to plain text while loading or on failure.
|
||||
const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) =>
|
||||
html !== undefined ? (
|
||||
<span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap break-all">{content}</span>
|
||||
);
|
||||
// Keep the line's layout stable while a cold worker request finishes. Plain
|
||||
// text appears only if highlighting fails, avoiding a visible color flash.
|
||||
interface CodeLineContentProps {
|
||||
content: string;
|
||||
html: string | undefined;
|
||||
status: WorkerHighlightedLinesResult['status'];
|
||||
}
|
||||
|
||||
const CodeLineContent: React.FC<CodeLineContentProps> = ({ content, html, status }) => {
|
||||
if (status === 'ready' && html !== undefined) {
|
||||
return <span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
if (status === 'loading') {
|
||||
return <span aria-hidden className="invisible whitespace-pre-wrap break-all">{content}</span>;
|
||||
}
|
||||
return <span className="whitespace-pre-wrap break-all">{content}</span>;
|
||||
};
|
||||
|
||||
interface DiffPreviewProps {
|
||||
diff: string;
|
||||
@@ -44,7 +56,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
|
||||
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIdx) => {
|
||||
const html = highlighted?.[lineCursor];
|
||||
const html = highlighted.lines?.[lineCursor];
|
||||
lineCursor += 1;
|
||||
return (
|
||||
<div
|
||||
@@ -67,7 +79,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
|
||||
{line.lineNumber || ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CodeLineContent content={line.content} html={html} />
|
||||
<CodeLineContent content={line.content} html={html} status={highlighted.status} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -106,7 +118,11 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, filePath })
|
||||
{lineIdx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CodeLineContent content={line || ' '} html={highlighted?.[lineIdx]} />
|
||||
<CodeLineContent
|
||||
content={line || ' '}
|
||||
html={highlighted.lines?.[lineIdx]}
|
||||
status={highlighted.status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useRef, memo } from 'react';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import type { AttachedFile } from '@/sync/session-ui-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
@@ -833,7 +834,10 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
useUIStore.getState().navigateToDiagram(filePath);
|
||||
const directory = useDirectoryStore.getState().currentDirectory;
|
||||
if (directory) {
|
||||
useUIStore.getState().openContextFile(directory, filePath);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left cursor-pointer",
|
||||
|
||||
@@ -14,6 +14,9 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
import { mentionServerQuery, rankFileMentionResults } from './fileMentionResults';
|
||||
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
|
||||
|
||||
type FileInfo = ProjectFileSearchHit;
|
||||
type AgentInfo = {
|
||||
@@ -80,7 +83,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
|
||||
const normalizedSearchQuery = (searchQuery ?? '').trim();
|
||||
const recentFiles = React.useMemo(() => {
|
||||
if (!projectRoot || !projectTabs) {
|
||||
@@ -93,14 +96,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
].filter((value): value is string => typeof value === 'string' && value.length > 0);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const queryLower = normalizedSearchQuery.toLowerCase();
|
||||
const mapped = ordered
|
||||
.filter((filePath) => {
|
||||
if (seen.has(filePath)) return false;
|
||||
seen.add(filePath);
|
||||
const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath;
|
||||
if (!queryLower) return true;
|
||||
return relative.toLowerCase().includes(queryLower);
|
||||
return matchesRankQuery([relative], normalizedSearchQuery);
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map((filePath) => {
|
||||
@@ -123,9 +124,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
() => normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2),
|
||||
[agents, normalizedSearchQuery.length],
|
||||
);
|
||||
const visibleDirectories = directories;
|
||||
const visibleRecentFiles = recentFiles;
|
||||
const visibleFiles = files;
|
||||
const visibleResults = React.useMemo(
|
||||
() => rankFileMentionResults(files, directories, normalizedSearchQuery, 20),
|
||||
[files, directories, normalizedSearchQuery],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
@@ -151,13 +154,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedQuery = (debouncedQuery ?? '').trim();
|
||||
const normalizedQueryLower = normalizedQuery
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/^\/+/, '')
|
||||
.toLowerCase();
|
||||
const serverQuery = mentionServerQuery(debouncedQuery ?? '');
|
||||
|
||||
if (!normalizedQueryLower) {
|
||||
if (!serverQuery) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
@@ -166,7 +165,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
pendingSearchRef.current++;
|
||||
setLoading(true);
|
||||
|
||||
searchFiles(currentDirectory, normalizedQueryLower, 80, {
|
||||
searchFiles(currentDirectory, serverQuery, 80, {
|
||||
includeHidden: showHidden,
|
||||
respectGitignore: !showGitignored,
|
||||
type: 'file',
|
||||
@@ -177,7 +176,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
}
|
||||
|
||||
const recentSet = new Set(recentFiles.map((file) => file.path));
|
||||
setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15));
|
||||
setFiles(hits.filter((hit) => !recentSet.has(hit.path)));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
@@ -209,13 +208,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedQuery = (debouncedQuery ?? '').trim();
|
||||
const normalizedQueryLower = normalizedQuery
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/^\/+/, '')
|
||||
.toLowerCase();
|
||||
const serverQuery = mentionServerQuery(debouncedQuery ?? '');
|
||||
|
||||
if (!normalizedQueryLower) {
|
||||
if (!serverQuery) {
|
||||
setDirectories([]);
|
||||
return;
|
||||
}
|
||||
@@ -224,14 +219,14 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
pendingSearchRef.current++;
|
||||
setLoading(true);
|
||||
|
||||
searchFiles(currentDirectory, normalizedQueryLower, 20, {
|
||||
searchFiles(currentDirectory, serverQuery, 20, {
|
||||
includeHidden: showHidden,
|
||||
respectGitignore: !showGitignored,
|
||||
type: 'directory',
|
||||
})
|
||||
.then((hits) => {
|
||||
if (!cancelled) {
|
||||
setDirectories(hits.slice(0, 10));
|
||||
setDirectories(hits);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -260,28 +255,22 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
|
||||
React.useEffect(() => {
|
||||
const visibleAgents = getVisibleAgents();
|
||||
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
|
||||
const filtered = visibleAgents
|
||||
const subagents = visibleAgents
|
||||
.filter((agent) => agent.mode && agent.mode !== 'primary')
|
||||
.filter((agent) => {
|
||||
if (!normalizedQuery) return true;
|
||||
const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase();
|
||||
return haystack.includes(normalizedQuery);
|
||||
})
|
||||
.map((agent) => ({
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
mode: agent.mode,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
setAgents(filtered);
|
||||
setAgents(rankByQuery(subagents, searchQuery ?? '', (agent) => [agent.name, agent.description]));
|
||||
}, [getVisibleAgents, searchQuery]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setOverflowMap({});
|
||||
setMarqueeDurations({});
|
||||
}, [visibleFiles, visibleDirectories, visibleRecentFiles.length, visibleAgents.length]);
|
||||
}, [visibleResults, visibleRecentFiles.length, visibleAgents.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
@@ -331,7 +320,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
}
|
||||
window.removeEventListener('resize', updateOverflow);
|
||||
};
|
||||
}, [visibleFiles, visibleDirectories]);
|
||||
}, [visibleResults]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const labelNode = labelRefs.current[selectedIndex];
|
||||
@@ -375,7 +364,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
return;
|
||||
}
|
||||
|
||||
const total = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + visibleFiles.length;
|
||||
const total = visibleAgents.length + visibleRecentFiles.length + visibleResults.length;
|
||||
if (total === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -399,24 +388,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
}
|
||||
return;
|
||||
}
|
||||
const dirIndex = safeIndex - visibleAgents.length;
|
||||
if (dirIndex < visibleDirectories.length) {
|
||||
const dir = visibleDirectories[dirIndex];
|
||||
if (dir) {
|
||||
handleFileSelect(dir);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const fileIndex = dirIndex - visibleDirectories.length;
|
||||
const selectedFile = fileIndex < visibleRecentFiles.length
|
||||
? visibleRecentFiles[fileIndex]
|
||||
: visibleFiles[fileIndex - visibleRecentFiles.length];
|
||||
const recentIndex = safeIndex - visibleAgents.length;
|
||||
const selectedFile = recentIndex < visibleRecentFiles.length
|
||||
? visibleRecentFiles[recentIndex]
|
||||
: visibleResults[recentIndex - visibleRecentFiles.length];
|
||||
if (selectedFile) {
|
||||
handleFileSelect(selectedFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}), [visibleFiles, visibleDirectories, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]);
|
||||
}), [visibleResults, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]);
|
||||
|
||||
const getFileIcon = (file: FileInfo) => {
|
||||
const ext = file.extension?.toLowerCase();
|
||||
@@ -458,6 +439,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
{visibleAgents.map((agent, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
return (
|
||||
<AutocompleteRowTooltip description={agent.description} active={!isMobile && isSelected}>
|
||||
<div
|
||||
key={`agent-${agent.name}`}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
@@ -470,11 +452,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold truncate">@{agent.name}</div>
|
||||
{agent.description && !isMobile ? (
|
||||
<div className="typography-meta text-muted-foreground truncate">{agent.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AutocompleteRowTooltip>
|
||||
);
|
||||
})}
|
||||
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
|
||||
@@ -482,38 +462,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
{t('chat.fileMentionAutocomplete.searchMoreAgents')}
|
||||
</div>
|
||||
)}
|
||||
{visibleAgents.length > 0 && (visibleDirectories.length > 0 || visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
)}
|
||||
{visibleDirectories.map((dir, index) => {
|
||||
const rowIndex = visibleAgents.length + index;
|
||||
const relativePath = dir.relativePath || dir.name;
|
||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||
const isSelected = selectedIndex === rowIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`dir-${dir.path}`}
|
||||
ref={(el) => { itemRefs.current[rowIndex] = el; }}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
|
||||
isSelected && "bg-interactive-selection"
|
||||
)}
|
||||
onClick={() => handleFileSelect(dir)}
|
||||
onMouseMove={() => setSelectedIndex(rowIndex)}
|
||||
>
|
||||
<Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
|
||||
<span className="flex-1 min-w-0 truncate" aria-label={relativePath}>
|
||||
{displayPath}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visibleDirectories.length > 0 && (visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
|
||||
{visibleAgents.length > 0 && (visibleRecentFiles.length > 0 || visibleResults.length > 0) && (
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
)}
|
||||
{visibleRecentFiles.map((file, index) => {
|
||||
const rowIndex = visibleAgents.length + visibleDirectories.length + index;
|
||||
const rowIndex = visibleAgents.length + index;
|
||||
const relativePath = file.relativePath || file.name;
|
||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||
const isSelected = selectedIndex === rowIndex;
|
||||
@@ -561,11 +514,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visibleRecentFiles.length > 0 && visibleFiles.length > 0 && (
|
||||
{visibleRecentFiles.length > 0 && visibleResults.length > 0 && (
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
)}
|
||||
{visibleFiles.map((file, index) => {
|
||||
const rowIndex = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + index;
|
||||
{visibleResults.map((file, index) => {
|
||||
const rowIndex = visibleAgents.length + visibleRecentFiles.length + index;
|
||||
const relativePath = file.relativePath || file.name;
|
||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||
const isSelected = selectedIndex === rowIndex;
|
||||
@@ -582,7 +535,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
onClick={() => handleFileSelect(file)}
|
||||
onMouseMove={() => setSelectedIndex(rowIndex)}
|
||||
>
|
||||
{getFileIcon(file)}
|
||||
{file.kind === 'directory'
|
||||
? <Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
|
||||
: getFileIcon(file)}
|
||||
<span
|
||||
ref={(el) => { labelRefs.current[rowIndex] = el; }}
|
||||
className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container"
|
||||
@@ -613,12 +568,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={file.path}>
|
||||
<React.Fragment key={`${file.kind}-${file.path}`}>
|
||||
{item}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{visibleFiles.length === 0 && visibleDirectories.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
|
||||
{visibleResults.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
|
||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||
{t('chat.fileMentionAutocomplete.empty')}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
acquireRuntimeUrlAuthToken,
|
||||
refreshRuntimeUrlAuthToken,
|
||||
subscribeRuntimeUrlAuthToken,
|
||||
} from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
import {
|
||||
extractMarkdownImageCandidates,
|
||||
MAX_MARKDOWN_IMAGE_COUNT,
|
||||
type MarkdownImageCandidate,
|
||||
} from './markdown/markdownCore';
|
||||
import {
|
||||
getPreparedMarkdownImageUrl,
|
||||
isLocalMarkdownImageSource,
|
||||
prepareLocalMarkdownImages,
|
||||
resolveMarkdownImageSource,
|
||||
resolveWorkspaceMarkdownImageSource,
|
||||
type PreparedMarkdownImage,
|
||||
} from './markdown/markdownImageAssets';
|
||||
|
||||
const useAssetAuth = (enabled: boolean): { ready: boolean; nonce: number } => {
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const [nonce, setNonce] = React.useState(0);
|
||||
const apiBaseUrl = getRuntimeApiBaseUrl();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
setReady(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const release = acquireRuntimeUrlAuthToken(apiBaseUrl);
|
||||
const unsubscribe = subscribeRuntimeUrlAuthToken(() => {
|
||||
if (!cancelled) setNonce((current) => current + 1);
|
||||
});
|
||||
const refresh = () => {
|
||||
void refreshRuntimeUrlAuthToken(apiBaseUrl)
|
||||
.then(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) retryTimer = setTimeout(refresh, 1000);
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
release();
|
||||
unsubscribe();
|
||||
};
|
||||
}, [apiBaseUrl, enabled]);
|
||||
|
||||
return { ready: !enabled || ready, nonce };
|
||||
};
|
||||
|
||||
const MarkdownImageThumbnail: React.FC<{
|
||||
candidate: MarkdownImageCandidate;
|
||||
preparation?: PreparedMarkdownImage;
|
||||
directory: string;
|
||||
assetAuthReady: boolean;
|
||||
assetAuthNonce: number;
|
||||
useWorkspaceFsBridge: boolean;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}> = ({
|
||||
candidate,
|
||||
preparation,
|
||||
directory,
|
||||
assetAuthReady,
|
||||
assetAuthNonce,
|
||||
useWorkspaceFsBridge,
|
||||
onShowPopup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const thumbnailRef = React.useRef<HTMLButtonElement>(null);
|
||||
const [shouldLoad, setShouldLoad] = React.useState(false);
|
||||
const [image, setImage] = React.useState<{ url: string; status: 'loading' | 'ready' | 'error' }>({
|
||||
url: '',
|
||||
status: 'loading',
|
||||
});
|
||||
const local = isLocalMarkdownImageSource(candidate.source);
|
||||
|
||||
React.useEffect(() => {
|
||||
const thumbnail = thumbnailRef.current;
|
||||
if (!thumbnail || shouldLoad) return;
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setShouldLoad(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
setShouldLoad(true);
|
||||
observer.disconnect();
|
||||
}, { rootMargin: '200px' });
|
||||
observer.observe(thumbnail);
|
||||
return () => observer.disconnect();
|
||||
}, [shouldLoad]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldLoad || (local && !useWorkspaceFsBridge && !preparation)) return;
|
||||
if (local && useWorkspaceFsBridge) {
|
||||
const controller = new AbortController();
|
||||
setImage({ url: '', status: 'loading' });
|
||||
void resolveWorkspaceMarkdownImageSource(candidate.source, directory, controller.signal).then((url) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setImage({ url, status: 'loading' });
|
||||
}).catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setImage({ url: '', status: 'error' });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}
|
||||
if (local) {
|
||||
if (preparation?.status !== 'ready') {
|
||||
setImage({ url: '', status: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!assetAuthReady) return;
|
||||
setImage({ url: getPreparedMarkdownImageUrl(preparation, directory), status: 'loading' });
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setImage({ url: '', status: 'loading' });
|
||||
void resolveMarkdownImageSource(candidate.source, controller.signal).then((url) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setImage({ url, status: 'loading' });
|
||||
}).catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setImage({ url: '', status: 'error' });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [assetAuthNonce, assetAuthReady, candidate.source, directory, local, preparation, shouldLoad, useWorkspaceFsBridge]);
|
||||
|
||||
const openPreview = React.useCallback(() => {
|
||||
if (image.status === 'error') {
|
||||
toast.error(t('filesView.error.previewUnavailable'));
|
||||
return;
|
||||
}
|
||||
if (image.status !== 'ready' || !onShowPopup) return;
|
||||
onShowPopup({
|
||||
open: true,
|
||||
title: candidate.filename,
|
||||
content: '',
|
||||
metadata: { tool: 'markdown-image-preview', filename: candidate.filename },
|
||||
image: { url: image.url, filename: candidate.filename },
|
||||
});
|
||||
}, [candidate.filename, image, onShowPopup, t]);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={thumbnailRef}
|
||||
type="button"
|
||||
className="w-[100px] shrink-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
aria-label={candidate.filename}
|
||||
disabled={image.status === 'loading'}
|
||||
onClick={openPreview}
|
||||
data-openchamber-markdown-image-action="true"
|
||||
data-openchamber-markdown-image-source={candidate.source}
|
||||
data-openchamber-markdown-image-filename={candidate.filename}
|
||||
>
|
||||
<span className="flex h-[72px] w-[100px] items-center justify-center overflow-hidden rounded-lg border border-border/40 bg-muted/10">
|
||||
{image.url && image.status !== 'error' ? (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={candidate.filename}
|
||||
className="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={() => setImage((current) => ({ ...current, status: 'ready' }))}
|
||||
onError={() => setImage({ url: '', status: 'error' })}
|
||||
data-openchamber-markdown-image="true"
|
||||
data-openchamber-markdown-image-thumbnail="true"
|
||||
data-openchamber-markdown-image-state={image.status}
|
||||
/>
|
||||
) : (
|
||||
<Icon name="file-image" className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="mt-1 flex w-[100px] items-center justify-center gap-1 text-muted-foreground"
|
||||
title={candidate.filename}
|
||||
data-openchamber-markdown-image-caption="true"
|
||||
>
|
||||
<Icon name="file-image" className="h-3 w-3 shrink-0" />
|
||||
<span className="min-w-0 truncate typography-meta">{candidate.filename}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkdownImageGallery: React.FC<{
|
||||
sessionId?: string;
|
||||
messageId: string;
|
||||
contents: readonly string[];
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}> = ({ sessionId, messageId, contents, onShowPopup }) => {
|
||||
const directory = useEffectiveDirectory() ?? '';
|
||||
const galleryRef = React.useRef<HTMLDivElement>(null);
|
||||
const [shouldPrepare, setShouldPrepare] = React.useState(false);
|
||||
const [prepared, setPrepared] = React.useState<Map<string, PreparedMarkdownImage> | null>(null);
|
||||
const [prepareEpoch, setPrepareEpoch] = React.useState(0);
|
||||
const useWorkspaceFsBridge = isVSCodeRuntime();
|
||||
const candidates = React.useMemo(
|
||||
() => extractMarkdownImageCandidates(contents, MAX_MARKDOWN_IMAGE_COUNT),
|
||||
[contents],
|
||||
);
|
||||
const serverPreparationSources = React.useMemo(
|
||||
() => useWorkspaceFsBridge
|
||||
? []
|
||||
: candidates
|
||||
.filter((candidate) => isLocalMarkdownImageSource(candidate.source))
|
||||
.map((candidate) => candidate.source),
|
||||
[candidates, useWorkspaceFsBridge],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (serverPreparationSources.length === 0 || shouldPrepare) return;
|
||||
const gallery = galleryRef.current;
|
||||
if (!gallery || typeof IntersectionObserver === 'undefined') {
|
||||
setShouldPrepare(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
setShouldPrepare(true);
|
||||
observer.disconnect();
|
||||
}, { rootMargin: '200px' });
|
||||
observer.observe(gallery);
|
||||
return () => observer.disconnect();
|
||||
}, [serverPreparationSources.length, shouldPrepare]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldPrepare || !sessionId || serverPreparationSources.length === 0) return;
|
||||
const controller = new AbortController();
|
||||
void prepareLocalMarkdownImages({
|
||||
sources: serverPreparationSources,
|
||||
directory,
|
||||
sessionId,
|
||||
messageId,
|
||||
signal: controller.signal,
|
||||
}).then((result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setPrepared(result);
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setPrepared(new Map(serverPreparationSources.map((source) => [source, { status: 'error' }])));
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [directory, messageId, prepareEpoch, serverPreparationSources, sessionId, shouldPrepare]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const nextExpiry = Math.min(...[...(prepared?.values() ?? [])]
|
||||
.filter((value): value is Extract<PreparedMarkdownImage, { status: 'ready' }> => value.status === 'ready')
|
||||
.map((value) => value.expiresAt ?? Number.POSITIVE_INFINITY));
|
||||
if (!Number.isFinite(nextExpiry)) return;
|
||||
const timer = setTimeout(() => setPrepareEpoch((current) => current + 1), Math.max(0, nextExpiry - Date.now()));
|
||||
return () => clearTimeout(timer);
|
||||
}, [prepared]);
|
||||
|
||||
const visibleCandidates = candidates.filter((candidate) => prepared?.get(candidate.source)?.status !== 'missing');
|
||||
const hasPreparedAssets = [...(prepared?.values() ?? [])].some((value) => value.status === 'ready');
|
||||
const assetAuth = useAssetAuth(hasPreparedAssets);
|
||||
if (visibleCandidates.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={galleryRef}
|
||||
className="mt-3 flex max-w-full gap-2 overflow-x-auto pb-1"
|
||||
data-openchamber-markdown-image-gallery="true"
|
||||
>
|
||||
{visibleCandidates.map((candidate) => (
|
||||
<MarkdownImageThumbnail
|
||||
key={candidate.source}
|
||||
candidate={candidate}
|
||||
preparation={prepared?.get(candidate.source)}
|
||||
directory={directory}
|
||||
assetAuthReady={assetAuth.ready}
|
||||
assetAuthNonce={assetAuth.nonce}
|
||||
useWorkspaceFsBridge={useWorkspaceFsBridge}
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,6 +17,10 @@ const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
||||
loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer }))
|
||||
);
|
||||
|
||||
const MarkdownImageGalleryLazy = lazyWithChunkRecovery(() =>
|
||||
import('./MarkdownImageGallery').then((m) => ({ default: m.MarkdownImageGallery }))
|
||||
);
|
||||
|
||||
const fallback = <div className="break-words w-full min-w-0" />;
|
||||
|
||||
const fallbackContentClassName = (variant: unknown): string => {
|
||||
@@ -43,8 +47,18 @@ export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof Ma
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy>> = (props) => (
|
||||
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
|
||||
type SimpleMarkdownRendererProps = React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy> & {
|
||||
fallbackContent?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ fallbackContent, ...props }) => (
|
||||
<React.Suspense fallback={fallbackContent ?? <MobileMarkdownFallback {...props} />}>
|
||||
<SimpleMarkdownRendererLazy {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
export const MarkdownImageGallery: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownImageGalleryLazy>> = (props) => (
|
||||
<React.Suspense fallback={null}>
|
||||
<MarkdownImageGalleryLazy {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { TextPart } from '@opencode-ai/sdk/v2';
|
||||
|
||||
type OperationCounts = {
|
||||
innerHTMLWrites: number;
|
||||
spriteIconInnerHTMLWrites: number;
|
||||
querySelectorAllCalls: number;
|
||||
appendCalls: number;
|
||||
replaceCalls: number;
|
||||
removeCalls: number;
|
||||
getBoundingClientRectCalls: number;
|
||||
viewBoxWrites: number;
|
||||
resizeObserverCreates: number;
|
||||
resizeObserverObserveCalls: number;
|
||||
geometrySequence: Array<'read' | 'write'>;
|
||||
};
|
||||
|
||||
type FixtureMetrics = OperationCounts & {
|
||||
renderers: number;
|
||||
markdownBlocks: number;
|
||||
mermaidBlocks: number;
|
||||
mermaidRenderedCount: number;
|
||||
mermaidSvgCount: number;
|
||||
};
|
||||
|
||||
const fixture = [
|
||||
'# Synthetic mount fixture',
|
||||
'',
|
||||
'A paragraph with **bold text**, a table, and a stable link.',
|
||||
'',
|
||||
'| name | value |',
|
||||
'| --- | ---: |',
|
||||
'| alpha | 1 |',
|
||||
'| beta | 2 |',
|
||||
'',
|
||||
'```typescript',
|
||||
'const answer = 42;',
|
||||
'console.log(answer);',
|
||||
'```',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph TD',
|
||||
' A[Start] --> B[Finish]',
|
||||
'```',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph LR',
|
||||
' Client[Client] --> Server[Server]',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const fixtureWorkload = {
|
||||
rendererCount: 3,
|
||||
domBlocksPerRenderer: 1,
|
||||
mermaidBlocksPerRenderer: 2,
|
||||
};
|
||||
|
||||
let windowInstance: Window;
|
||||
let previousGlobals: Map<string, PropertyDescriptor | undefined>;
|
||||
let activeCounts: OperationCounts | null = null;
|
||||
let animationFrameQueue: FrameRequestCallback[] = [];
|
||||
let notifyResize: ((entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) | null = null;
|
||||
let MarkdownRenderer: React.ComponentType<{
|
||||
content: string;
|
||||
messageId: string;
|
||||
part?: TextPart;
|
||||
isAnimated?: boolean;
|
||||
isStreaming?: boolean;
|
||||
enableFileReferences?: boolean;
|
||||
}>;
|
||||
let clearDetachedMarkdownDomCache: () => void;
|
||||
let detachedMarkdownDomCacheStats: () => { sessions: number; entries: number };
|
||||
|
||||
const makeCounts = (): OperationCounts => ({
|
||||
innerHTMLWrites: 0,
|
||||
spriteIconInnerHTMLWrites: 0,
|
||||
querySelectorAllCalls: 0,
|
||||
appendCalls: 0,
|
||||
replaceCalls: 0,
|
||||
removeCalls: 0,
|
||||
getBoundingClientRectCalls: 0,
|
||||
viewBoxWrites: 0,
|
||||
resizeObserverCreates: 0,
|
||||
resizeObserverObserveCalls: 0,
|
||||
geometrySequence: [],
|
||||
});
|
||||
|
||||
const installGlobal = (name: string, value: Window[keyof Window]): void => {
|
||||
previousGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
|
||||
const waitForSettledEffects = async (): Promise<void> => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const flushAnimationFrame = async (): Promise<void> => {
|
||||
const callbacks = animationFrameQueue;
|
||||
animationFrameQueue = [];
|
||||
await act(async () => {
|
||||
for (const callback of callbacks) callback(windowInstance.performance.now());
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const flushDeferredMermaidInitialization = async (): Promise<void> => {
|
||||
await flushAnimationFrame();
|
||||
await flushAnimationFrame();
|
||||
};
|
||||
|
||||
const mountFixture = async (rendererCount: number): Promise<{
|
||||
root: Root;
|
||||
host: HTMLDivElement;
|
||||
operations: OperationCounts;
|
||||
counts: FixtureMetrics;
|
||||
}> => {
|
||||
const counts = makeCounts();
|
||||
activeCounts = counts;
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<>
|
||||
{Array.from({ length: rendererCount }, (_, index) => (
|
||||
<MarkdownRenderer
|
||||
key={`fixture-${index}`}
|
||||
content={fixture}
|
||||
messageId={`fixture-message-${index}`}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>
|
||||
))}
|
||||
</>,
|
||||
);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => waitForSettledEffects());
|
||||
|
||||
const mermaidBlocks = host.querySelectorAll('[data-markdown="mermaid-block"]').length;
|
||||
const mermaidRenderedCount = host.querySelectorAll('[data-mermaid-render]').length;
|
||||
const mermaidSvgCount = host.querySelectorAll('[data-markdown="mermaid"] svg').length;
|
||||
return {
|
||||
root,
|
||||
host,
|
||||
operations: counts,
|
||||
counts: {
|
||||
...counts,
|
||||
renderers: rendererCount,
|
||||
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
|
||||
mermaidBlocks,
|
||||
mermaidRenderedCount,
|
||||
mermaidSvgCount,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const runFixture = async (rendererCount: number): Promise<FixtureMetrics> => {
|
||||
const { root, host, operations } = await mountFixture(rendererCount);
|
||||
await flushDeferredMermaidInitialization();
|
||||
const counts: FixtureMetrics = {
|
||||
...operations,
|
||||
renderers: rendererCount,
|
||||
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
|
||||
mermaidBlocks: host.querySelectorAll('[data-markdown="mermaid-block"]').length,
|
||||
mermaidRenderedCount: host.querySelectorAll('[data-mermaid-render]').length,
|
||||
mermaidSvgCount: host.querySelectorAll('[data-markdown="mermaid"] svg').length,
|
||||
};
|
||||
await act(async () => root.unmount());
|
||||
return counts;
|
||||
};
|
||||
|
||||
const initializePerformanceDom = async (): Promise<void> => {
|
||||
windowInstance = new Window({ url: 'http://localhost/' });
|
||||
windowInstance.document.write('<!doctype html><html><head></head><body></body></html>');
|
||||
windowInstance.document.close();
|
||||
previousGlobals = new Map();
|
||||
installGlobal('window', windowInstance);
|
||||
installGlobal('document', windowInstance.document);
|
||||
installGlobal('navigator', windowInstance.navigator);
|
||||
installGlobal('customElements', windowInstance.customElements);
|
||||
for (const name of ['Document', 'Element', 'HTMLElement', 'SVGElement', 'Node', 'Text', 'NodeFilter', 'MutationObserver', 'DOMParser', 'XMLSerializer', 'HTMLAnchorElement', 'HTMLButtonElement']) {
|
||||
// SAFETY: these names are the DOM constructors installed by this happy-dom Window.
|
||||
const globalValue = windowInstance[name as keyof Window];
|
||||
if (globalValue === undefined) throw new Error(`happy-dom global is unavailable: ${name}`);
|
||||
installGlobal(name, globalValue);
|
||||
}
|
||||
Object.defineProperty(windowInstance, 'matchMedia', { configurable: true, value: () => ({ matches: false, media: '', onchange: null, addListener: () => undefined, removeListener: () => undefined, addEventListener: () => undefined, removeEventListener: () => undefined, dispatchEvent: () => false }) });
|
||||
Object.defineProperty(windowInstance, 'requestAnimationFrame', { configurable: true, value: (callback: FrameRequestCallback) => {
|
||||
animationFrameQueue.push(callback);
|
||||
return animationFrameQueue.length;
|
||||
} });
|
||||
Object.defineProperty(windowInstance, 'cancelAnimationFrame', { configurable: true, value: () => undefined });
|
||||
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const elementPrototype = Element.prototype;
|
||||
const nodePrototype = Node.prototype;
|
||||
const documentPrototype = Document.prototype;
|
||||
const innerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
|
||||
if (!innerHTMLDescriptor?.set || !innerHTMLDescriptor.get) throw new Error('happy-dom innerHTML descriptor unavailable');
|
||||
Object.defineProperty(Element.prototype, 'innerHTML', {
|
||||
configurable: true,
|
||||
get: innerHTMLDescriptor.get,
|
||||
set(value: string) {
|
||||
if (activeCounts) {
|
||||
activeCounts.innerHTMLWrites += 1;
|
||||
if (value.includes('href="#oc-')) activeCounts.spriteIconInnerHTMLWrites += 1;
|
||||
}
|
||||
innerHTMLDescriptor.set?.call(this, value);
|
||||
},
|
||||
});
|
||||
const originalQuerySelectorAll = elementPrototype.querySelectorAll;
|
||||
Object.defineProperty(elementPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
|
||||
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
|
||||
return originalQuerySelectorAll.call(this, selectors);
|
||||
} });
|
||||
const originalDocumentQuerySelectorAll = documentPrototype.querySelectorAll;
|
||||
Object.defineProperty(documentPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
|
||||
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
|
||||
return originalDocumentQuerySelectorAll.call(this, selectors);
|
||||
} });
|
||||
const originalAppendChild = nodePrototype.appendChild;
|
||||
Object.defineProperty(nodePrototype, 'appendChild', { configurable: true, value: function (node: Node): Node {
|
||||
if (activeCounts) activeCounts.appendCalls += 1;
|
||||
return originalAppendChild.call(this, node);
|
||||
} });
|
||||
const originalReplaceWith = elementPrototype.replaceWith;
|
||||
Object.defineProperty(elementPrototype, 'replaceWith', { configurable: true, value: function (...nodes: (Node | string)[]): void {
|
||||
if (activeCounts) activeCounts.replaceCalls += 1;
|
||||
return originalReplaceWith.apply(this, nodes);
|
||||
} });
|
||||
const originalRemove = elementPrototype.remove;
|
||||
Object.defineProperty(elementPrototype, 'remove', { configurable: true, value: function (): void {
|
||||
if (activeCounts) activeCounts.removeCalls += 1;
|
||||
return originalRemove.call(this);
|
||||
} });
|
||||
const originalGetBoundingClientRect = elementPrototype.getBoundingClientRect;
|
||||
Object.defineProperty(elementPrototype, 'getBoundingClientRect', { configurable: true, value: function (): DOMRect {
|
||||
if (activeCounts) {
|
||||
activeCounts.getBoundingClientRectCalls += 1;
|
||||
activeCounts.geometrySequence.push('read');
|
||||
}
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
} });
|
||||
const svgSetAttribute = SVGElement.prototype.setAttribute;
|
||||
Object.defineProperty(SVGElement.prototype, 'setAttribute', { configurable: true, value: function (name: string, value: string): void {
|
||||
if (name === 'viewBox' && activeCounts && this.closest('[data-markdown="mermaid"]')) {
|
||||
activeCounts.viewBoxWrites += 1;
|
||||
activeCounts.geometrySequence.push('write');
|
||||
}
|
||||
return svgSetAttribute.call(this, name, value);
|
||||
} });
|
||||
class CountingResizeObserver {
|
||||
constructor(callback: (entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) {
|
||||
if (activeCounts) activeCounts.resizeObserverCreates += 1;
|
||||
notifyResize = callback;
|
||||
}
|
||||
|
||||
observe(): void {
|
||||
if (activeCounts) activeCounts.resizeObserverObserveCalls += 1;
|
||||
}
|
||||
|
||||
unobserve(): void {}
|
||||
|
||||
disconnect(): void {}
|
||||
}
|
||||
installGlobal('ResizeObserver', CountingResizeObserver);
|
||||
|
||||
const fakeState = {
|
||||
openContextPreview: () => undefined,
|
||||
codeBlockLineWrap: false,
|
||||
mermaidRenderingMode: 'svg',
|
||||
};
|
||||
type UIStateSelection = typeof fakeState[keyof typeof fakeState];
|
||||
const { mock } = await import('bun:test');
|
||||
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
|
||||
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => null }));
|
||||
mock.module('@/stores/useUIStore', () => ({ useUIStore: Object.assign((selector: (state: typeof fakeState) => UIStateSelection) => selector(fakeState), { getState: () => fakeState }) }));
|
||||
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
|
||||
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
|
||||
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
|
||||
mock.module('@/lib/url', () => ({ getUrlScheme: () => null, isAppLinkUrl: () => false, isExternalHttpUrl: () => false, openConfirmedAppLinkUrl: async () => false, openExternalUrl: async () => undefined, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => false }));
|
||||
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
|
||||
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
|
||||
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
|
||||
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '', normalizeFilePath: (value: string) => value, isAbsoluteFilePath: (value: string) => value.startsWith('/') }));
|
||||
mock.module('@/lib/clipboard', () => ({ copyTextToClipboard: async () => undefined }));
|
||||
mock.module('beautiful-mermaid', () => ({
|
||||
renderMermaidASCII: () => 'diagram',
|
||||
renderMermaidSVG: () => '<svg viewBox="0 0 240 120" width="240" height="120"><path d="M0 0h1v1z" /></svg>',
|
||||
}));
|
||||
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
|
||||
mock.module('./markdown/markdown-worker', () => ({
|
||||
highlightCodeInWorker: async () => null,
|
||||
highlightLinesInWorker: async () => null,
|
||||
highlightTokensInWorker: async () => null,
|
||||
}));
|
||||
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: React.ReactNode }) => children }));
|
||||
const imported = await import('./MarkdownRendererImpl');
|
||||
MarkdownRenderer = imported.MarkdownRenderer;
|
||||
const { detachedMarkdownDomCache } = await import('./markdown/detachedMarkdownDomCache');
|
||||
clearDetachedMarkdownDomCache = () => detachedMarkdownDomCache.clear();
|
||||
detachedMarkdownDomCacheStats = () => detachedMarkdownDomCache.stats();
|
||||
};
|
||||
|
||||
await initializePerformanceDom();
|
||||
|
||||
afterAll(() => {
|
||||
for (const [name, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
|
||||
describe('MarkdownRenderer DOM mount performance contract', () => {
|
||||
test('builds Markdown sprite controls without parsing SVG markup', async () => {
|
||||
const mounted = await mountFixture(1);
|
||||
|
||||
const spriteControlCount = mounted.host.querySelectorAll('[data-md-action] use[href^="#oc-"]').length;
|
||||
const spriteIconInnerHTMLWrites = mounted.operations.spriteIconInnerHTMLWrites;
|
||||
await act(async () => mounted.root.unmount());
|
||||
|
||||
expect(spriteControlCount).toBeGreaterThan(0);
|
||||
expect(spriteIconInnerHTMLWrites).toBe(0);
|
||||
});
|
||||
|
||||
test('reuses settled Markdown DOM without parsing or decorating it again', async () => {
|
||||
clearDetachedMarkdownDomCache();
|
||||
const content = '# Cached viewport\n\nA settled paragraph.';
|
||||
const part: TextPart = {
|
||||
id: 'part-cache',
|
||||
sessionID: 'session-cache',
|
||||
messageID: 'message-cache',
|
||||
type: 'text',
|
||||
text: content,
|
||||
time: { start: 0, end: 1 },
|
||||
};
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const render = (root: Root) => root.render(
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
messageId="message-cache"
|
||||
part={part}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstCounts = makeCounts();
|
||||
activeCounts = firstCounts;
|
||||
const firstRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
render(firstRoot);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
const originalBlock = host.querySelector('[data-md-block]');
|
||||
expect(originalBlock).not.toBeNull();
|
||||
expect(firstCounts.innerHTMLWrites).toBeGreaterThan(0);
|
||||
await act(async () => firstRoot.unmount());
|
||||
|
||||
const secondCounts = makeCounts();
|
||||
activeCounts = secondCounts;
|
||||
const secondRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
render(secondRoot);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
expect(host.querySelector('[data-md-block]')).toBe(originalBlock);
|
||||
expect(secondCounts.innerHTMLWrites).toBe(0);
|
||||
await act(async () => secondRoot.unmount());
|
||||
clearDetachedMarkdownDomCache();
|
||||
});
|
||||
|
||||
test('does not cache streaming, unfinished, or Mermaid DOM', async () => {
|
||||
clearDetachedMarkdownDomCache();
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const renderScoped = (
|
||||
root: Root,
|
||||
content: string,
|
||||
partId: string,
|
||||
isStreaming = false,
|
||||
) => root.render(
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
messageId="message-cache"
|
||||
part={{
|
||||
id: partId,
|
||||
sessionID: 'session-cache',
|
||||
messageID: 'message-cache',
|
||||
type: 'text',
|
||||
text: content,
|
||||
time: { start: 0, end: 1 },
|
||||
}}
|
||||
isAnimated={false}
|
||||
isStreaming={isStreaming}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const streamingRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
renderScoped(streamingRoot, 'streaming content', 'part-streaming', true);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => streamingRoot.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
|
||||
const unfinalizedRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
unfinalizedRoot.render(
|
||||
<MarkdownRenderer
|
||||
content="unfinalized content"
|
||||
messageId="message-unfinalized"
|
||||
part={{
|
||||
id: 'part-unfinalized',
|
||||
sessionID: 'session-cache',
|
||||
messageID: 'message-unfinalized',
|
||||
type: 'text',
|
||||
text: 'unfinalized content',
|
||||
}}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => unfinalizedRoot.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
|
||||
const mermaidRoot = createRoot(host);
|
||||
await act(async () => {
|
||||
renderScoped(mermaidRoot, '```mermaid\ngraph TD\nA --> B\n```', 'part-mermaid');
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
await act(async () => mermaidRoot.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
|
||||
clearDetachedMarkdownDomCache();
|
||||
});
|
||||
|
||||
test('does not detach Markdown DOM that intersects the active selection', async () => {
|
||||
clearDetachedMarkdownDomCache();
|
||||
const content = 'selected content';
|
||||
const host = document.createElement('div');
|
||||
document.body.replaceChildren(host);
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
messageId="message-selected"
|
||||
part={{
|
||||
id: 'part-selected',
|
||||
sessionID: 'session-selected',
|
||||
messageID: 'message-selected',
|
||||
type: 'text',
|
||||
text: content,
|
||||
time: { start: 0, end: 1 },
|
||||
}}
|
||||
isAnimated={false}
|
||||
enableFileReferences={false}
|
||||
/>,
|
||||
);
|
||||
await waitForSettledEffects();
|
||||
});
|
||||
const markdown = host.querySelector<HTMLElement>('[data-markdown-content]');
|
||||
if (!markdown) throw new Error('Expected mounted Markdown content');
|
||||
const originalGetSelection = window.getSelection;
|
||||
Object.defineProperty(window, 'getSelection', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
rangeCount: 1,
|
||||
isCollapsed: false,
|
||||
getRangeAt: () => ({ intersectsNode: (node: Node) => node === markdown }),
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => root.unmount());
|
||||
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
|
||||
} finally {
|
||||
Object.defineProperty(window, 'getSelection', { configurable: true, value: originalGetSelection });
|
||||
clearDetachedMarkdownDomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('defers and batches Mermaid controller initialization after Markdown mount', async () => {
|
||||
const mounted = await mountFixture(fixtureWorkload.rendererCount);
|
||||
const critical = mounted.counts;
|
||||
|
||||
expect(critical.getBoundingClientRectCalls).toBe(0);
|
||||
expect(critical.viewBoxWrites).toBe(0);
|
||||
expect(critical.resizeObserverCreates).toBe(0);
|
||||
expect(mounted.host.querySelectorAll('[data-markdown="mermaid"] svg')).toHaveLength(6);
|
||||
|
||||
await flushDeferredMermaidInitialization();
|
||||
const metrics = {
|
||||
...mounted.operations,
|
||||
renderers: fixtureWorkload.rendererCount,
|
||||
markdownBlocks: mounted.host.querySelectorAll('[data-md-block]').length,
|
||||
mermaidBlocks: mounted.host.querySelectorAll('[data-markdown="mermaid-block"]').length,
|
||||
mermaidRenderedCount: mounted.host.querySelectorAll('[data-mermaid-render]').length,
|
||||
mermaidSvgCount: mounted.host.querySelectorAll('[data-markdown="mermaid"] svg').length,
|
||||
};
|
||||
|
||||
expect(metrics.renderers).toBe(3);
|
||||
expect(metrics.markdownBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.domBlocksPerRenderer);
|
||||
expect(metrics.mermaidBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.mermaidBlocksPerRenderer);
|
||||
expect(metrics.mermaidRenderedCount).toBeGreaterThan(0);
|
||||
expect(metrics.innerHTMLWrites).toBeGreaterThan(0);
|
||||
expect(metrics.querySelectorAllCalls).toBeGreaterThan(0);
|
||||
expect(metrics.appendCalls).toBeGreaterThan(0);
|
||||
expect(metrics.getBoundingClientRectCalls).toBe(metrics.mermaidRenderedCount);
|
||||
expect(metrics.viewBoxWrites).toBe(metrics.mermaidRenderedCount);
|
||||
expect(metrics.resizeObserverCreates).toBe(1);
|
||||
expect(metrics.resizeObserverObserveCalls).toBe(metrics.mermaidRenderedCount);
|
||||
expect(metrics.geometrySequence.lastIndexOf('read')).toBeLessThan(metrics.geometrySequence.indexOf('write'));
|
||||
|
||||
const viewport = mounted.host.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]');
|
||||
if (!viewport || !notifyResize) throw new Error('Expected initialized Mermaid viewport and shared observer');
|
||||
const readsBeforeResize = mounted.operations.getBoundingClientRectCalls;
|
||||
const writesBeforeResize = mounted.operations.viewBoxWrites;
|
||||
notifyResize([{ target: viewport, contentRect: { width: 320, height: 180 } }]);
|
||||
expect(mounted.operations.getBoundingClientRectCalls).toBe(readsBeforeResize);
|
||||
expect(mounted.operations.viewBoxWrites).toBe(writesBeforeResize + 1);
|
||||
console.log(JSON.stringify({ fixture: fixtureWorkload, baseline: metrics }));
|
||||
await act(async () => mounted.root.unmount());
|
||||
});
|
||||
|
||||
test('cancels deferred Mermaid initialization when the renderer unmounts first', async () => {
|
||||
const mounted = await mountFixture(1);
|
||||
await act(async () => mounted.root.unmount());
|
||||
await flushDeferredMermaidInitialization();
|
||||
|
||||
expect(mounted.operations.getBoundingClientRectCalls).toBe(0);
|
||||
expect(mounted.operations.viewBoxWrites).toBe(0);
|
||||
expect(mounted.operations.resizeObserverCreates).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps DOM operation fanout linear when renderer count doubles', async () => {
|
||||
const three = await runFixture(3);
|
||||
const six = await runFixture(6);
|
||||
|
||||
expect(six.mermaidBlocks).toBe(three.mermaidBlocks * 2);
|
||||
expect(six.mermaidRenderedCount).toBe(three.mermaidRenderedCount * 2);
|
||||
expect(six.innerHTMLWrites).toBeLessThanOrEqual(three.innerHTMLWrites * 2 + 6);
|
||||
expect(six.querySelectorAllCalls).toBeLessThanOrEqual(three.querySelectorAllCalls * 2 + 12);
|
||||
expect(six.appendCalls).toBeLessThanOrEqual(three.appendCalls * 2 + 12);
|
||||
expect(six.getBoundingClientRectCalls).toBe(three.getBoundingClientRectCalls * 2);
|
||||
expect(six.viewBoxWrites).toBe(three.viewBoxWrites * 2);
|
||||
expect(three.resizeObserverCreates).toBe(1);
|
||||
expect(six.resizeObserverCreates).toBe(1);
|
||||
expect(six.resizeObserverObserveCalls).toBe(three.resizeObserverObserveCalls * 2);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,377 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { parseFileReference, type ParsedFileReference } from './fileReferenceParser';
|
||||
import { localPathFromFileUrl, parseFileReference, type ParsedFileReference } from './fileReferenceParser';
|
||||
|
||||
const parse = (value: string): ParsedFileReference | null => parseFileReference(value);
|
||||
|
||||
type FakeElement = {
|
||||
childNodes: FakeElement[];
|
||||
children: FakeElement[];
|
||||
parentNode: FakeElement | null;
|
||||
attributes: Map<string, string>;
|
||||
style: { display: string; setProperty: () => void };
|
||||
innerHTML: string;
|
||||
setAttribute: (name: string, value: string) => void;
|
||||
getAttribute: (name: string) => string | null;
|
||||
appendChild: (child: FakeElement) => FakeElement;
|
||||
replaceWith: (replacement: FakeElement) => void;
|
||||
remove: () => void;
|
||||
querySelector: (selector: string) => FakeElement | null;
|
||||
querySelectorAll: <T>(selector: string) => T[];
|
||||
addEventListener: () => void;
|
||||
removeEventListener: () => void;
|
||||
contains: (child: FakeElement) => boolean;
|
||||
isEqualNode: () => boolean;
|
||||
};
|
||||
|
||||
type FakeDocument = { createElement: () => FakeElement };
|
||||
type FakeJsxProps = {
|
||||
ref?: { current: FakeElement | null };
|
||||
children?: FakeElement | FakeElement[];
|
||||
className?: string;
|
||||
'data-markdown-content'?: boolean;
|
||||
};
|
||||
|
||||
let syncRenderCalls = 0;
|
||||
let morphCalls = 0;
|
||||
let decorateCalls = 0;
|
||||
let mermaidRegistryCreates = 0;
|
||||
let mermaidRegistryCleanups = 0;
|
||||
let cachedRendererBlocks: Array<{ id: string; html: string }> | null = null;
|
||||
let renderedRendererBlocks: Array<{ id: string; html: string }> = [];
|
||||
let renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
|
||||
let currentContextVersion = 0;
|
||||
const layoutEffects: Array<() => void> = [];
|
||||
const passiveEffects: Array<() => void | (() => void)> = [];
|
||||
let hookCursor = 0;
|
||||
let hookStates: Array<{ current: null } | undefined> = [];
|
||||
let activeFakeDocument: FakeDocument | null = null;
|
||||
|
||||
const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => {
|
||||
void ownerDocument;
|
||||
let html = '';
|
||||
const element: FakeElement = {
|
||||
childNodes: [],
|
||||
children: [],
|
||||
parentNode: null,
|
||||
attributes: new Map(),
|
||||
style: { display: '', setProperty: () => undefined },
|
||||
get innerHTML() {
|
||||
return html;
|
||||
},
|
||||
set innerHTML(value: string) {
|
||||
html = value;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, value);
|
||||
},
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
},
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.childNodes.push(child);
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
replaceWith(replacement) {
|
||||
if (!this.parentNode) return;
|
||||
const parent = this.parentNode;
|
||||
const index = parent.children.indexOf(this);
|
||||
if (index < 0) return;
|
||||
replacement.parentNode = parent;
|
||||
parent.children[index] = replacement;
|
||||
parent.childNodes[index] = replacement;
|
||||
this.parentNode = null;
|
||||
},
|
||||
remove() {
|
||||
if (!this.parentNode) return;
|
||||
const parent = this.parentNode;
|
||||
parent.children = parent.children.filter((child) => child !== this);
|
||||
parent.childNodes = parent.childNodes.filter((child) => child !== this);
|
||||
this.parentNode = null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '[data-markdown-content]') {
|
||||
return this.children.find((child) => child.getAttribute('data-markdown-content') === '') ?? null;
|
||||
}
|
||||
if (selector === '[data-markdown="mermaid-block"]' && html.includes('data-markdown="mermaid-block"')) {
|
||||
return this;
|
||||
}
|
||||
for (const child of this.children) {
|
||||
const match = child.querySelector(selector);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
contains(child) {
|
||||
return child === this || this.children.some((candidate) => candidate.contains(child));
|
||||
},
|
||||
isEqualNode: () => false,
|
||||
};
|
||||
return element;
|
||||
};
|
||||
|
||||
const installRendererDom = () => {
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousMutationObserver = Object.getOwnPropertyDescriptor(globalThis, 'MutationObserver');
|
||||
const documentStub: FakeDocument = { createElement: () => makeFakeElement(documentStub) };
|
||||
activeFakeDocument = documentStub;
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: documentStub });
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
matchMedia: () => ({ matches: false }),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||
configurable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument);
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousMutationObserver) Object.defineProperty(globalThis, 'MutationObserver', previousMutationObserver);
|
||||
else Reflect.deleteProperty(globalThis, 'MutationObserver');
|
||||
activeFakeDocument = null;
|
||||
};
|
||||
};
|
||||
|
||||
const rendererThemes = [{
|
||||
metadata: { id: 'renderer-test' },
|
||||
colors: {
|
||||
surface: { elevated: '#fff', foreground: '#000', mutedForeground: '#666', muted: '#eee' },
|
||||
interactive: { border: '#ccc' },
|
||||
primary: { base: '#00f' },
|
||||
},
|
||||
}, {
|
||||
metadata: { id: 'renderer-test-next' },
|
||||
colors: {
|
||||
surface: { elevated: '#eee', foreground: '#111', mutedForeground: '#555', muted: '#ddd' },
|
||||
interactive: { border: '#bbb' },
|
||||
primary: { base: '#f00' },
|
||||
},
|
||||
}];
|
||||
let rendererThemeIndex = 0;
|
||||
const rendererTheme = () => rendererThemes[rendererThemeIndex] ?? rendererThemes[0];
|
||||
const rendererUiState = {
|
||||
codeBlockLineWrap: false,
|
||||
mermaidRenderingMode: 'svg',
|
||||
setCodeBlockLineWrap: () => undefined,
|
||||
openContextPreview: () => undefined,
|
||||
};
|
||||
|
||||
const fakeReact = {
|
||||
useCallback: <T>(callback: T): T => {
|
||||
hookCursor += 1;
|
||||
return callback;
|
||||
},
|
||||
useEffect: (effect: () => void | (() => void)) => { passiveEffects.push(effect); },
|
||||
useLayoutEffect: (effect: () => void) => { layoutEffects.push(effect); },
|
||||
useMemo: <T>(factory: () => T): T => {
|
||||
hookCursor += 1;
|
||||
return factory();
|
||||
},
|
||||
useRef: <T>(current: T) => {
|
||||
void current;
|
||||
const index = hookCursor;
|
||||
hookCursor += 1;
|
||||
if (!hookStates[index]) hookStates[index] = { current: null };
|
||||
// SAFETY: this test hook preserves one mutable ref slot per hook index.
|
||||
return hookStates[index] as { current: T };
|
||||
},
|
||||
memo: <T>(component: T): T => component,
|
||||
};
|
||||
|
||||
const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => {
|
||||
const ref = props?.ref;
|
||||
// SAFETY: the renderer test installs the typed fake document before JSX is
|
||||
// evaluated; this branch only supplies its fake element factory.
|
||||
const fakeDocument = activeFakeDocument;
|
||||
if (!fakeDocument) throw new Error('Renderer fake document is not installed');
|
||||
const element = ref?.current ?? makeFakeElement(fakeDocument);
|
||||
if (!ref?.current) {
|
||||
element.childNodes.length = 0;
|
||||
element.children.length = 0;
|
||||
}
|
||||
if (props) {
|
||||
if (ref) ref.current = element;
|
||||
if (props.className) element.setAttribute('class', props.className);
|
||||
if (props['data-markdown-content']) element.setAttribute('data-markdown-content', '');
|
||||
}
|
||||
const jsxChildren = props?.children;
|
||||
const allChildren = jsxChildren === undefined ? children : Array.isArray(jsxChildren) ? jsxChildren : [jsxChildren];
|
||||
for (const child of allChildren) {
|
||||
if (child) element.appendChild(child);
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
mock.module('react', () => ({ default: fakeReact }));
|
||||
mock.module('react/jsx-runtime', () => ({ jsx: fakeJsx, jsxs: fakeJsx, Fragment: 'fragment' }));
|
||||
mock.module('react/jsx-dev-runtime', () => ({ jsxDEV: fakeJsx, Fragment: 'fragment' }));
|
||||
mock.module('beautiful-mermaid', () => ({
|
||||
renderMermaidASCII: () => '',
|
||||
renderMermaidSVG: (_source: string, colors: { bg: string }) => colors.bg,
|
||||
}));
|
||||
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
|
||||
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => `${key}:${currentContextVersion}` }) }));
|
||||
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
|
||||
mock.module('@/lib/url', () => ({
|
||||
getUrlScheme: () => null,
|
||||
isAppLinkUrl: () => false,
|
||||
isExternalHttpUrl: () => false,
|
||||
openConfirmedAppLinkUrl: async () => false,
|
||||
openExternalUrl: async () => undefined,
|
||||
}));
|
||||
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => ({ currentTheme: rendererTheme() }) }));
|
||||
mock.module('@/lib/theme/themes', () => ({ getDefaultTheme: () => rendererTheme() }));
|
||||
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: FakeElement | FakeElement[] }) => children }));
|
||||
type RendererUiSelectorResult = boolean | string | (() => void);
|
||||
const fakeUseUIStore = Object.assign(
|
||||
(selector: (state: typeof rendererUiState) => RendererUiSelectorResult) => selector(rendererUiState),
|
||||
{ getState: () => rendererUiState },
|
||||
);
|
||||
mock.module('@/stores/useUIStore', () => ({ useUIStore: fakeUseUIStore }));
|
||||
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
|
||||
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
|
||||
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
|
||||
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
|
||||
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
|
||||
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '' }));
|
||||
mock.module('./markdown/markdownCore', () => ({
|
||||
getCachedMarkdownBlocks: () => cachedRendererBlocks,
|
||||
renderMarkdownBlocks: () => renderMarkdownBlocksForTest(),
|
||||
renderMarkdownSync: () => {
|
||||
syncRenderCalls += 1;
|
||||
return '<p>cold</p>';
|
||||
},
|
||||
}));
|
||||
mock.module('./markdown/markdownTheme', () => ({ ensureMarkdownShikiTheme: () => undefined }));
|
||||
mock.module('./markdown/markdownSyntaxVars', () => ({ getMarkdownSyntaxVars: () => ({}) }));
|
||||
mock.module('./markdown/detachedMarkdownDomCache', () => ({
|
||||
detachedMarkdownDomCache: {
|
||||
take: () => null,
|
||||
store: () => undefined,
|
||||
},
|
||||
}));
|
||||
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'runtime' }));
|
||||
type TestDecorateContext = {
|
||||
labels: { copy: string };
|
||||
codeBlockLineWrap: boolean;
|
||||
renderMermaid: (source: string) => { svg?: string };
|
||||
};
|
||||
mock.module('./markdown/decorate', () => ({
|
||||
attachMarkdownInteractions: () => () => undefined,
|
||||
applyMarkdownCodeBlockWrapState: () => undefined,
|
||||
decorateMarkdown: (root: FakeElement, ctx: TestDecorateContext) => {
|
||||
decorateCalls += 1;
|
||||
if (root.getAttribute('data-test-decoration-marker') === 'true') return;
|
||||
root.setAttribute('data-test-decoration-marker', 'true');
|
||||
root.setAttribute(
|
||||
'data-test-decoration',
|
||||
`${ctx.labels.copy}|${ctx.codeBlockLineWrap}|${ctx.renderMermaid('test').svg ?? ''}`,
|
||||
);
|
||||
},
|
||||
getMarkdownCodeText: () => '',
|
||||
}));
|
||||
mock.module('./markdown/textPosition', () => ({ findTextPosition: () => null }));
|
||||
mock.module('./markdown/mermaidViewer', () => ({
|
||||
createMermaidViewerRegistry: () => {
|
||||
mermaidRegistryCreates += 1;
|
||||
return {
|
||||
refresh: () => undefined,
|
||||
cleanup: () => { mermaidRegistryCleanups += 1; },
|
||||
};
|
||||
},
|
||||
MERMAID_BLOCK_SELECTOR: '[data-markdown="mermaid-block"]',
|
||||
shouldRefreshMermaidViewers: (container: Pick<FakeElement, 'querySelector'>) => container.querySelector('[data-markdown="mermaid-block"]') !== null,
|
||||
}));
|
||||
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
|
||||
mock.module('morphdom', () => ({ default: () => { morphCalls += 1; } }));
|
||||
|
||||
const { MarkdownRenderer } = await import('./MarkdownRendererImpl');
|
||||
|
||||
const resetRendererTestState = () => {
|
||||
cachedRendererBlocks = null;
|
||||
renderedRendererBlocks = [];
|
||||
renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
|
||||
syncRenderCalls = 0;
|
||||
morphCalls = 0;
|
||||
decorateCalls = 0;
|
||||
mermaidRegistryCreates = 0;
|
||||
mermaidRegistryCleanups = 0;
|
||||
hookCursor = 0;
|
||||
hookStates = [];
|
||||
layoutEffects.length = 0;
|
||||
passiveEffects.length = 0;
|
||||
currentContextVersion = 0;
|
||||
rendererThemeIndex = 0;
|
||||
rendererUiState.codeBlockLineWrap = false;
|
||||
};
|
||||
|
||||
const beginRendererRender = () => {
|
||||
hookCursor = 0;
|
||||
return renderMarkdownForTest();
|
||||
};
|
||||
|
||||
const rendererRoot = (value: ReturnType<typeof renderMarkdownForTest>): FakeElement => {
|
||||
if (!(value instanceof Object) || !('childNodes' in value) || !('getAttribute' in value)) {
|
||||
throw new Error('Renderer test did not return its fake JSX root');
|
||||
}
|
||||
// SAFETY: the structural check confirms this ReactNode is the object
|
||||
// returned by the mocked JSX runtime.
|
||||
const candidate = value as object;
|
||||
// SAFETY: the mocked JSX runtime creates the complete FakeElement shape.
|
||||
return candidate as FakeElement;
|
||||
};
|
||||
|
||||
const runRendererLayoutEffects = () => {
|
||||
const pending = layoutEffects.splice(0);
|
||||
for (const effect of pending) effect();
|
||||
};
|
||||
|
||||
const runRendererPassiveEffects = () => passiveEffects.splice(0).map((effect) => effect());
|
||||
|
||||
const findBlock = (root: FakeElement, id: string): FakeElement | null => {
|
||||
if (root.getAttribute('data-md-id') === id) return root;
|
||||
for (const child of root.children) {
|
||||
const match = findBlock(child, id);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderMarkdownForTest = () => MarkdownRenderer({
|
||||
content: 'cached markdown',
|
||||
messageId: 'message-1',
|
||||
isAnimated: false,
|
||||
isStreaming: false,
|
||||
});
|
||||
|
||||
const withRendererDom = async (run: () => void | Promise<void>): Promise<void> => {
|
||||
const restoreDom = installRendererDom();
|
||||
const previousThemeIndex = rendererThemeIndex;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
rendererThemeIndex = previousThemeIndex;
|
||||
restoreDom();
|
||||
}
|
||||
};
|
||||
|
||||
describe('parseFileReference', () => {
|
||||
test('returns null for empty or whitespace input', () => {
|
||||
expect(parse('')).toBeNull();
|
||||
@@ -72,11 +440,7 @@ describe('parseFileReference', () => {
|
||||
});
|
||||
|
||||
test('preserves line:col form (does not interpret as range)', () => {
|
||||
expect(parse('src/foo.ts:42:8')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
column: 8,
|
||||
});
|
||||
expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
|
||||
});
|
||||
|
||||
test('preserves hash form', () => {
|
||||
@@ -96,3 +460,134 @@ describe('parseFileReference', () => {
|
||||
expect(result).toEqual({ path: 'src/foo.ts', line: 42, endLine: 58 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('localPathFromFileUrl', () => {
|
||||
test('converts local file URLs to absolute paths', () => {
|
||||
expect(localPathFromFileUrl('file:///private/tmp/report%20viewer.html')).toBe('/private/tmp/report viewer.html');
|
||||
expect(localPathFromFileUrl('file://localhost/private/tmp/REPORT.md')).toBe('/private/tmp/REPORT.md');
|
||||
expect(localPathFromFileUrl('file:///C:/Users/test/report.html')).toBe('C:/Users/test/report.html');
|
||||
});
|
||||
|
||||
test('rejects non-file URLs and remote file hosts', () => {
|
||||
expect(localPathFromFileUrl('https://example.com/report.html')).toBeNull();
|
||||
expect(localPathFromFileUrl('file://remote-host/share/report.html')).toBeNull();
|
||||
expect(localPathFromFileUrl('file:///tmp/bad%ZZpath')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownRenderer warm settled path', () => {
|
||||
test('installs cached blocks without sync fallback and skips same-ID morph', async () => {
|
||||
await withRendererDom(async () => {
|
||||
resetRendererTestState();
|
||||
cachedRendererBlocks = [{ id: 'full:cached', html: '<p>cached</p>' }];
|
||||
renderedRendererBlocks = cachedRendererBlocks;
|
||||
syncRenderCalls = 0;
|
||||
morphCalls = 0;
|
||||
decorateCalls = 0;
|
||||
|
||||
// SAFETY: the test JSX adapter returns the fake element assigned to the
|
||||
// renderer container ref and exposes the DOM members used below.
|
||||
const root = rendererRoot(beginRendererRender());
|
||||
runRendererLayoutEffects();
|
||||
expect(syncRenderCalls).toBe(0);
|
||||
const block = findBlock(root, 'full:cached');
|
||||
expect(block).not.toBeNull();
|
||||
expect(block?.innerHTML).toBe('<p>cached</p>');
|
||||
expect(block?.getAttribute('data-md-block')).toBe('');
|
||||
expect(block?.getAttribute('data-md-id')).toBe('full:cached');
|
||||
expect(block?.style.display).toBe('contents');
|
||||
expect(decorateCalls).toBe(1);
|
||||
|
||||
runRendererPassiveEffects();
|
||||
await Promise.resolve();
|
||||
expect(morphCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('recreates the Mermaid registry after StrictMode-like cleanup without remounting blocks', () => {
|
||||
return withRendererDom(() => {
|
||||
resetRendererTestState();
|
||||
const mermaidHtml = '<div data-markdown="mermaid-block"><svg></svg></div>';
|
||||
cachedRendererBlocks = [{ id: 'full:mermaid', html: mermaidHtml }];
|
||||
renderedRendererBlocks = cachedRendererBlocks;
|
||||
mermaidRegistryCreates = 0;
|
||||
mermaidRegistryCleanups = 0;
|
||||
morphCalls = 0;
|
||||
|
||||
const root = rendererRoot(beginRendererRender());
|
||||
runRendererLayoutEffects();
|
||||
expect(mermaidRegistryCreates).toBe(1);
|
||||
const cleanups = runRendererPassiveEffects();
|
||||
for (const cleanup of cleanups) cleanup?.();
|
||||
expect(mermaidRegistryCleanups).toBe(1);
|
||||
|
||||
beginRendererRender();
|
||||
runRendererLayoutEffects();
|
||||
expect(mermaidRegistryCreates).toBe(2);
|
||||
expect(findBlock(root, 'full:mermaid')).not.toBeNull();
|
||||
expect(morphCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('redecorates a same-ID block when decoration context changes before async completion', async () => {
|
||||
await withRendererDom(async () => {
|
||||
resetRendererTestState();
|
||||
cachedRendererBlocks = [{
|
||||
id: 'full:context',
|
||||
html: '<div data-markdown="mermaid-block"><p>cached</p></div>',
|
||||
}];
|
||||
renderedRendererBlocks = cachedRendererBlocks;
|
||||
|
||||
const root = rendererRoot(beginRendererRender());
|
||||
runRendererLayoutEffects();
|
||||
const block = findBlock(root, 'full:context');
|
||||
const firstDecorationId = block?.getAttribute('data-md-decoration-id');
|
||||
expect(firstDecorationId).not.toBeNull();
|
||||
const firstDecorateCalls = decorateCalls;
|
||||
|
||||
rendererThemeIndex = 1;
|
||||
currentContextVersion = 1;
|
||||
rendererUiState.codeBlockLineWrap = true;
|
||||
beginRendererRender();
|
||||
runRendererLayoutEffects();
|
||||
runRendererPassiveEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(decorateCalls).toBeGreaterThan(firstDecorateCalls);
|
||||
expect(syncRenderCalls).toBe(0);
|
||||
expect(morphCalls).toBe(0);
|
||||
const updatedBlock = findBlock(root, 'full:context');
|
||||
expect(updatedBlock?.getAttribute('data-md-decoration-id')).not.toBe(firstDecorationId);
|
||||
expect(updatedBlock?.getAttribute('data-test-decoration')).toContain(':1|true|#eee');
|
||||
expect(updatedBlock?.getAttribute('data-test-decoration-marker')).toBe('true');
|
||||
expect(mermaidRegistryCleanups).toBeGreaterThan(0);
|
||||
expect(mermaidRegistryCreates).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects an older async render after a newer layout commit', async () => {
|
||||
await withRendererDom(async () => {
|
||||
resetRendererTestState();
|
||||
cachedRendererBlocks = [{ id: 'full:initial', html: '<p>initial</p>' }];
|
||||
let resolveOldRender: ((blocks: Array<{ id: string; html: string }>) => void) | undefined;
|
||||
const oldRender = new Promise<Array<{ id: string; html: string }>>((resolve) => {
|
||||
resolveOldRender = resolve;
|
||||
});
|
||||
renderMarkdownBlocksForTest = () => oldRender;
|
||||
|
||||
beginRendererRender();
|
||||
runRendererLayoutEffects();
|
||||
runRendererPassiveEffects();
|
||||
|
||||
cachedRendererBlocks = [{ id: 'full:new', html: '<p>new</p>' }];
|
||||
beginRendererRender();
|
||||
runRendererLayoutEffects();
|
||||
expect(resolveOldRender).toBeDefined();
|
||||
resolveOldRender?.([{ id: 'full:old-late', html: '<p>old late</p>' }]);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(morphCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -4,11 +4,12 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { openAppLinkWithConfirmation } from './appLinkConfirmation';
|
||||
import { attachAppLinkInteractions } from './appLinkInteractions';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -19,8 +20,14 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/l
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
||||
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
||||
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
|
||||
import {
|
||||
getCachedMarkdownBlocks,
|
||||
renderMarkdownBlocks,
|
||||
renderMarkdownSync,
|
||||
type MarkdownImageMode,
|
||||
} from './markdown/markdownCore';
|
||||
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
|
||||
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
||||
import {
|
||||
attachMarkdownInteractions,
|
||||
applyMarkdownCodeBlockWrapState,
|
||||
@@ -36,11 +43,15 @@ import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMerma
|
||||
import {
|
||||
BLOCK_PATH_TOKEN_RE,
|
||||
isAbsoluteReferencePath,
|
||||
localPathFromFileUrl,
|
||||
normalizeReferencePath,
|
||||
parseFileReference,
|
||||
type ParsedFileReference,
|
||||
} from './fileReferenceParser';
|
||||
import { fileReferenceExists } from './fileReferenceStat';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
const useCurrentMermaidTheme = () => {
|
||||
const themeSystem = useOptionalThemeSystem();
|
||||
@@ -53,7 +64,7 @@ const useCurrentMermaidTheme = () => {
|
||||
: fallbackLight);
|
||||
};
|
||||
|
||||
const useExternalLinkInteractions = ({
|
||||
const useLinkInteractions = ({
|
||||
containerRef,
|
||||
enabled,
|
||||
}: {
|
||||
@@ -61,48 +72,16 @@ const useExternalLinkInteractions = ({
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = target.closest('a[href]');
|
||||
if (!(anchor instanceof HTMLAnchorElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute('href') ?? '';
|
||||
if (!isExternalHttpUrl(href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void openExternalUrl(href);
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
return () => {
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
return attachAppLinkInteractions(container, {
|
||||
allowExternalHttp: enabled !== false,
|
||||
openAppLink: (href) => void openAppLinkWithConfirmation(href),
|
||||
openExternalHttp: (href) => void openExternalUrl(href),
|
||||
});
|
||||
}, [containerRef, enabled]);
|
||||
};
|
||||
|
||||
@@ -149,19 +128,9 @@ const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
|
||||
// output. The regex is defined in `./fileReferenceParser`; the inline-code
|
||||
// pipeline reads full text content rather than using this regex.
|
||||
const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
|
||||
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
|
||||
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
|
||||
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
|
||||
const FILE_REFERENCE_LINK_LIMIT = 80;
|
||||
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
|
||||
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
|
||||
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
|
||||
let activeFileReferenceStatCount = 0;
|
||||
const pendingFileReferenceStats: Array<() => void> = [];
|
||||
|
||||
const getFileReferenceStatCacheMax = (): number => (
|
||||
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
|
||||
);
|
||||
|
||||
const getFileReferenceLinkLimit = (): number => (
|
||||
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
|
||||
@@ -244,6 +213,10 @@ const unwrapBlockCodePathTokens = (container: HTMLElement): void => {
|
||||
const extractPathCandidateFromElement = (element: HTMLElement): string => {
|
||||
if (element.tagName.toLowerCase() === 'a') {
|
||||
const href = element.getAttribute('href')?.trim();
|
||||
const fileUrlPath = href ? localPathFromFileUrl(href) : null;
|
||||
if (fileUrlPath) {
|
||||
return fileUrlPath;
|
||||
}
|
||||
if (href && isLikelyFilePath(href)) {
|
||||
return href;
|
||||
}
|
||||
@@ -355,61 +328,6 @@ const getResolvedReference = (rawValue: string, effectiveDirectory: string): (Pa
|
||||
};
|
||||
};
|
||||
|
||||
const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
|
||||
const normalizedPath = normalizePath(resolvedPath);
|
||||
if (!normalizedPath) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
|
||||
if (cached) {
|
||||
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
|
||||
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const request = new Promise<boolean>((resolve) => {
|
||||
const run = () => {
|
||||
activeFileReferenceStatCount += 1;
|
||||
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
|
||||
resolve(payload?.exists !== false);
|
||||
})
|
||||
.catch(() => resolve(false))
|
||||
.finally(() => {
|
||||
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
|
||||
pendingFileReferenceStats.shift()?.();
|
||||
});
|
||||
};
|
||||
|
||||
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingFileReferenceStats.push(run);
|
||||
});
|
||||
|
||||
const maxCacheEntries = getFileReferenceStatCacheMax();
|
||||
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
|
||||
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
|
||||
if (typeof oldest !== 'string') {
|
||||
break;
|
||||
}
|
||||
FILE_REFERENCE_STAT_CACHE.delete(oldest);
|
||||
}
|
||||
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
|
||||
return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
|
||||
};
|
||||
@@ -434,6 +352,13 @@ const useFileReferenceInteractions = ({
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
// Wait for the real directory: annotating against an empty/fallback
|
||||
// directory issues stat probes under the wrong cache key (and the wrong
|
||||
// server directory), and the pass reruns anyway once the directory
|
||||
// resolves — every link ended up verified twice.
|
||||
if (enabled && !effectiveDirectory) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
|
||||
// On mobile surfaces, file-reference highlighting is disabled entirely — not
|
||||
@@ -487,6 +412,19 @@ const useFileReferenceInteractions = ({
|
||||
};
|
||||
|
||||
const annotateFileLinks = () => {
|
||||
annotationWriteDepth += 1;
|
||||
try {
|
||||
annotateFileLinksInner();
|
||||
} finally {
|
||||
// Let the mutation events from our own writes flush before the
|
||||
// observer starts listening for real content changes again.
|
||||
queueMicrotask(() => {
|
||||
annotationWriteDepth -= 1;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const annotateFileLinksInner = () => {
|
||||
if (fileReferencesEnabled) {
|
||||
wrapBlockCodePathTokens(container);
|
||||
}
|
||||
@@ -515,7 +453,7 @@ const useFileReferenceInteractions = ({
|
||||
&& !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
|
||||
const existsPromise = canGrantOutsideFile
|
||||
? Promise.resolve(true)
|
||||
: fileReferenceExists(resolved.resolvedPath);
|
||||
: fileReferenceExists(resolved.resolvedPath, effectiveDirectory);
|
||||
|
||||
void existsPromise.then((exists) => {
|
||||
if (cancelled || !exists || !container.contains(candidate)) {
|
||||
@@ -615,7 +553,12 @@ const useFileReferenceInteractions = ({
|
||||
|
||||
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
|
||||
|
||||
// Our own annotation writes (path-token wrapping, attribute updates) fire
|
||||
// childList mutations too; observing them re-ran the whole pass — every
|
||||
// link was scanned and verified twice per render.
|
||||
let annotationWriteDepth = 0;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (annotationWriteDepth > 0) return;
|
||||
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
|
||||
});
|
||||
observer.observe(container, {
|
||||
@@ -748,6 +691,19 @@ const useMermaidInlineInteractions = ({
|
||||
// so a stable diagram is laid out once and served from cache thereafter.
|
||||
const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>();
|
||||
const MERMAID_RENDER_CACHE_MAX = 100;
|
||||
const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id';
|
||||
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
|
||||
let nextMarkdownDecorationId = 0;
|
||||
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
|
||||
|
||||
const getMarkdownDecorationId = (ctx: DecorateContext): string => {
|
||||
const existing = MARKDOWN_DECORATION_IDS.get(ctx);
|
||||
if (existing) return existing;
|
||||
const id = `decoration-${nextMarkdownDecorationId}`;
|
||||
nextMarkdownDecorationId += 1;
|
||||
MARKDOWN_DECORATION_IDS.set(ctx, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const cachedMermaidRender = (key: string, compute: () => MermaidRender): MermaidRender => {
|
||||
const existing = MERMAID_RENDER_CACHE.get(key);
|
||||
@@ -829,22 +785,31 @@ const useMorphdomMarkdown = ({
|
||||
containerRef,
|
||||
text,
|
||||
streaming,
|
||||
cacheKey,
|
||||
imageMode = 'inline',
|
||||
syntaxVars,
|
||||
ctx,
|
||||
domCacheKey,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
cacheKey: string;
|
||||
imageMode?: MarkdownImageMode;
|
||||
syntaxVars: Record<string, string>;
|
||||
ctx: DecorateContext;
|
||||
domCacheKey?: DetachedMarkdownDomKey | null;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
ensureMarkdownShikiTheme();
|
||||
}, []);
|
||||
|
||||
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
|
||||
const renderRevisionRef = React.useRef(0);
|
||||
// Only DOM that was actually restored or completed by the async pipeline is
|
||||
// eligible for capture. A fallback from an earlier content revision is not.
|
||||
const mountedDomRef = React.useRef<{
|
||||
key: DetachedMarkdownDomKey;
|
||||
copiedLabel: string;
|
||||
} | null>(null);
|
||||
const refreshMermaidViewers = React.useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
@@ -860,6 +825,63 @@ const useMorphdomMarkdown = ({
|
||||
mermaidViewerRef.current.refresh();
|
||||
}, [containerRef]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
renderRevisionRef.current += 1;
|
||||
mountedDomRef.current = null;
|
||||
}, [ctx, imageMode, streaming, text]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!domCacheKey) return;
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target || target.childNodes.length > 0) return;
|
||||
|
||||
const cached = detachedMarkdownDomCache.take(domCacheKey);
|
||||
if (cached) {
|
||||
target.appendChild(cached);
|
||||
const decorationId = getMarkdownDecorationId(ctx);
|
||||
for (const block of Array.from(target.children)) {
|
||||
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
}
|
||||
for (const [key, value] of Object.entries(syntaxVars)) target.style.setProperty(key, value);
|
||||
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
|
||||
mountedDomRef.current = {
|
||||
key: domCacheKey,
|
||||
copiedLabel: ctx.labels.copied,
|
||||
};
|
||||
streamPerfCount('ui.markdown_renderer.dom_cache.hit');
|
||||
}
|
||||
}, [containerRef, ctx, domCacheKey, syntaxVars, text.length]);
|
||||
|
||||
// Restoration follows the cache identity above, but capture must only happen
|
||||
// when this renderer lifecycle ends. Combining both in one keyed effect would
|
||||
// detach the live DOM on ordinary content, theme, or locale updates.
|
||||
React.useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target) return;
|
||||
return () => {
|
||||
const mountedDom = mountedDomRef.current;
|
||||
if (!mountedDom) return;
|
||||
// Viewer controllers and transient interaction state belong to the
|
||||
// current renderer instance and must not cross the cache boundary.
|
||||
if (target.childNodes.length === 0 || shouldRefreshMermaidViewers(target)) return;
|
||||
if (Array.from(target.children).some((block) => !block.hasAttribute('data-md-id'))) return;
|
||||
if (target.querySelector('[data-md-copy-pending]')) return;
|
||||
const selection = window.getSelection();
|
||||
if (selection?.rangeCount && !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(target)) return;
|
||||
const openMenu = target.querySelector<HTMLElement>('[data-md-menu]:not(.hidden)');
|
||||
const copiedButton = Array.from(target.querySelectorAll<HTMLButtonElement>('[data-md-action]'))
|
||||
.some((button) => button.getAttribute('title') === mountedDom.copiedLabel);
|
||||
if (openMenu || copiedButton) return;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(...Array.from(target.childNodes));
|
||||
detachedMarkdownDomCache.store({ ...mountedDom.key, fragment });
|
||||
streamPerfCount('ui.markdown_renderer.dom_cache.capture');
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
// Synchronous first paint: while the async parse is in-flight, show escaped
|
||||
// plain text immediately so there is no blank frame on initial mount. Only
|
||||
// runs when the target is empty — subsequent updates keep the prior rich DOM
|
||||
@@ -869,25 +891,40 @@ const useMorphdomMarkdown = ({
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target) return;
|
||||
const decorationId = getMarkdownDecorationId(ctx);
|
||||
if (text && target.childNodes.length === 0) {
|
||||
const block = document.createElement('div');
|
||||
block.setAttribute('data-md-block', '');
|
||||
// `display:contents` keeps margin-collapsing/spacing identical to a flat
|
||||
// HTML body — the wrapper exists only for per-block reconciliation.
|
||||
block.style.display = 'contents';
|
||||
block.innerHTML = renderMarkdownSync(text);
|
||||
// Decorate synchronously too: wrap code blocks in their framed card,
|
||||
// mark inline code, build table controls, etc. The async pass re-decorates
|
||||
// its own DOM before morphing, so without this the first paint shows bare
|
||||
// <pre>/tables that "snap" into their decorated form a tick later. Matching
|
||||
// the structure here keeps the async morph to syntax colors only.
|
||||
decorateMarkdown(block, ctx);
|
||||
target.appendChild(block);
|
||||
if (shouldRefreshMermaidViewers(block)) {
|
||||
refreshMermaidViewers();
|
||||
const cachedBlocks = !streaming ? getCachedMarkdownBlocks(text, imageMode) : null;
|
||||
if (cachedBlocks) {
|
||||
let hasMermaidBlock = false;
|
||||
for (const cachedBlock of cachedBlocks) {
|
||||
const block = document.createElement('div');
|
||||
block.setAttribute('data-md-block', '');
|
||||
block.style.display = 'contents';
|
||||
block.innerHTML = cachedBlock.html;
|
||||
decorateMarkdown(block, ctx);
|
||||
block.setAttribute('data-md-id', cachedBlock.id);
|
||||
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
hasMermaidBlock ||= shouldRefreshMermaidViewers(block);
|
||||
target.appendChild(block);
|
||||
}
|
||||
if (hasMermaidBlock) refreshMermaidViewers();
|
||||
} else {
|
||||
const block = document.createElement('div');
|
||||
block.setAttribute('data-md-block', '');
|
||||
block.style.display = 'contents';
|
||||
block.innerHTML = renderMarkdownSync(text, imageMode);
|
||||
decorateMarkdown(block, ctx);
|
||||
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
target.appendChild(block);
|
||||
if (shouldRefreshMermaidViewers(block)) refreshMermaidViewers();
|
||||
}
|
||||
} else if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(target)) {
|
||||
// StrictMode re-runs this setup after the cleanup probe. The DOM remains,
|
||||
// but the viewer registry does not, so recreate it without reinstalling
|
||||
// or re-decorating ordinary blocks.
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
}, [containerRef, text, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
mermaidViewerRef.current?.cleanup();
|
||||
@@ -899,27 +936,70 @@ const useMorphdomMarkdown = ({
|
||||
if (!container) return;
|
||||
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
let active = true;
|
||||
const renderRevision = renderRevisionRef.current;
|
||||
const decorationId = getMarkdownDecorationId(ctx);
|
||||
|
||||
void renderMarkdownBlocks(text, streaming, cacheKey).then((blocks) => {
|
||||
if (!active) return;
|
||||
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
|
||||
if (!active || renderRevisionRef.current !== renderRevision) return;
|
||||
const existing = Array.from(target.children) as HTMLElement[];
|
||||
|
||||
// Reconcile per block: only re-morph blocks whose content changed, leaving
|
||||
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded
|
||||
// to the trailing (growing) block instead of the whole message.
|
||||
let enteredThisPass = 0;
|
||||
blocks.forEach((block, index) => {
|
||||
let el = existing[index];
|
||||
let isNewBlock = false;
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.setAttribute('data-md-block', '');
|
||||
el.style.display = 'contents';
|
||||
target.appendChild(el);
|
||||
isNewBlock = true;
|
||||
}
|
||||
if (el.getAttribute('data-md-id') === block.id) {
|
||||
if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
|
||||
const hasMermaidBlock = shouldRefreshMermaidViewers(el);
|
||||
if (hasMermaidBlock) {
|
||||
mermaidViewerRef.current?.cleanup();
|
||||
mermaidViewerRef.current = null;
|
||||
}
|
||||
const replacement = document.createElement('div');
|
||||
replacement.setAttribute('data-md-block', '');
|
||||
replacement.style.display = 'contents';
|
||||
replacement.innerHTML = block.html;
|
||||
decorateMarkdown(replacement, ctx);
|
||||
replacement.setAttribute('data-md-id', block.id);
|
||||
replacement.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
el.replaceWith(replacement);
|
||||
if (hasMermaidBlock || shouldRefreshMermaidViewers(replacement)) refreshMermaidViewers();
|
||||
}
|
||||
if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(el)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (el.getAttribute('data-md-id') === block.id) return;
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = block.html;
|
||||
decorateMarkdown(temp, ctx);
|
||||
if (isNewBlock && streaming && index > 0) {
|
||||
// A freshly committed block enters with a short reveal. The class
|
||||
// goes on the block's children — the wrapper is display:contents
|
||||
// and cannot animate — and the transform never changes layout, so
|
||||
// row measurement stays exact. Skipped for the first block so a
|
||||
// full initial render does not shimmer. Several blocks committed
|
||||
// in one tick cascade with a small stagger instead of popping in
|
||||
// together.
|
||||
const delayMs = Math.min(enteredThisPass, 4) * 55;
|
||||
enteredThisPass += 1;
|
||||
for (const child of Array.from(temp.children)) {
|
||||
child.classList.add('oc-md-block-enter');
|
||||
if (delayMs > 0 && child instanceof HTMLElement) {
|
||||
child.style.setProperty('--oc-md-enter-delay', `${delayMs}ms`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const hadMermaidBlock = shouldRefreshMermaidViewers(el);
|
||||
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
|
||||
morphdom(el, temp, {
|
||||
@@ -927,12 +1007,12 @@ const useMorphdomMarkdown = ({
|
||||
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
|
||||
});
|
||||
el.setAttribute('data-md-id', block.id);
|
||||
el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
||||
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
});
|
||||
|
||||
// Remove any trailing block elements no longer present.
|
||||
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
|
||||
let removedMermaidBlock = false;
|
||||
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
|
||||
@@ -945,13 +1025,15 @@ const useMorphdomMarkdown = ({
|
||||
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
|
||||
mountedDomRef.current = domCacheKey
|
||||
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
|
||||
: null;
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -1028,13 +1110,49 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
enabled: enableFileReferences && !isStreaming,
|
||||
});
|
||||
useExternalLinkInteractions({ containerRef });
|
||||
useLinkInteractions({ containerRef });
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
const { locale } = useI18n();
|
||||
const imageMode: MarkdownImageMode = variant === 'assistant' ? 'label' : 'inline';
|
||||
const settledPart = part
|
||||
&& (part.type === 'text' || part.type === 'reasoning')
|
||||
&& part.time?.end !== undefined
|
||||
? part
|
||||
: null;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
// Memoized on scalar identities, not the part object: sync-store reducers
|
||||
// recreate part objects on unrelated updates, and an object-identity dep
|
||||
// re-ran the async render pipeline for identical content.
|
||||
const settledSessionID = settledPart?.sessionID;
|
||||
const settledMessageID = settledPart?.messageID;
|
||||
const settledPartID = settledPart?.id;
|
||||
const domCacheKey = React.useMemo<DetachedMarkdownDomKey | null>(() => {
|
||||
// Streaming, unfinished, oversized, and identity-less Markdown continues
|
||||
// through the normal rendering pipeline and never retains detached DOM.
|
||||
if (isStreaming || !settledSessionID || !settledMessageID || !settledPartID || content.length === 0 || content.length > MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS) return null;
|
||||
// content.length is a cheap fingerprint: an edited or reverted part that
|
||||
// re-materializes under the same id must not restore the old DOM.
|
||||
return {
|
||||
scope: `${runtimeKey}\0${settledSessionID}`,
|
||||
id: `${settledMessageID}\0${settledPartID}\0${imageMode}\0${content.length}`,
|
||||
locale,
|
||||
directory: effectiveDirectory,
|
||||
};
|
||||
}, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledSessionID, settledMessageID, settledPartID]);
|
||||
// Identity for the fade-in wrapper: a new part/message restarts the animation.
|
||||
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
|
||||
useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx });
|
||||
useMorphdomMarkdown({
|
||||
containerRef,
|
||||
text: content,
|
||||
streaming: live,
|
||||
imageMode,
|
||||
syntaxVars,
|
||||
ctx,
|
||||
domCacheKey,
|
||||
});
|
||||
|
||||
const markdownContent = (
|
||||
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
|
||||
@@ -1044,7 +1162,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
|
||||
if (isAnimated) {
|
||||
return (
|
||||
<FadeInOnReveal key={cacheKey} skipAnimation={skipFadeIn}>
|
||||
<FadeInOnReveal key={fadeKey} skipAnimation={skipFadeIn}>
|
||||
{markdownContent}
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
@@ -1071,6 +1189,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
content: string;
|
||||
className?: string;
|
||||
variant?: MarkdownVariant;
|
||||
// App links remain confirmed even where ordinary HTTP link handling is off.
|
||||
disableLinkSafety?: boolean;
|
||||
stripFrontmatter?: boolean;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
@@ -1112,7 +1231,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
enabled: enableFileReferences,
|
||||
});
|
||||
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
||||
useLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
|
||||
@@ -1121,7 +1240,6 @@ const SimpleMarkdownRendererImpl: React.FC<{
|
||||
containerRef,
|
||||
text: renderedContent,
|
||||
streaming: false,
|
||||
cacheKey: `simple:${variant}`,
|
||||
syntaxVars,
|
||||
ctx,
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,8 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
|
||||
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
@@ -57,6 +58,28 @@ type MobileVariantTarget = { providerId: string; modelId: string };
|
||||
const buildModelRefKey = (providerID: string, modelID: string) => `${providerID}:${modelID}`;
|
||||
const MAX_INLINE_MOBILE_VARIANT_OPTIONS = 6;
|
||||
|
||||
const AgentDescriptionTooltip: React.FC<{
|
||||
description?: string;
|
||||
children: React.ReactElement;
|
||||
}> = ({ description, children }) => {
|
||||
if (!description) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={450}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground">{description}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
@@ -301,7 +324,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||
const currentVariant = useConfigStore((state) => state.currentVariant);
|
||||
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
|
||||
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
|
||||
const currentVariant = currentVariantSelection.override ?? undefined;
|
||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
|
||||
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
|
||||
@@ -309,6 +334,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
const setModel = useConfigStore((state) => state.setModel);
|
||||
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
|
||||
const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride);
|
||||
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
|
||||
const setAgent = useConfigStore((state) => state.setAgent);
|
||||
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
|
||||
@@ -506,13 +532,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
|
||||
const sortedAndFilteredAgents = React.useMemo(() => {
|
||||
const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (!agentSearchQuery.trim()) {
|
||||
return sorted;
|
||||
}
|
||||
return sorted.filter((agent) =>
|
||||
fuzzyMatch(agent.name, agentSearchQuery) ||
|
||||
(agent.description && fuzzyMatch(agent.description, agentSearchQuery))
|
||||
);
|
||||
return rankByQuery(sorted, agentSearchQuery, (agent) => [agent.name, agent.description]);
|
||||
}, [selectableDesktopAgents, agentSearchQuery]);
|
||||
|
||||
const defaultAgentName = React.useMemo(() => {
|
||||
@@ -558,38 +578,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return result;
|
||||
}, [providers, hiddenModels]);
|
||||
|
||||
const normalizeModelSearchValue = React.useCallback((value: string) => {
|
||||
const lower = value.toLowerCase().trim();
|
||||
const compact = lower.replace(/[^a-z0-9]/g, '');
|
||||
const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean);
|
||||
return { lower, compact, tokens };
|
||||
}, []);
|
||||
|
||||
const matchesModelSearch = React.useCallback((candidate: string, query: string) => {
|
||||
const normalizedQuery = normalizeModelSearchValue(query);
|
||||
if (!normalizedQuery.lower) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedCandidate = normalizeModelSearchValue(candidate);
|
||||
if (normalizedCandidate.lower.includes(normalizedQuery.lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedQuery.compact.length >= 2 && normalizedCandidate.compact.includes(normalizedQuery.compact)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedQuery.tokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedQuery.tokens.every((queryToken) =>
|
||||
normalizedCandidate.tokens.some((candidateToken) =>
|
||||
candidateToken.startsWith(queryToken) || candidateToken.includes(queryToken)
|
||||
)
|
||||
);
|
||||
}, [normalizeModelSearchValue]);
|
||||
const matchesModelSearch = React.useCallback(
|
||||
(candidate: string, query: string) => matchesRankQuery([candidate], query),
|
||||
[],
|
||||
);
|
||||
|
||||
const currentModelForMetadata = currentModelId
|
||||
? models.find((model: ProviderModel) => model.id === currentModelId)
|
||||
@@ -641,7 +633,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
];
|
||||
|
||||
const prevAgentNameRef = React.useRef<string | undefined>(undefined);
|
||||
const explicitAgentSwitchRef = React.useRef<string | null>(null);
|
||||
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
|
||||
|
||||
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
|
||||
@@ -704,6 +695,30 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return variants ? Object.keys(variants) : [];
|
||||
}, [providers]);
|
||||
|
||||
const resolveInheritedVariantForModel = React.useCallback((providerId: string, modelId: string, agentName?: string | null) => {
|
||||
const variantOptions = getModelVariantOptions(providerId, modelId);
|
||||
if (variantOptions.length === 0) return undefined;
|
||||
|
||||
let currentInherited: string | undefined;
|
||||
if (currentProviderId === providerId && currentModelId === modelId) {
|
||||
currentInherited = currentVariantSelection.inherited
|
||||
?? (currentVariantSelection.override === null || currentVariantSelection.override === undefined
|
||||
? effectiveCurrentVariant
|
||||
: undefined);
|
||||
}
|
||||
|
||||
const effectiveAgentName = agentName ?? uiAgentName ?? currentAgentName;
|
||||
const agent = effectiveAgentName ? agents.find((candidate) => candidate.name === effectiveAgentName) : undefined;
|
||||
const agentVariant = (
|
||||
agent?.model?.providerID === providerId
|
||||
&& agent.model.modelID === modelId
|
||||
) ? agent.variant : undefined;
|
||||
const candidates = currentSessionId
|
||||
? [agentVariant, settingsDefaultVariant, currentInherited]
|
||||
: [currentInherited, agentVariant, settingsDefaultVariant];
|
||||
return candidates.find((candidate) => candidate !== undefined && variantOptions.includes(candidate));
|
||||
}, [agents, currentAgentName, currentModelId, currentProviderId, currentSessionId, currentVariantSelection, effectiveCurrentVariant, getModelVariantOptions, settingsDefaultVariant, uiAgentName]);
|
||||
|
||||
const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string) => {
|
||||
const variantOptions = getModelVariantOptions(providerId, modelId);
|
||||
if (variantOptions.length === 0) {
|
||||
@@ -722,10 +737,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return currentVariant;
|
||||
}
|
||||
|
||||
if (!currentSessionId && settingsDefaultVariant && variantOptions.includes(settingsDefaultVariant)) {
|
||||
return settingsDefaultVariant;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
currentAgentName,
|
||||
@@ -735,7 +746,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
currentVariant,
|
||||
getAgentModelVariantForSession,
|
||||
getModelVariantOptions,
|
||||
settingsDefaultVariant,
|
||||
uiAgentName,
|
||||
]);
|
||||
|
||||
@@ -759,7 +769,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
}
|
||||
|
||||
manualVariantSelectionRef.current = true;
|
||||
setCurrentVariant(variant);
|
||||
setCurrentVariantOverride(
|
||||
variant ?? null,
|
||||
resolveInheritedVariantForModel(providerId, modelId, agentNameOverride),
|
||||
);
|
||||
addRecentEffort(providerId, modelId, variant);
|
||||
|
||||
const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName();
|
||||
@@ -770,9 +783,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
addRecentEffort,
|
||||
currentSessionId,
|
||||
getModelVariantOptions,
|
||||
resolveInheritedVariantForModel,
|
||||
resolveLiveAgentName,
|
||||
saveAgentModelVariantForSession,
|
||||
setCurrentVariant,
|
||||
setCurrentVariantOverride,
|
||||
]);
|
||||
|
||||
const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => {
|
||||
@@ -893,25 +908,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
? useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
|
||||
: null;
|
||||
if (savedAgentName) {
|
||||
if (currentAgentName !== savedAgentName) {
|
||||
setAgent(savedAgentName);
|
||||
}
|
||||
|
||||
const savedModel = getAgentModelForSession(currentSessionId, savedAgentName);
|
||||
if (savedModel) {
|
||||
const result = tryApplyModelSelection(savedModel.providerId, savedModel.modelId, savedAgentName);
|
||||
if (result === 'applied') {
|
||||
if (currentAgentName !== savedAgentName) {
|
||||
setAgent(savedAgentName);
|
||||
}
|
||||
return 'resolved';
|
||||
}
|
||||
if (result === 'provider-missing') {
|
||||
return 'waiting';
|
||||
}
|
||||
} else if (currentAgentName !== savedAgentName) {
|
||||
setAgent(savedAgentName);
|
||||
}
|
||||
}
|
||||
|
||||
if (savedSessionModel) {
|
||||
const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined);
|
||||
if (result === 'applied') {
|
||||
if (savedAgentName && currentAgentName !== savedAgentName) {
|
||||
setAgent(savedAgentName);
|
||||
}
|
||||
return 'resolved';
|
||||
}
|
||||
if (result === 'provider-missing') {
|
||||
@@ -925,16 +944,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentAgentName !== agent.name) {
|
||||
setAgent(agent.name);
|
||||
}
|
||||
|
||||
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
|
||||
if (!existingSelection) {
|
||||
saveSessionAgentSelection(currentSessionId, agent.name);
|
||||
}
|
||||
const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name);
|
||||
if (result === 'applied') {
|
||||
if (currentAgentName !== agent.name) {
|
||||
setAgent(agent.name);
|
||||
}
|
||||
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
|
||||
if (!existingSelection) {
|
||||
saveSessionAgentSelection(currentSessionId, agent.name);
|
||||
}
|
||||
return 'resolved';
|
||||
}
|
||||
if (result === 'provider-missing') {
|
||||
@@ -1032,9 +1050,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
prevAgentNameRef.current = currentAgentName;
|
||||
|
||||
if (currentAgentName && currentSessionId) {
|
||||
const shouldPreferAgentModel = explicitAgentSwitchRef.current === currentAgentName;
|
||||
explicitAgentSwitchRef.current = null;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, 50);
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
@@ -1047,33 +1062,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedAgent = shouldPreferAgentModel
|
||||
? agents.find((agent) => agent.name === currentAgentName)
|
||||
: undefined;
|
||||
if (selectedAgent?.model?.providerID && selectedAgent.model.modelID) {
|
||||
const result = tryApplyModelSelection(
|
||||
selectedAgent.model.providerID,
|
||||
selectedAgent.model.modelID,
|
||||
currentAgentName,
|
||||
);
|
||||
if (result === 'applied' || result === 'provider-missing') {
|
||||
if (result === 'applied') {
|
||||
saveSessionModelSelection(
|
||||
currentSessionId,
|
||||
selectedAgent.model.providerID,
|
||||
selectedAgent.model.modelID,
|
||||
);
|
||||
saveAgentModelForSession(
|
||||
currentSessionId,
|
||||
currentAgentName,
|
||||
selectedAgent.model.providerID,
|
||||
selectedAgent.model.modelID,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const persistedChoice = getAgentModelForSession(currentSessionId, currentAgentName);
|
||||
|
||||
if (persistedChoice) {
|
||||
@@ -1099,12 +1087,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
abortController.abort();
|
||||
};
|
||||
}, [
|
||||
agents,
|
||||
currentAgentName,
|
||||
currentSessionId,
|
||||
getAgentModelForSession,
|
||||
saveAgentModelForSession,
|
||||
saveSessionModelSelection,
|
||||
tryApplyModelSelection,
|
||||
contextHydrated,
|
||||
]);
|
||||
@@ -1129,18 +1114,21 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
}
|
||||
|
||||
if (currentVariant && !availableVariants.includes(currentVariant)) {
|
||||
setCurrentVariant(undefined);
|
||||
setCurrentVariantOverride(
|
||||
null,
|
||||
resolveInheritedVariantForModel(currentProviderId, currentModelId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Draft state (no session yet): seed from settings default, but don't override
|
||||
// user selection while drafting.
|
||||
if (!currentSessionId) {
|
||||
if (!currentVariant && !manualVariantSelectionRef.current) {
|
||||
if (currentVariantSelection.override === undefined && !manualVariantSelectionRef.current) {
|
||||
const desired = settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
|
||||
? settingsDefaultVariant
|
||||
: undefined;
|
||||
setCurrentVariant(desired);
|
||||
setCurrentVariantOverride(desired ?? null, desired);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1152,13 +1140,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
currentModelId,
|
||||
);
|
||||
|
||||
const resolvedSaved = savedVariant && availableVariants.includes(savedVariant)
|
||||
? savedVariant
|
||||
: settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
|
||||
? settingsDefaultVariant
|
||||
: undefined;
|
||||
|
||||
setCurrentVariant(resolvedSaved);
|
||||
const inheritedVariant = resolveInheritedVariantForModel(currentProviderId, currentModelId);
|
||||
if (savedVariant && availableVariants.includes(savedVariant)) {
|
||||
setCurrentVariantOverride(savedVariant, inheritedVariant);
|
||||
} else if (currentVariantSelection.override === null) {
|
||||
setCurrentVariantOverride(null, inheritedVariant);
|
||||
} else {
|
||||
setCurrentVariant(inheritedVariant);
|
||||
}
|
||||
manualVariantSelectionRef.current = false;
|
||||
}, [
|
||||
availableVariants,
|
||||
@@ -1168,8 +1157,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
currentVariant,
|
||||
currentVariantSelection.override,
|
||||
effectiveCurrentVariant,
|
||||
getAgentModelVariantForSession,
|
||||
resolveInheritedVariantForModel,
|
||||
setCurrentVariant,
|
||||
setCurrentVariantOverride,
|
||||
settingsDefaultVariant,
|
||||
]);
|
||||
|
||||
@@ -1185,7 +1178,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
|
||||
const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => {
|
||||
try {
|
||||
explicitAgentSwitchRef.current = agentName;
|
||||
setAgent(agentName);
|
||||
addRecentAgent(agentName);
|
||||
if (options?.closeModelSelector ?? true) {
|
||||
@@ -2256,7 +2248,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
: 'Default';
|
||||
|
||||
return (
|
||||
<span className={cn('typography-micro whitespace-nowrap', wasAdjusted ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
<span className={cn(
|
||||
'typography-micro whitespace-nowrap',
|
||||
isHighlighted
|
||||
? (wasAdjusted ? 'text-interactive-selection-foreground' : 'text-interactive-selection-foreground/70')
|
||||
: (wasAdjusted ? 'text-foreground' : 'text-muted-foreground'),
|
||||
)}>
|
||||
Thinking: {displayLabel}
|
||||
</span>
|
||||
);
|
||||
@@ -2316,9 +2313,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col"
|
||||
side="top"
|
||||
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col overflow-hidden"
|
||||
align="end"
|
||||
alignOffset={-40}
|
||||
constrainToMain
|
||||
collisionAvoidance={{ side: 'none', align: 'shift' }}
|
||||
onKeyDownCapture={handleModelShortcutKeyDownCapture}
|
||||
>
|
||||
<div className="p-1 border-b border-border/40">
|
||||
@@ -2375,6 +2375,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
maxHeightClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1"
|
||||
tooltipsEnabled={agentMenuOpen}
|
||||
onEscape={() => setAgentMenuOpen(false)}
|
||||
/>
|
||||
@@ -2618,7 +2619,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
|
||||
<DropdownMenuContent side="top" align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
|
||||
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">{t('chat.modelControls.thinking')}</DropdownMenuLabel>
|
||||
<DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}>
|
||||
<div className="flex items-center justify-between gap-2 w-full min-w-0">
|
||||
@@ -2708,7 +2709,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col">
|
||||
<DropdownMenuContent side="top" align="end" alignOffset={-40} constrainToMain collisionAvoidance={{ side: 'none', align: 'shift' }} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col overflow-hidden">
|
||||
<div className="p-2 border-b border-border/40">
|
||||
<div className="relative">
|
||||
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
@@ -2724,7 +2725,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
|
||||
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1">
|
||||
<div className="p-1">
|
||||
{!agentSearchQuery.trim() && defaultAgentName && (
|
||||
<>
|
||||
@@ -2746,12 +2747,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
sortedAndFilteredAgents.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.name}
|
||||
className="typography-meta"
|
||||
onSelect={() => handleAgentChange(agent.name)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<AgentDescriptionTooltip key={agent.name} description={agent.description}>
|
||||
<DropdownMenuItem
|
||||
className="typography-meta"
|
||||
onSelect={() => handleAgentChange(agent.name)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn(
|
||||
'h-1 w-1 rounded-full agent-dot',
|
||||
@@ -2759,13 +2759,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
)} />
|
||||
<span className="font-medium">{capitalizeAgentName(agent.name)}</span>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<span className="typography-meta text-muted-foreground max-w-[200px] ml-2.5 break-words">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem>
|
||||
</AgentDescriptionTooltip>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
|
||||
>
|
||||
<Icon name="file-edit" className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
|
||||
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
|
||||
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">
|
||||
<span className="composer-status-bar__changed-label min-w-0 typography-ui-label text-foreground truncate">
|
||||
{t('chat.pendingChanges.changedInWorkspace')}
|
||||
</span>
|
||||
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
|
||||
|
||||
@@ -10,6 +10,10 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { DiffPreview, WritePreview } from './DiffPreview';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getVisiblePermissionPatterns } from './permissionCardPatterns';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
|
||||
// Newest pending card owns the keyboard; older cards wait their turn.
|
||||
const activePermissionCardIds: string[] = [];
|
||||
|
||||
const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = {
|
||||
margin: 0,
|
||||
@@ -66,6 +70,14 @@ const getToolIcon = (toolName: string) => {
|
||||
return <Icon name="global" className={iconClass} />;
|
||||
}
|
||||
|
||||
if (tool === 'linear' || tool.startsWith('linear_')) {
|
||||
return <Icon name="linear" className={iconClass} />;
|
||||
}
|
||||
|
||||
if (tool === 'cloudflare' || tool.startsWith('cloudflare_') || tool === 'claudflare' || tool.startsWith('claudflare_')) {
|
||||
return <Icon name="cloudflare" className={iconClass} />;
|
||||
}
|
||||
|
||||
return <Icon name="tools" className={iconClass} />;
|
||||
};
|
||||
|
||||
@@ -118,6 +130,33 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleResponseRef = React.useRef(handleResponse);
|
||||
handleResponseRef.current = handleResponse;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasResponded) return;
|
||||
activePermissionCardIds.push(permission.id);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (activePermissionCardIds.at(-1) !== permission.id) return;
|
||||
if (!event.altKey || event.metaKey || event.ctrlKey) return;
|
||||
const response = event.key === 'Enter'
|
||||
? (event.shiftKey ? 'always' as const : 'once' as const)
|
||||
: event.key === 'Backspace' && !event.shiftKey
|
||||
? 'reject' as const
|
||||
: null;
|
||||
if (!response) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleResponseRef.current(response);
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
const index = activePermissionCardIds.lastIndexOf(permission.id);
|
||||
if (index !== -1) activePermissionCardIds.splice(index, 1);
|
||||
};
|
||||
}, [hasResponded, permission.id]);
|
||||
|
||||
if (hasResponded) {
|
||||
return null;
|
||||
}
|
||||
@@ -372,6 +411,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
>
|
||||
<Icon name="check" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
|
||||
Allow Once
|
||||
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+enter')}</kbd>
|
||||
</button>
|
||||
|
||||
{permission.always.length > 0 ? (
|
||||
@@ -428,6 +468,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
>
|
||||
<Icon name="time" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
|
||||
Always Allow
|
||||
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+shift+enter')}</kbd>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -451,6 +492,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
>
|
||||
<Icon name="close" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
|
||||
Deny
|
||||
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+backspace')}</kbd>
|
||||
</button>
|
||||
|
||||
{isResponding && (
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as sessionActions from '@/sync/session-actions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { serializeQuestionAsJson, serializeQuestionAsMarkdown } from './questionSerializers';
|
||||
import { QUESTION_CUSTOM_TEXTAREA_MIN_HEIGHT, getQuestionCustomTextareaHeight } from './questionTextareaSizing';
|
||||
import { QuestionMarkdown } from './QuestionMarkdown';
|
||||
|
||||
interface QuestionCardProps {
|
||||
question: QuestionRequest;
|
||||
@@ -423,7 +424,11 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
||||
</div>
|
||||
) : activeQuestion ? (
|
||||
<>
|
||||
<div className="typography-meta font-medium text-foreground mb-1.5">{activeQuestion.question}</div>
|
||||
<QuestionMarkdown
|
||||
content={activeQuestion.question}
|
||||
size="meta"
|
||||
className="font-medium text-foreground mb-1.5"
|
||||
/>
|
||||
|
||||
{isMultiple ? (
|
||||
<div className="typography-micro text-muted-foreground mb-1.5">{t('chat.questionCard.selectMultiple')}</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { QuestionMarkdown } from './QuestionMarkdown';
|
||||
|
||||
// The markdown renderer is lazy, so a synchronous server render always emits the
|
||||
// Suspense fallback QuestionMarkdown supplies. That fallback is the surface that
|
||||
// has to keep the exact question text and the question typography classes.
|
||||
describe('QuestionMarkdown', () => {
|
||||
test('renders the question content verbatim', () => {
|
||||
const content = 'Choose **one** from `mode`: [details](https://example.com)';
|
||||
|
||||
const html = renderToStaticMarkup(<QuestionMarkdown content={content} size="meta" />);
|
||||
|
||||
expect(html).toBe(
|
||||
`<div class="question-markdown typography-meta whitespace-pre-wrap">${content}</div>`,
|
||||
);
|
||||
});
|
||||
|
||||
test('applies meta typography and caller classes', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QuestionMarkdown content="Meta" size="meta" className="font-medium text-foreground" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('class="question-markdown typography-meta font-medium text-foreground whitespace-pre-wrap"');
|
||||
});
|
||||
|
||||
test('applies micro typography and caller classes', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QuestionMarkdown content="Micro" size="micro" className="text-muted-foreground" />,
|
||||
);
|
||||
|
||||
expect(html).toContain('class="question-markdown typography-micro text-muted-foreground whitespace-pre-wrap"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SimpleMarkdownRenderer } from './MarkdownRenderer';
|
||||
|
||||
interface QuestionMarkdownProps {
|
||||
content: string;
|
||||
size: 'meta' | 'micro';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function QuestionMarkdown({ content, size, className }: QuestionMarkdownProps) {
|
||||
const classes = cn('question-markdown', size === 'meta' ? 'typography-meta' : 'typography-micro', className);
|
||||
|
||||
return (
|
||||
<SimpleMarkdownRenderer
|
||||
content={content}
|
||||
variant="tool"
|
||||
className={classes}
|
||||
fallbackContent={<div className={cn(classes, 'whitespace-pre-wrap')}>{content}</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore';
|
||||
import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata';
|
||||
import { sessionGoalStatusColor } from '@/lib/sessionGoalPresentation';
|
||||
import { SessionGoalDialog } from '@/components/chat/SessionGoalDialog';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -50,12 +51,13 @@ export const SessionGoalButton: React.FC<SessionGoalButtonProps> = React.memo(({
|
||||
const liveGoal = goal && goal.status !== 'complete' ? goal : null;
|
||||
const isEngaged = armed || Boolean(liveGoal);
|
||||
|
||||
const colorClass = (() => {
|
||||
if (goal?.status === 'complete') return 'text-[var(--status-success)]';
|
||||
if (goal?.status === 'blocked' || goal?.status === 'budgetLimited') return 'text-[var(--status-error)]';
|
||||
if (armed || goal?.status === 'active' || goal?.status === 'paused') return 'text-[var(--status-info)]';
|
||||
return '';
|
||||
})();
|
||||
// One mapping for every goal surface. This button used to carry its own,
|
||||
// which painted `paused` the same info colour as `active` — so a paused goal
|
||||
// was indistinguishable from a running one — and `blocked` as an error rather
|
||||
// than a warning. `armed` is not a goal status, so it keeps its own case.
|
||||
const iconColor = goal
|
||||
? sessionGoalStatusColor[goal.status]
|
||||
: (armed ? 'var(--status-info)' : undefined);
|
||||
|
||||
const label = goal
|
||||
? t('chat.goal.button.manageAria')
|
||||
@@ -74,7 +76,8 @@ export const SessionGoalButton: React.FC<SessionGoalButtonProps> = React.memo(({
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(footerIconButtonClass, colorClass)}
|
||||
className={footerIconButtonClass}
|
||||
style={iconColor ? { color: iconColor } : undefined}
|
||||
onClick={handleClick}
|
||||
// Same guard as PermissionAutoAcceptButton, but only for the ARM
|
||||
// toggle: arming happens mid-typing (the next message IS the
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
|
||||
|
||||
interface SkillInfo {
|
||||
name: string;
|
||||
@@ -31,7 +32,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
}, ref) => {
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const selectedIndexRef = React.useRef(0);
|
||||
const keyboardNavigationRef = React.useRef(false);
|
||||
@@ -126,6 +127,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
const isProject = skill.scope === 'project';
|
||||
const source = skill.source || 'opencode';
|
||||
return (
|
||||
<AutocompleteRowTooltip description={skill.description} active={!isMobile && index === selectedIndex}>
|
||||
<div
|
||||
key={`${skill.name}-${skill.scope}`}
|
||||
ref={(el) => {
|
||||
@@ -157,13 +159,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
||||
{source}
|
||||
</span>
|
||||
</div>
|
||||
{skill.description && !isMobile && (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AutocompleteRowTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
|
||||
const { t } = useI18n();
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
||||
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const selectedIndexRef = React.useRef(0);
|
||||
const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]);
|
||||
|
||||
@@ -1,141 +1,25 @@
|
||||
import React from "react";
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDirectorySync } from "@/sync/sync-context";
|
||||
import type { Todo } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
// Compat aliases for old TodoItem shape
|
||||
type TodoItem = Todo & { id?: string };
|
||||
type TodoStatus = string;
|
||||
type TodoPriority = string;
|
||||
import { useUIStore } from "@/stores/useUIStore";
|
||||
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
|
||||
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
||||
import { isVSCodeRuntime } from "@/lib/desktop";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
// The floating assistant-status chip that hovers above the composer while the
|
||||
// agent works ("Claude is working…"). ONLY that. The composer's
|
||||
// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
|
||||
// to share this component, and every restyle of this chip (glass, placement)
|
||||
// silently dragged the composer bar and its dropdown along with it.
|
||||
|
||||
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
|
||||
|
||||
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
|
||||
in_progress: {
|
||||
textClassName: "text-foreground",
|
||||
},
|
||||
pending: {
|
||||
textClassName: "text-foreground",
|
||||
},
|
||||
completed: {
|
||||
textClassName: "text-muted-foreground line-through",
|
||||
},
|
||||
cancelled: {
|
||||
textClassName: "text-muted-foreground line-through",
|
||||
},
|
||||
};
|
||||
|
||||
const priorityClassName: Record<TodoPriority, string> = {
|
||||
high: "text-[var(--status-warning)]",
|
||||
medium: "text-muted-foreground",
|
||||
low: "text-muted-foreground/70",
|
||||
};
|
||||
|
||||
const priorityIcon: Record<TodoPriority, React.ReactNode> = {
|
||||
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true"/>,
|
||||
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
|
||||
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
|
||||
};
|
||||
|
||||
const statusLabelKey: Record<TodoStatus, string> = {
|
||||
in_progress: "chat.statusRow.todo.status.inProgress",
|
||||
pending: "chat.statusRow.todo.status.pending",
|
||||
completed: "chat.statusRow.todo.status.completed",
|
||||
cancelled: "chat.statusRow.todo.status.cancelled",
|
||||
};
|
||||
|
||||
const priorityLabelKey: Record<TodoPriority, string> = {
|
||||
high: "chat.statusRow.todo.priority.high",
|
||||
medium: "chat.statusRow.todo.priority.medium",
|
||||
low: "chat.statusRow.todo.priority.low",
|
||||
};
|
||||
|
||||
interface TodoItemRowProps {
|
||||
todo: TodoItem;
|
||||
}
|
||||
|
||||
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
||||
const { t } = useI18n();
|
||||
const config = statusConfig[todo.status] || statusConfig.pending;
|
||||
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
|
||||
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
|
||||
|
||||
const statusIcon =
|
||||
todo.status === "in_progress" ? (
|
||||
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true"/>
|
||||
) : todo.status === "completed" ? (
|
||||
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true"/>
|
||||
) : (
|
||||
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true"/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-0 py-0.5 gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex-shrink-0">{statusIcon}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
{t(statusKey as never)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 typography-ui-label",
|
||||
config.textClassName
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
|
||||
priorityClassName[todo.priority] ?? priorityClassName.medium
|
||||
)}
|
||||
>
|
||||
{priorityIcon[todo.priority] ?? priorityIcon.medium}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{t(priorityKey as never)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EMPTY_TODOS: TodoItem[] = [];
|
||||
|
||||
interface StatusRowProps {
|
||||
// Working state
|
||||
isWorking?: boolean;
|
||||
statusText?: string | null;
|
||||
isGenericStatus?: boolean;
|
||||
isWaitingForPermission?: boolean;
|
||||
wasAborted?: boolean;
|
||||
abortActive?: boolean;
|
||||
retryInfo?: { attempt?: number; next?: number } | null;
|
||||
// Abort state (for mobile/vscode)
|
||||
showAbort?: boolean;
|
||||
onAbort?: () => void;
|
||||
// Abort status display
|
||||
showAbortStatus?: boolean;
|
||||
showAssistantStatus?: boolean;
|
||||
showTodos?: boolean;
|
||||
agentName?: string;
|
||||
modelName?: string | null;
|
||||
providerId?: string | null;
|
||||
leftAccessory?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
@@ -143,186 +27,36 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
statusText = null,
|
||||
isGenericStatus,
|
||||
isWaitingForPermission,
|
||||
wasAborted,
|
||||
abortActive,
|
||||
retryInfo,
|
||||
showAbort,
|
||||
onAbort,
|
||||
showAbortStatus,
|
||||
showAssistantStatus = true,
|
||||
showTodos = true,
|
||||
agentName,
|
||||
modelName,
|
||||
providerId,
|
||||
leftAccessory,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
const liveTodos = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
|
||||
return state.todo[currentSessionId] ?? EMPTY_TODOS;
|
||||
},
|
||||
[currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const persistedSessionTodos = useTodosPersistStore(
|
||||
React.useCallback(
|
||||
(state) => (showTodos && currentSessionId && currentSessionDirectory
|
||||
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
|
||||
: undefined),
|
||||
[currentSessionDirectory, currentSessionId, showTodos],
|
||||
),
|
||||
);
|
||||
const todos: TodoItem[] = React.useMemo(() => {
|
||||
if (!currentSessionId) return EMPTY_TODOS;
|
||||
if (liveTodos.length > 0) return liveTodos;
|
||||
return persistedSessionTodos ?? EMPTY_TODOS;
|
||||
}, [liveTodos, persistedSessionTodos, currentSessionId]);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const isCompact = isMobile || isVSCodeRuntime();
|
||||
|
||||
// Filter out cancelled todos for display and keep original order.
|
||||
// This prevents items from jumping around when status changes.
|
||||
const visibleTodos = React.useMemo(() => {
|
||||
return todos.filter((todo) => todo.status !== "cancelled");
|
||||
}, [todos]);
|
||||
const shouldRenderPlaceholder = !abortActive;
|
||||
const hasContent = isWorking;
|
||||
|
||||
// Find the current active todo (first in_progress, or first pending)
|
||||
const activeTodo = React.useMemo(() => {
|
||||
return (
|
||||
visibleTodos.find((t) => t.status === "in_progress") ||
|
||||
visibleTodos.find((t) => t.status === "pending") ||
|
||||
null
|
||||
);
|
||||
}, [visibleTodos]);
|
||||
|
||||
// Calculate progress
|
||||
const progress = React.useMemo(() => {
|
||||
const total = todos.filter((t) => t.status !== "cancelled").length;
|
||||
const completed = todos.filter((t) => t.status === "completed").length;
|
||||
return { completed, total };
|
||||
}, [todos]);
|
||||
|
||||
const statusSummary = React.useMemo(() => {
|
||||
const active = visibleTodos.filter((t) => t.status === "in_progress").length;
|
||||
const left = visibleTodos.filter((t) => t.status === "in_progress" || t.status === "pending").length;
|
||||
return { active, left };
|
||||
}, [visibleTodos]);
|
||||
|
||||
const hasTodoContent = showTodos && statusSummary.left > 0;
|
||||
const hasAssistantContent = showAssistantStatus && (
|
||||
isWorking ||
|
||||
Boolean(wasAborted) ||
|
||||
Boolean(showAbortStatus)
|
||||
);
|
||||
const hasLeftAccessory = Boolean(leftAccessory);
|
||||
// Original logic from ChatInput
|
||||
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
||||
|
||||
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
|
||||
|
||||
// Close popover when clicking outside
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isExpanded]);
|
||||
|
||||
const toggleExpanded = () => setIsExpanded((prev) => !prev);
|
||||
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
|
||||
active: statusSummary.active,
|
||||
left: statusSummary.left,
|
||||
});
|
||||
|
||||
// Abort button for mobile/vscode
|
||||
const abortButton = showAbort && onAbort ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
|
||||
aria-label={t('chat.statusRow.actions.stopGeneratingAria')}
|
||||
>
|
||||
<Icon name="close-circle" aria-hidden="true"/>
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
// Todo trigger button
|
||||
const todoTrigger = hasTodoContent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
|
||||
aria-label={todoSummaryLabel}
|
||||
title={todoSummaryLabel}
|
||||
>
|
||||
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
|
||||
{!isCompact && activeTodo ? (
|
||||
<span className="status-row__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
|
||||
{activeTodo.content}
|
||||
</span>
|
||||
) : (
|
||||
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
|
||||
)}
|
||||
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
|
||||
{statusSummary.active}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Icon name="time" className="h-3.5 w-3.5" />
|
||||
{statusSummary.left}
|
||||
</span>
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
// Don't render if nothing to show
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
// Mobile: breathing room between the last message and the agent status
|
||||
// line — without it the "<model> is running…" row sits flush against
|
||||
// the message above.
|
||||
className={cn("mb-1", isMobile && "mt-2", !hasLeftAccessory && "chat-column")}
|
||||
// The row renders inside the composer-anchored overlay, which owns the
|
||||
// distance to the input and the horizontal column (the same ones the
|
||||
// scroll-to-bottom pill uses).
|
||||
style={STATUS_ROW_CONTAINER_STYLE}
|
||||
>
|
||||
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}>
|
||||
{/* Left: Abort status | Working placeholder | leftAccessory */}
|
||||
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
|
||||
{showAssistantStatus && showAbortStatus ? (
|
||||
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
|
||||
<span className="flex items-center gap-1.5 typography-ui-label">
|
||||
<Icon name="close-circle" aria-hidden="true"/>
|
||||
{t('chat.statusRow.aborted')}
|
||||
</span>
|
||||
</div>
|
||||
) : showAssistantStatus && shouldRenderPlaceholder ? (
|
||||
{/* h-8 matches the turn footer's real row height: its h-8 action
|
||||
buttons define the footer line, with the meta text centered in it. */}
|
||||
{/* The glass chip lives here, not on the container: the root above is
|
||||
an inline-size query container, whose width ignores its children —
|
||||
a shrink-to-fit wrapper around it always collapsed to zero. */}
|
||||
<div className="oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3">
|
||||
<div className="flex items-center min-w-0 gap-2 overflow-x-hidden">
|
||||
{shouldRenderPlaceholder ? (
|
||||
<WorkingPlaceholder
|
||||
key={currentSessionId ?? "no-session"}
|
||||
isWorking={isWorking}
|
||||
@@ -334,50 +68,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
modelName={modelName}
|
||||
providerId={providerId}
|
||||
/>
|
||||
) : leftAccessory ? (
|
||||
leftAccessory
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right: Abort (mobile only) + Todo */}
|
||||
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory ? "pr-1.5" : "-mr-3")} ref={popoverRef}>
|
||||
{abortButton}
|
||||
{todoTrigger}
|
||||
|
||||
{/* Popover dropdown */}
|
||||
{isExpanded && hasTodoContent && (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "min(28rem, calc(100cqw - 4ch))",
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
color: "var(--surface-elevated-foreground)",
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-0 bottom-full mb-1 z-50",
|
||||
"w-max min-w-[200px] rounded-xl p-1",
|
||||
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
|
||||
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
|
||||
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
|
||||
"duration-150"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
|
||||
<span>{t('chat.statusRow.tasksTitle')}</span>
|
||||
<span className="typography-meta tabular-nums">
|
||||
{progress.completed}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Todo list */}
|
||||
<div className="px-1 max-h-[200px] overflow-y-auto">
|
||||
{visibleTodos.map((todo, index) => (
|
||||
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
|
||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { StatusRow } from './StatusRow';
|
||||
|
||||
@@ -12,15 +11,6 @@ import { StatusRow } from './StatusRow';
|
||||
* labels while still limiting subscriptions to the active assistant message.
|
||||
*/
|
||||
export const StatusRowContainer: React.FC = React.memo(() => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const abortRecord = useSessionUIStore(
|
||||
React.useCallback((state) => {
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessionAbortFlags?.get(currentSessionId) ?? null;
|
||||
}, [currentSessionId]),
|
||||
);
|
||||
const { activeModel, working } = useAssistantStatus();
|
||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
@@ -35,19 +25,14 @@ export const StatusRowContainer: React.FC = React.memo(() => {
|
||||
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
|
||||
}, [activeModel, providers]);
|
||||
|
||||
const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged);
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
isWorking={working.isWorking}
|
||||
statusText={working.statusText}
|
||||
isGenericStatus={working.isGenericStatus}
|
||||
isWaitingForPermission={working.isWaitingForPermission}
|
||||
wasAborted={wasAborted || working.wasAborted}
|
||||
abortActive={wasAborted || working.abortActive}
|
||||
abortActive={working.abortActive}
|
||||
retryInfo={working.retryInfo}
|
||||
showAssistantStatus
|
||||
showTodos={false}
|
||||
agentName={currentAgentName}
|
||||
modelName={modelDisplayName}
|
||||
providerId={activeModel?.providerId ?? null}
|
||||
|
||||
@@ -223,20 +223,48 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
|
||||
if (!currentSessionId) return null;
|
||||
|
||||
const turnActions = (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
|
||||
onClick={() => {
|
||||
void onScrollByTurnOffset?.(-1);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{t('chat.timeline.actions.previousTurn')}
|
||||
</button>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
|
||||
onClick={() => {
|
||||
onResumeToLatest?.();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{t('chat.timeline.actions.latest')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] max-md:max-h-[85dvh] flex flex-col overflow-y-auto">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icon name="time" className="h-5 w-5" />
|
||||
{t('chat.timeline.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('chat.timeline.description')}
|
||||
</DialogDescription>
|
||||
{!isMobile && (
|
||||
<DialogDescription>
|
||||
{t('chat.timeline.description')}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative mt-2">
|
||||
<div className="relative mt-2 shrink-0">
|
||||
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -249,7 +277,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
</div>
|
||||
|
||||
{canLoadEarlier && onLoadEarlier && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="flex shrink-0 justify-center py-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
@@ -266,7 +294,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto">
|
||||
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||
{filteredMessages.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
|
||||
@@ -312,7 +340,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span className={cn(
|
||||
"typography-meta w-16 flex-shrink-0 text-right tabular-nums",
|
||||
"typography-meta min-w-16 flex-shrink-0 text-right tabular-nums whitespace-nowrap",
|
||||
isSelected ? "text-interactive-selection-foreground/70" : "text-muted-foreground"
|
||||
)}>
|
||||
{messageTime}
|
||||
@@ -373,45 +401,31 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
||||
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
|
||||
onClick={() => {
|
||||
void onScrollByTurnOffset?.(-1);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{t('chat.timeline.actions.previousTurn')}
|
||||
</button>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
|
||||
onClick={() => {
|
||||
onResumeToLatest?.();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{t('chat.timeline.actions.latest')}
|
||||
</button>
|
||||
{isMobile ? (
|
||||
<div className="mt-2 flex shrink-0 items-center justify-center gap-2 border-t border-border/60 pt-2">
|
||||
{turnActions}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t('chat.timeline.help.clickMessage')}</span>
|
||||
) : (
|
||||
<div className="mt-4 p-3 bg-muted/30 rounded-lg shrink-0">
|
||||
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{turnActions}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="arrow-go-back" className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t('chat.timeline.help.undoToPoint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="git-branch" className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
|
||||
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t('chat.timeline.help.clickMessage')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="arrow-go-back" className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t('chat.timeline.help.undoToPoint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="git-branch" className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Regression coverage for https://github.com/openchamber/openchamber/issues/2903
|
||||
*
|
||||
* Busy embedded session-chat panels were rendering only the working-status row
|
||||
* ("…is running command") because ChatContainer gated message reads on the
|
||||
* same visibility flag used to keep the composer from stealing focus. When the
|
||||
* iframe booted inactive (or a visibility postMessage was lost),
|
||||
* useSessionMessageRecords returned [] while session status stayed busy — so
|
||||
* the empty-state branch was skipped and the transcript showed status only.
|
||||
*
|
||||
* Idle sessions hit the empty state instead (#2892). Same root cause.
|
||||
*
|
||||
* Fix: embedded session-chat keeps `messagesEnabled={true}` so history stays
|
||||
* subscribed while `active={embeddedBackgroundWorkEnabled}` still gates
|
||||
* composer focus and background work.
|
||||
*/
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
mock.module('sonner', () => ({
|
||||
toast: { dismiss: () => undefined, error: () => undefined, info: () => undefined, success: () => undefined },
|
||||
}));
|
||||
mock.module('@/components/ui', () => ({
|
||||
toast: { info: () => undefined, error: () => undefined, success: () => undefined },
|
||||
}));
|
||||
mock.module('@/lib/opencode/client', () => ({
|
||||
opencodeClient: {
|
||||
getDirectory: () => '/repo',
|
||||
setDirectory: () => undefined,
|
||||
getSdkClient: () => ({}),
|
||||
getScopedSdkClient: () => ({}),
|
||||
},
|
||||
}));
|
||||
mock.module('@/stores/permissionStore', () => ({
|
||||
usePermissionStore: { getState: () => ({ isSessionAutoAccepting: () => false, hydrate: async () => undefined }) },
|
||||
}));
|
||||
mock.module('@/stores/useConfigStore', () => ({
|
||||
useConfigStore: {
|
||||
getState: () => ({ isConnected: true, hasEverConnected: true, settingsMessageStreamTransport: 'auto' }),
|
||||
setState: () => undefined,
|
||||
},
|
||||
}));
|
||||
mock.module('@/stores/useTodosPersistStore', () => ({
|
||||
useTodosPersistStore: { getState: () => ({ setSessionTodos: () => undefined }) },
|
||||
}));
|
||||
|
||||
const { useSessionMessageRecords } = await import('@/sync/sync-context');
|
||||
const { ChildStoreManager } = await import('@/sync/child-store');
|
||||
const { getSessionMaterializationStatus } = await import('@/sync/materialization');
|
||||
import type { State } from '@/sync/types';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(join(__dirname, '..', '..', '..', 'App.tsx'), 'utf-8');
|
||||
const chatContainerSource = readFileSync(join(__dirname, '..', 'ChatContainer.tsx'), 'utf-8');
|
||||
const chatViewSource = readFileSync(join(__dirname, '..', '..', 'views', 'ChatView.tsx'), 'utf-8');
|
||||
const syncContextSource = readFileSync(join(__dirname, '..', '..', '..', 'sync', 'sync-context.tsx'), 'utf-8');
|
||||
|
||||
const SESSION_ID = 'ses_subagent_2903';
|
||||
const DIRECTORY = '/repo';
|
||||
|
||||
const installMinimalDom = () => {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = (name: string, value: unknown) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ElementStub {}
|
||||
const documentStub: Record<string, unknown> = {
|
||||
nodeType: 9,
|
||||
defaultView: globalThis,
|
||||
activeElement: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
const container = {
|
||||
nodeType: 1,
|
||||
tagName: 'DIV',
|
||||
nodeName: 'DIV',
|
||||
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||
ownerDocument: documentStub,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
documentStub.documentElement = container;
|
||||
documentStub.body = container;
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', globalThis);
|
||||
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
|
||||
setGlobal('Element', ElementStub);
|
||||
setGlobal('HTMLElement', ElementStub);
|
||||
setGlobal('HTMLIFrameElement', ElementStub);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0));
|
||||
setGlobal('cancelAnimationFrame', (id: ReturnType<typeof setTimeout>) => clearTimeout(id));
|
||||
return {
|
||||
container: container as unknown as Element,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createMessage = (id: string, role: 'user' | 'assistant', created: number): Message => ({
|
||||
id,
|
||||
sessionID: SESSION_ID,
|
||||
role,
|
||||
...(role === 'assistant' ? { parentID: `u_${created}` } : {}),
|
||||
time: { created },
|
||||
} as Message);
|
||||
|
||||
const createPart = (id: string, messageID: string, text: string): Part => ({
|
||||
id,
|
||||
messageID,
|
||||
sessionID: SESSION_ID,
|
||||
type: 'text',
|
||||
text,
|
||||
} as Part);
|
||||
|
||||
/** 14-message subagent transcript, matching the issue reproduction fixture. */
|
||||
const buildMaterializedSubagentSession = () => {
|
||||
const messages: Message[] = [];
|
||||
const part: Record<string, Part[]> = {};
|
||||
for (let index = 0; index < 14; index += 1) {
|
||||
const created = index + 1;
|
||||
const role: 'user' | 'assistant' = created % 2 === 1 ? 'user' : 'assistant';
|
||||
const id = role === 'user' ? `u_${created}` : `a_${created}`;
|
||||
messages.push(createMessage(id, role, created));
|
||||
part[id] = [createPart(`prt_${id}`, id, role === 'user' ? `prompt ${created}` : `output ${created}`)];
|
||||
}
|
||||
return { messages, part };
|
||||
};
|
||||
|
||||
const syncContext = (globalThis as unknown as {
|
||||
__openchamber_sync_context__?: React.Context<unknown>;
|
||||
}).__openchamber_sync_context__;
|
||||
|
||||
if (!syncContext) {
|
||||
throw new Error('sync context was not published on globalThis by @/sync/sync-context');
|
||||
}
|
||||
|
||||
describe('issue #2903 busy embedded subagent status-line-only', () => {
|
||||
test('cold disabled reads hide a fully materialized 14-message subagent; enabled reads return all 14', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const childStores = new ChildStoreManager();
|
||||
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
|
||||
const { messages, part } = buildMaterializedSubagentSession();
|
||||
store.setState({
|
||||
status: 'complete',
|
||||
session: [{
|
||||
id: SESSION_ID,
|
||||
title: 'Audit Searchbar implementation',
|
||||
time: { created: 1, updated: 1 },
|
||||
version: '1',
|
||||
directory: DIRECTORY,
|
||||
} as State['session'][number]],
|
||||
message: { [SESSION_ID]: messages },
|
||||
part,
|
||||
} as Partial<State>);
|
||||
|
||||
expect(getSessionMaterializationStatus(store.getState(), SESSION_ID)).toEqual({
|
||||
hasMessages: true,
|
||||
renderable: true,
|
||||
missingPartMessageIDs: [],
|
||||
});
|
||||
|
||||
const system = { childStores, messageLoader: {}, sdk: {}, runtimeKey: 'test', directory: DIRECTORY };
|
||||
const Provider = syncContext.Provider as React.Provider<unknown>;
|
||||
let inactiveCount = -1;
|
||||
let activeCount = -1;
|
||||
let enabled = false;
|
||||
|
||||
const Harness = () => {
|
||||
const records = useSessionMessageRecords(SESSION_ID, DIRECTORY, { enabled });
|
||||
if (enabled) {
|
||||
activeCount = records.length;
|
||||
} else {
|
||||
inactiveCount = records.length;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Provider, { value: system }, React.createElement(Harness)));
|
||||
});
|
||||
expect(inactiveCount).toBe(0);
|
||||
|
||||
enabled = true;
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Provider, { value: system }, React.createElement(Harness)));
|
||||
});
|
||||
expect(activeCount).toBe(14);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('sync gate still returns empty on cold disabled reads', () => {
|
||||
const hookStart = syncContextSource.indexOf('export function useSessionMessageRecords(');
|
||||
const hookBody = syncContextSource.slice(hookStart, hookStart + 1800);
|
||||
expect(hookBody).toContain('if (options?.enabled === false)');
|
||||
expect(hookBody).toContain('EMPTY_SESSION_MESSAGE_RECORDS');
|
||||
expect(hookBody).toContain('snapshotRef.current.sessionID === sessionID ? snapshotRef.current.list');
|
||||
});
|
||||
|
||||
test('embedded session-chat keeps message history enabled while visibility gates active', () => {
|
||||
expect(appSource).toContain('messagesEnabled={true}');
|
||||
expect(appSource).toContain('active={embeddedBackgroundWorkEnabled}');
|
||||
expect(appSource).toContain('const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(false);');
|
||||
expect(chatViewSource).toContain('messagesEnabled?: boolean');
|
||||
expect(chatContainerSource).toContain('messagesEnabled: messagesEnabledProp');
|
||||
expect(chatContainerSource).toContain('const messagesEnabled = messagesEnabledProp ?? active;');
|
||||
expect(chatContainerSource).toContain('enabled: messagesEnabled');
|
||||
expect(chatContainerSource.includes('enabled: active')).toBe(false);
|
||||
expect(chatContainerSource).toContain('if (!messagesEnabled || !currentSessionId) return;');
|
||||
expect(chatContainerSource).toContain('void ensureSessionRenderable(currentSessionId);');
|
||||
});
|
||||
|
||||
test('the empty and idle branch leaves the status row to the busy path', () => {
|
||||
// A busy session with no messages yet must fall through to the viewport so
|
||||
// StatusRowContainer is the only thing on screen. The idle branch returns
|
||||
// before it and must not render one of its own. The empty state itself no
|
||||
// longer lives here: the draft surface owns it since the draft transition
|
||||
// animation landed.
|
||||
expect(chatContainerSource).toContain('if (sessionMessages.length === 0 && !sessionIsWorking)');
|
||||
expect(chatContainerSource).toContain('<StatusRowContainer />');
|
||||
|
||||
const emptyIdleGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
|
||||
const emptyIdleReturn = chatContainerSource.indexOf(emptyIdleGuard);
|
||||
expect(emptyIdleReturn).toBeGreaterThan(-1);
|
||||
const emptyIdleBlock = chatContainerSource.slice(
|
||||
emptyIdleReturn,
|
||||
emptyIdleReturn + 1600,
|
||||
);
|
||||
expect(emptyIdleBlock).not.toContain('<StatusRowContainer />');
|
||||
});
|
||||
|
||||
test('visibility handshake remains as defense-in-depth for background work', () => {
|
||||
expect(appSource).toContain('requestEmbeddedSessionVisibility();');
|
||||
expect(appSource).toContain('EMBEDDED_VISIBILITY_UPDATE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Regression coverage for https://github.com/openchamber/openchamber/issues/3036.
|
||||
*
|
||||
* Restoring persisted agent/model pairs used to switch agents before checking
|
||||
* whether each model still existed. Several stale pairs could therefore keep
|
||||
* changing the active agent on every effect pass until React hit its nested
|
||||
* update limit. The API error belongs in the assistant message; an invalid
|
||||
* persisted pair must not mutate the current selection while it is rendered.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const modelControlsSource = readFileSync(join(__dirname, '..', 'ModelControls.tsx'), 'utf-8');
|
||||
|
||||
describe('issue #3036 stale persisted models', () => {
|
||||
test('changes the agent only after its persisted model is accepted', () => {
|
||||
const candidateLoop = modelControlsSource.slice(
|
||||
modelControlsSource.indexOf('for (const agent of agents)'),
|
||||
modelControlsSource.indexOf("return 'continue';"),
|
||||
);
|
||||
|
||||
const applyIndex = candidateLoop.indexOf('const result = tryApplyModelSelection');
|
||||
const acceptedIndex = candidateLoop.indexOf("if (result === 'applied')");
|
||||
const setAgentIndex = candidateLoop.indexOf('setAgent(agent.name)');
|
||||
|
||||
expect(applyIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(acceptedIndex).toBeGreaterThan(applyIndex);
|
||||
expect(setAgentIndex).toBeGreaterThan(acceptedIndex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
|
||||
|
||||
import {
|
||||
getAppLinkConfirmationSnapshot,
|
||||
openAppLinkWithConfirmation,
|
||||
settleAppLinkConfirmation,
|
||||
} from './appLinkConfirmation';
|
||||
|
||||
describe('app link confirmation', () => {
|
||||
beforeEach(() => {
|
||||
useAppLinkTrustStore.setState({ trustedSchemes: [] });
|
||||
const pending = getAppLinkConfirmationSnapshot();
|
||||
if (pending) {
|
||||
settleAppLinkConfirmation('cancel');
|
||||
}
|
||||
});
|
||||
|
||||
test('opens trusted schemes without asking', async () => {
|
||||
useAppLinkTrustStore.getState().trustScheme('obsidian');
|
||||
|
||||
await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes');
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true);
|
||||
});
|
||||
|
||||
test('asks once and trusts the scheme when the user chooses trust', async () => {
|
||||
const pending = openAppLinkWithConfirmation('linear://issue/ABC-1');
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1');
|
||||
|
||||
settleAppLinkConfirmation('trust');
|
||||
await pending;
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true);
|
||||
});
|
||||
|
||||
test('cancel opens nothing and keeps the scheme untrusted', async () => {
|
||||
const pending = openAppLinkWithConfirmation('notion://note/xyz');
|
||||
|
||||
settleAppLinkConfirmation('cancel');
|
||||
await pending;
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false);
|
||||
});
|
||||
|
||||
test('a newer request cancels the pending one', async () => {
|
||||
const first = openAppLinkWithConfirmation('obsidian://open?vault=a');
|
||||
const firstChoice = first.then(
|
||||
() => 'settled',
|
||||
() => 'settled',
|
||||
);
|
||||
const second = openAppLinkWithConfirmation('linear://open/1');
|
||||
|
||||
expect(await firstChoice).toBe('settled');
|
||||
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1');
|
||||
|
||||
settleAppLinkConfirmation('open');
|
||||
await second;
|
||||
|
||||
expect(getAppLinkConfirmationSnapshot()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
|
||||
import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url';
|
||||
|
||||
export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel';
|
||||
|
||||
type PendingAppLinkRequest = {
|
||||
url: string;
|
||||
resolve: (choice: AppLinkConfirmationChoice) => void;
|
||||
};
|
||||
|
||||
let pendingRequest: PendingAppLinkRequest | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emitChange = (): void => {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest;
|
||||
|
||||
const subscribe = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask the user (via the app-level confirmation dialog) whether an application
|
||||
* deep link may be opened. Resolves immediately when the scheme was trusted
|
||||
* earlier. Only one request is active at a time; a new request cancels the
|
||||
* pending one.
|
||||
*/
|
||||
export const openAppLinkWithConfirmation = (url: string): Promise<void> => {
|
||||
const scheme = getUrlScheme(url);
|
||||
if (!scheme) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const trustStore = useAppLinkTrustStore.getState();
|
||||
if (trustStore.isSchemeTrusted(scheme)) {
|
||||
return openConfirmedAppLinkUrl(url).then(() => undefined);
|
||||
}
|
||||
|
||||
if (pendingRequest) {
|
||||
pendingRequest.resolve('cancel');
|
||||
}
|
||||
|
||||
return new Promise<AppLinkConfirmationChoice>((resolve) => {
|
||||
pendingRequest = { url, resolve };
|
||||
emitChange();
|
||||
}).then((choice) => {
|
||||
if (choice === 'trust') {
|
||||
useAppLinkTrustStore.getState().trustScheme(scheme);
|
||||
}
|
||||
if (choice === 'open' || choice === 'trust') {
|
||||
return openConfirmedAppLinkUrl(url).then(() => undefined);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => {
|
||||
const request = pendingRequest;
|
||||
pendingRequest = null;
|
||||
emitChange();
|
||||
request?.resolve(choice);
|
||||
};
|
||||
|
||||
export const subscribeAppLinkConfirmation = subscribe;
|
||||
export const getAppLinkConfirmationSnapshot = getSnapshot;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { attachAppLinkInteractions } from './appLinkInteractions';
|
||||
|
||||
const TestElement = class Element {};
|
||||
const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {};
|
||||
Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement });
|
||||
|
||||
class TestAnchor extends HTMLAnchorElement {
|
||||
constructor(private readonly rawHref: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
return name === 'href' ? this.rawHref : null;
|
||||
}
|
||||
|
||||
closest(): TestAnchor {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class TestContainer {
|
||||
listeners = new Map<string, EventListener>();
|
||||
|
||||
addEventListener(name: string, listener: (event: MouseEvent) => void): void {
|
||||
// SAFETY: dispatch constructs every mouse field read by the production listener.
|
||||
this.listeners.set(name, (event) => listener(event as MouseEvent));
|
||||
}
|
||||
|
||||
removeEventListener(name: string, listener: (event: MouseEvent) => void): void {
|
||||
void listener;
|
||||
this.listeners.delete(name);
|
||||
}
|
||||
|
||||
dispatch(name: string, href: string, init: Partial<MouseEvent> = {}): Event {
|
||||
const event = new Event(name, { cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
target: { value: new TestAnchor(href) },
|
||||
button: { value: init.button ?? 0 },
|
||||
metaKey: { value: init.metaKey ?? false },
|
||||
ctrlKey: { value: init.ctrlKey ?? false },
|
||||
altKey: { value: init.altKey ?? false },
|
||||
shiftKey: { value: init.shiftKey ?? false },
|
||||
});
|
||||
this.listeners.get(name)?.(event);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
const setup = (allowExternalHttp = true) => {
|
||||
const container = new TestContainer();
|
||||
const appLinks: string[] = [];
|
||||
const httpLinks: string[] = [];
|
||||
const cleanup = attachAppLinkInteractions(container, {
|
||||
allowExternalHttp,
|
||||
openAppLink: (url) => appLinks.push(url),
|
||||
openExternalHttp: (url) => httpLinks.push(url),
|
||||
});
|
||||
return { container, appLinks, httpLinks, cleanup };
|
||||
};
|
||||
|
||||
describe('app link interactions', () => {
|
||||
test('confirms plain, modifier, and middle-click activations', () => {
|
||||
const { container, appLinks } = setup();
|
||||
const href = 'obsidian://open?vault=Notes';
|
||||
|
||||
expect(container.dispatch('click', href).defaultPrevented).toBe(true);
|
||||
expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true);
|
||||
expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true);
|
||||
expect(appLinks).toEqual([href, href, href]);
|
||||
});
|
||||
|
||||
test('blocks drag activation without opening immediately', () => {
|
||||
const { container, appLinks } = setup();
|
||||
const href = 'obsidian://open?vault=Notes';
|
||||
|
||||
expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true);
|
||||
expect(appLinks).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => {
|
||||
const enabled = setup();
|
||||
const disabled = setup(false);
|
||||
const href = 'https://example.com';
|
||||
|
||||
expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false);
|
||||
expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true);
|
||||
expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false);
|
||||
expect(enabled.httpLinks).toEqual([href]);
|
||||
expect(disabled.httpLinks).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url';
|
||||
|
||||
type AppLinkInteractionOptions = {
|
||||
allowExternalHttp: boolean;
|
||||
openAppLink: (url: string) => void;
|
||||
openExternalHttp: (url: string) => void;
|
||||
};
|
||||
|
||||
type LinkInteractionContainer = {
|
||||
addEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
|
||||
removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
|
||||
};
|
||||
|
||||
const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return null;
|
||||
const anchor = target.closest('a[href]');
|
||||
if (!(anchor instanceof HTMLAnchorElement)) return null;
|
||||
if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null;
|
||||
return anchor;
|
||||
};
|
||||
|
||||
const interceptAppLink = (
|
||||
event: MouseEvent | DragEvent,
|
||||
openAppLink?: (url: string) => void,
|
||||
): boolean => {
|
||||
if (event.defaultPrevented) return false;
|
||||
const anchor = findLink(event);
|
||||
const href = anchor?.getAttribute('href') ?? '';
|
||||
if (!isAppLinkUrl(href)) return false;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openAppLink?.(href);
|
||||
return true;
|
||||
};
|
||||
|
||||
const isPlainPrimaryClick = (event: MouseEvent): boolean => (
|
||||
event.button === 0
|
||||
&& !event.metaKey
|
||||
&& !event.ctrlKey
|
||||
&& !event.altKey
|
||||
&& !event.shiftKey
|
||||
);
|
||||
|
||||
export const attachAppLinkInteractions = (
|
||||
container: LinkInteractionContainer,
|
||||
options: AppLinkInteractionOptions,
|
||||
): (() => void) => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (interceptAppLink(event, options.openAppLink)) return;
|
||||
if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return;
|
||||
|
||||
const href = findLink(event)?.getAttribute('href') ?? '';
|
||||
if (!isExternalHttpUrl(href)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
options.openExternalHttp(href);
|
||||
};
|
||||
const handleAuxClick = (event: MouseEvent) => {
|
||||
if (event.button === 1) interceptAppLink(event, options.openAppLink);
|
||||
};
|
||||
const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => {
|
||||
interceptAppLink(event);
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
container.addEventListener('auxclick', handleAuxClick);
|
||||
container.addEventListener('dragstart', blockAlternateAppLinkActivation);
|
||||
return () => {
|
||||
container.removeEventListener('click', handleClick);
|
||||
container.removeEventListener('auxclick', handleAuxClick);
|
||||
container.removeEventListener('dragstart', blockAlternateAppLinkActivation);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,477 @@
|
||||
import React from 'react';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useBtwStore } from '@/stores/useBtwStore';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import {
|
||||
useSessionMessageRecords,
|
||||
useSessionRenderable,
|
||||
useSessionStatus,
|
||||
useScopedBlockingPermissions,
|
||||
useScopedBlockingQuestions,
|
||||
} from '@/sync/sync-context';
|
||||
import { useStreamingStore } from '@/sync/streaming';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { destroyBtwSession, filterBtwTailMessages, promoteBtwSession, type BtwSessionRef } from '@/lib/btw';
|
||||
import type { BtwPanelState } from './useBtwPanelState';
|
||||
import { ChatSurfaceProvider } from '../ChatSurfaceContext';
|
||||
import { useMobileAutocompleteMaxHeight } from '../useMobileAutocompleteMaxHeight';
|
||||
import ChatMessage from '../ChatMessage';
|
||||
import { PermissionCard } from '../PermissionCard';
|
||||
import { QuestionCard } from '../QuestionCard';
|
||||
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
|
||||
/**
|
||||
* The `/btw` peek panel.
|
||||
*
|
||||
* Rendered from inside the composer form, so the sheet docks exactly above
|
||||
* the main composer (`absolute bottom-full` on the composer column) on both
|
||||
* desktop and mobile — the main composer IS the btw input, so nothing may
|
||||
* cover it. Identity is derived from the parent session's metadata (see
|
||||
* `useBtwPanelState`), so the panel belongs to one parent session only.
|
||||
*
|
||||
* Three exits: collapse (panel minimizes to the composer chip, the composer
|
||||
* returns to the main session), promote (the fork becomes a normal session
|
||||
* and the app navigates to it), destroy (the fork is deleted; the main
|
||||
* conversation is never touched).
|
||||
*/
|
||||
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState }> = ({
|
||||
parentSessionId,
|
||||
panel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (panel.btwSessionId && panel.btwDirectory) {
|
||||
return (
|
||||
<BtwSheet
|
||||
sessionRef={{
|
||||
parentSessionId,
|
||||
btwSessionId: panel.btwSessionId,
|
||||
directory: panel.btwDirectory,
|
||||
}}
|
||||
title={panel.btwSession?.title?.trim() || t('chat.btw.titleFallback')}
|
||||
boundaryMessageID={panel.boundaryMessageID}
|
||||
collapsed={panel.collapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (panel.creating) {
|
||||
return (
|
||||
<BtwFrame title={t('chat.btw.titleFallback')}>
|
||||
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
<span>{t('chat.btw.loading')}</span>
|
||||
</div>
|
||||
</BtwFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const useBtwDestroy = (sessionRef: BtwSessionRef | null): (() => void) => {
|
||||
const { t } = useI18n();
|
||||
return React.useCallback(() => {
|
||||
if (!sessionRef) return;
|
||||
void destroyBtwSession(sessionRef).then((ok) => {
|
||||
if (!ok) toast.error(t('chat.btw.toast.destroyFailed'));
|
||||
});
|
||||
}, [sessionRef, t]);
|
||||
};
|
||||
|
||||
type BtwSessionData = {
|
||||
messageRecords: Array<{ info: Message; parts: Part[] }>;
|
||||
sessionIsWorking: boolean;
|
||||
streamingMessageId: string | null;
|
||||
activeStreamingPhase: 'streaming' | 'cooldown' | 'completed' | null;
|
||||
sessionPermissions: ReturnType<typeof useScopedBlockingPermissions>;
|
||||
sessionQuestions: ReturnType<typeof useScopedBlockingQuestions>;
|
||||
isEmpty: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Live session data for the fork, all keyed by the fork's own ids. Only the
|
||||
* fork's tail (messages after the inherited-history boundary) is shown.
|
||||
*/
|
||||
const useBtwSessionData = (
|
||||
sessionId: string,
|
||||
directory: string,
|
||||
boundaryMessageID: string | null,
|
||||
): BtwSessionData => {
|
||||
const sync = useSync();
|
||||
const renderable = useSessionRenderable(sessionId, directory);
|
||||
React.useEffect(() => {
|
||||
if (!renderable) {
|
||||
void sync.ensureSessionRenderable(sessionId, false, directory);
|
||||
}
|
||||
}, [directory, renderable, sessionId, sync]);
|
||||
|
||||
const messageRecords = useSessionMessageRecords(sessionId, directory);
|
||||
const status = useSessionStatus(sessionId, directory) ?? IDLE_SESSION_STATUS;
|
||||
const streamingMessageId = useStreamingStore(
|
||||
React.useCallback((s) => s.streamingMessageIds.get(sessionId) ?? null, [sessionId]),
|
||||
);
|
||||
const activeStreamingPhase = useStreamingStore(
|
||||
React.useCallback(
|
||||
(s) => (streamingMessageId ? s.messageStreamStates.get(streamingMessageId)?.phase ?? null : null),
|
||||
[streamingMessageId],
|
||||
),
|
||||
);
|
||||
const sessionPermissions = useScopedBlockingPermissions(sessionId, directory);
|
||||
const sessionQuestions = useScopedBlockingQuestions(sessionId, directory);
|
||||
|
||||
const tailRecords = React.useMemo(
|
||||
() => filterBtwTailMessages(messageRecords, boundaryMessageID),
|
||||
[boundaryMessageID, messageRecords],
|
||||
);
|
||||
|
||||
const sessionIsWorking = React.useMemo(() => {
|
||||
if (sessionPermissions.length > 0 || sessionQuestions.length > 0) {
|
||||
return false;
|
||||
}
|
||||
const statusType = status.type ?? 'idle';
|
||||
if (statusType === 'busy' || statusType === 'retry') {
|
||||
return true;
|
||||
}
|
||||
// SAFETY: reads only the optional `time.completed` field, which the
|
||||
// SDK Message union does not expose uniformly; a missing value means
|
||||
// the assistant turn has not completed.
|
||||
const lastMessage = tailRecords[tailRecords.length - 1]?.info as (Message & { time?: { completed?: number } }) | undefined;
|
||||
return Boolean(
|
||||
lastMessage
|
||||
&& lastMessage.role === 'assistant'
|
||||
&& typeof lastMessage.time?.completed !== 'number',
|
||||
);
|
||||
}, [sessionPermissions.length, sessionQuestions.length, status.type, tailRecords]);
|
||||
|
||||
return {
|
||||
messageRecords: tailRecords,
|
||||
sessionIsWorking,
|
||||
streamingMessageId,
|
||||
activeStreamingPhase,
|
||||
sessionPermissions,
|
||||
sessionQuestions,
|
||||
isEmpty: tailRecords.length === 0,
|
||||
};
|
||||
};
|
||||
|
||||
/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
|
||||
const useEscapeToCollapse = (onCollapse: () => void): void => {
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
// SAFETY: keydown targets are DOM elements (or null on window).
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
onCollapse();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onCollapse]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stick-to-bottom auto-scroll. Streaming grows content inside one message
|
||||
* without changing the record count, so following the tail needs a
|
||||
* ResizeObserver on the content wrapper — data-driven effects alone would
|
||||
* stop following mid-stream.
|
||||
*/
|
||||
const useAutoScroll = (
|
||||
bodyRef: React.RefObject<HTMLDivElement | null>,
|
||||
contentRef: React.RefObject<HTMLDivElement | null>,
|
||||
contentReady: boolean,
|
||||
): ((event: React.UIEvent<HTMLDivElement>) => void) => {
|
||||
const stickToBottomRef = React.useRef(true);
|
||||
// `contentReady` is a dependency because the refs are only attached once
|
||||
// the empty state gives way to the message list; an effect keyed on the
|
||||
// refs alone would run against `null` and never re-attach the observer.
|
||||
React.useEffect(() => {
|
||||
if (!contentReady) return;
|
||||
const content = contentRef.current;
|
||||
const element = bodyRef.current;
|
||||
if (element && stickToBottomRef.current) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
if (!content || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const body = bodyRef.current;
|
||||
if (body && stickToBottomRef.current) {
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
});
|
||||
observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, [bodyRef, contentReady, contentRef]);
|
||||
return React.useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const element = event.currentTarget;
|
||||
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const BtwFrame: React.FC<{
|
||||
title: string;
|
||||
actions?: React.ReactNode;
|
||||
onTitleClick?: () => void;
|
||||
titleClickLabel?: string;
|
||||
collapsed?: boolean;
|
||||
headerSpinner?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
|
||||
<div
|
||||
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
|
||||
role="dialog"
|
||||
aria-label="btw"
|
||||
>
|
||||
<div className="oc-glass-popover w-full overflow-hidden rounded-xl border border-[var(--interactive-border)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||
{onTitleClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTitleClick}
|
||||
aria-label={titleClickLabel}
|
||||
title={titleClickLabel}
|
||||
className="flex min-w-0 items-center gap-2 text-left text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{headerSpinner ? (
|
||||
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="typography-ui-label min-w-0 truncate font-semibold">
|
||||
{title}
|
||||
</span>
|
||||
<Icon name={collapsed ? 'arrow-up-s' : 'arrow-down-s'} className="size-4 shrink-0" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
|
||||
<h2 className="typography-ui-label min-w-0 truncate font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1" />
|
||||
{actions}
|
||||
</div>
|
||||
{children ? (
|
||||
<>
|
||||
{children}
|
||||
<div className="h-2" />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BtwSheet: React.FC<{
|
||||
sessionRef: BtwSessionRef;
|
||||
title: string;
|
||||
boundaryMessageID: string | null;
|
||||
collapsed: boolean;
|
||||
}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => {
|
||||
const { t } = useI18n();
|
||||
const handleDestroy = useBtwDestroy(sessionRef);
|
||||
const setCollapsed = React.useCallback((next: boolean) => {
|
||||
useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next });
|
||||
}, [sessionRef.parentSessionId]);
|
||||
const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
|
||||
const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]);
|
||||
const handlePromote = React.useCallback(() => {
|
||||
void promoteBtwSession(sessionRef).catch(() => {
|
||||
toast.error(t('chat.btw.toast.promoteFailed'));
|
||||
});
|
||||
}, [sessionRef, t]);
|
||||
useEscapeToCollapse(handleCollapse);
|
||||
|
||||
const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria');
|
||||
const headerButtonClass = 'size-7 rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:!bg-transparent active:!bg-transparent';
|
||||
const actions = (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={headerButtonClass}
|
||||
onClick={handlePromote}
|
||||
aria-label={t('chat.btw.promoteAria')}
|
||||
title={t('chat.btw.promoteAria')}
|
||||
>
|
||||
<Icon name="external-link" className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={headerButtonClass}
|
||||
onClick={handleDestroy}
|
||||
aria-label={t('chat.btw.destroyAria')}
|
||||
title={t('chat.btw.destroyAria')}
|
||||
>
|
||||
<Icon name="close" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<BtwCollapsedStrip
|
||||
sessionRef={sessionRef}
|
||||
title={title}
|
||||
actions={actions}
|
||||
onExpand={handleToggleCollapsed}
|
||||
expandLabel={toggleLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BtwExpandedSheet
|
||||
sessionRef={sessionRef}
|
||||
title={title}
|
||||
boundaryMessageID={boundaryMessageID}
|
||||
actions={actions}
|
||||
onTitleClick={handleToggleCollapsed}
|
||||
titleClickLabel={toggleLabel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapsed mode: only the header strip stays docked above the composer. The
|
||||
* fork keeps running in the background; a spinner replaces the header icon
|
||||
* while it is busy so activity stays visible without the message list.
|
||||
*/
|
||||
const BtwCollapsedStrip: React.FC<{
|
||||
sessionRef: BtwSessionRef;
|
||||
title: string;
|
||||
actions: React.ReactNode;
|
||||
onExpand: () => void;
|
||||
expandLabel: string;
|
||||
}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => {
|
||||
const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS;
|
||||
const isBusy = status.type === 'busy' || status.type === 'retry';
|
||||
return (
|
||||
<BtwFrame
|
||||
title={title}
|
||||
actions={actions}
|
||||
onTitleClick={onExpand}
|
||||
titleClickLabel={expandLabel}
|
||||
collapsed
|
||||
headerSpinner={isBusy}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const BtwExpandedSheet: React.FC<{
|
||||
sessionRef: BtwSessionRef;
|
||||
title: string;
|
||||
boundaryMessageID: string | null;
|
||||
actions: React.ReactNode;
|
||||
onTitleClick: () => void;
|
||||
titleClickLabel: string;
|
||||
}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
|
||||
const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID);
|
||||
const bodyRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const handleBodyScroll = useAutoScroll(bodyRef, contentRef, !data.isEmpty);
|
||||
// With the on-screen keyboard open the composer (this panel's anchor)
|
||||
// rises, and a vh-based cap would push the panel under the app header.
|
||||
// Same protection as the composer autocomplete popups: clamp the scroll
|
||||
// body to the space actually available above the anchor. The hook measures
|
||||
// room for the scroll body itself, but the panel header and bottom spacer
|
||||
// sit inside the same frame above/below it — reserve their height too.
|
||||
const BTW_FRAME_CHROME_PX = 48;
|
||||
const availableMaxHeight = useMobileAutocompleteMaxHeight(bodyRef, true, 520 + BTW_FRAME_CHROME_PX);
|
||||
const mobileMaxHeight = availableMaxHeight !== undefined
|
||||
? Math.max(120, availableMaxHeight - BTW_FRAME_CHROME_PX)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<BtwFrame title={title} actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
|
||||
<ChatSurfaceProvider mode="peek">
|
||||
<BtwMessages
|
||||
data={data}
|
||||
bodyRef={bodyRef}
|
||||
contentRef={contentRef}
|
||||
onBodyScroll={handleBodyScroll}
|
||||
maxHeight={mobileMaxHeight}
|
||||
/>
|
||||
</ChatSurfaceProvider>
|
||||
</BtwFrame>
|
||||
);
|
||||
};
|
||||
|
||||
const BtwMessages: React.FC<{
|
||||
data: BtwSessionData;
|
||||
bodyRef: React.RefObject<HTMLDivElement | null>;
|
||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||
onBodyScroll: (event: React.UIEvent<HTMLDivElement>) => void;
|
||||
maxHeight?: number;
|
||||
}> = ({ data, bodyRef, contentRef, onBodyScroll, maxHeight }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (data.isEmpty) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
<span>{t('chat.btw.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollShadow
|
||||
ref={bodyRef}
|
||||
onScroll={onBodyScroll}
|
||||
size={32}
|
||||
data-scroll-shadow="true"
|
||||
className="max-h-[min(55vh,520px)] min-h-0 overflow-y-auto px-3 py-1"
|
||||
style={maxHeight !== undefined ? { maxHeight } : undefined}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
{data.messageRecords.map((record, index) => (
|
||||
<ChatMessage
|
||||
key={record.info.id}
|
||||
message={record}
|
||||
previousMessage={data.messageRecords[index - 1]}
|
||||
nextMessage={data.messageRecords[index + 1]}
|
||||
isInActiveTurn={index === data.messageRecords.length - 1}
|
||||
activeStreamingPhase={
|
||||
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{data.sessionQuestions.length > 0 || data.sessionPermissions.length > 0 ? (
|
||||
<div>
|
||||
{data.sessionQuestions.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} />
|
||||
))}
|
||||
{data.sessionPermissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Always reserve this row so the content does not shift down
|
||||
by a line when the indicator disappears. */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-1 py-2 text-xs text-muted-foreground',
|
||||
!data.sessionIsWorking && 'invisible',
|
||||
)}
|
||||
aria-hidden={!data.sessionIsWorking}
|
||||
>
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
<span>{t('chat.btw.working')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useSession } from '@/sync/sync-context';
|
||||
import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetadata';
|
||||
import { useBtwStore } from '@/stores/useBtwStore';
|
||||
|
||||
export type BtwPanelState = {
|
||||
/** The session the composer is in — the one `/btw` would fork. */
|
||||
parentSession: Session | null;
|
||||
/** The active fork for this parent, or null when no panel should exist. */
|
||||
btwSessionId: string | null;
|
||||
btwSession: Session | null;
|
||||
/** The fork's directory identity (may be canonicalized by the server). */
|
||||
btwDirectory: string | null;
|
||||
/** Last message id inherited from the parent; the panel shows what's after it. */
|
||||
boundaryMessageID: string | null;
|
||||
collapsed: boolean;
|
||||
creating: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the `/btw` panel identity for one parent session from authoritative
|
||||
* session metadata (`openchamber.btwSessionID`), plus the transient UI state
|
||||
* kept in `useBtwStore`. The panel exists only while the parent's link AND the
|
||||
* fork itself are present in the live stores, so a fork deleted anywhere
|
||||
* (sidebar, another client) makes the panel disappear without extra tracking.
|
||||
*/
|
||||
export function useBtwPanelState(
|
||||
parentSessionId: string | null | undefined,
|
||||
directory: string | undefined,
|
||||
): BtwPanelState {
|
||||
const parentSession = useSession(parentSessionId, directory);
|
||||
const linkedBtwSessionId = getBtwSessionID(parentSession);
|
||||
const btwSession = useSession(linkedBtwSessionId, directory) ?? null;
|
||||
const uiState = useBtwStore(
|
||||
React.useCallback(
|
||||
(s) => (parentSessionId ? s.byParent[parentSessionId] : undefined),
|
||||
[parentSessionId],
|
||||
),
|
||||
);
|
||||
|
||||
const destroying = Boolean(uiState?.destroying);
|
||||
const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null;
|
||||
return {
|
||||
parentSession: parentSession ?? null,
|
||||
btwSessionId,
|
||||
btwSession: btwSessionId ? btwSession : null,
|
||||
// SAFETY: the SDK Session type omits the server's `directory` field; this
|
||||
// widening only reads it, with the parent's directory as the fallback.
|
||||
btwDirectory: btwSessionId
|
||||
? ((btwSession as (Session & { directory?: string | null }) | null)?.directory ?? directory ?? null)
|
||||
: null,
|
||||
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
|
||||
collapsed: Boolean(uiState?.collapsed),
|
||||
creating: Boolean(uiState?.creating),
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
|
||||
import { withReviewSessionMarker } from '@/lib/sessionReviewMetadata';
|
||||
|
||||
const session = (parentID?: string): Session => ({
|
||||
id: 'session',
|
||||
@@ -27,4 +28,14 @@ describe('resolveChatPromptReadOnly', () => {
|
||||
expect(resolveChatPromptReadOnly(session(), true, true)).toBe(true);
|
||||
expect(resolveChatPromptReadOnly(session(), true, false)).toBe(false);
|
||||
});
|
||||
|
||||
test('treats a marked code review as an independent session even with a stale parent ID', () => {
|
||||
const reviewSession = {
|
||||
...session('original'),
|
||||
metadata: withReviewSessionMarker({}, 'original'),
|
||||
} as Session;
|
||||
|
||||
expect(resolveChatPromptReadOnly(reviewSession, false, false)).toBe(false);
|
||||
expect(resolveChatPromptReadOnly(reviewSession, true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { isReviewSession } from '@/lib/sessionReviewMetadata';
|
||||
|
||||
export const resolveChatPromptReadOnly = (
|
||||
session: Session | null | undefined,
|
||||
allowPromptingSubagentSessions: boolean,
|
||||
readOnly: boolean,
|
||||
): boolean => {
|
||||
// Review sessions are independent conversations even if an older server or
|
||||
// cached record still carries parentID. Their explicit metadata is the
|
||||
// authority; only the surface itself may make them read-only.
|
||||
if (isReviewSession(session)) {
|
||||
return readOnly;
|
||||
}
|
||||
|
||||
if (session?.parentID) {
|
||||
return !allowPromptingSubagentSessions;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
export type ChatSurfaceMode = 'default' | 'mini-chat';
|
||||
/**
|
||||
* 'mini-chat' is the browser-panel side chat (compact, no fork/plan actions).
|
||||
* 'peek' is a read-only glance surface (the /btw panel): messages render with
|
||||
* no per-message controls at all — no user action row, no assistant action
|
||||
* buttons, no turn footer.
|
||||
*/
|
||||
export type ChatSurfaceMode = 'default' | 'mini-chat' | 'peek';
|
||||
|
||||
export const ChatSurfaceContext = React.createContext<ChatSurfaceMode>('default');
|
||||
|
||||
@@ -1,33 +1,85 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
|
||||
/**
|
||||
* Compact one-line mirror of the status row for the pill: same label, none of
|
||||
* the status row's animation machinery (which does not survive being squeezed
|
||||
* into a 32px chip).
|
||||
*/
|
||||
const PillWorkingStatus: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { activeModel, working } = useAssistantStatus();
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
|
||||
const modelName = React.useMemo(() => {
|
||||
if (!activeModel) return null;
|
||||
const provider = providers.find((candidate) => candidate.id === activeModel.providerId);
|
||||
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
|
||||
}, [activeModel, providers]);
|
||||
|
||||
if (!working.isWorking || !working.statusText) return null;
|
||||
const status = working.statusText;
|
||||
const label = modelName && modelName.trim().length > 0
|
||||
? t('chat.statusRow.modelStatus', { model: modelName.trim(), status })
|
||||
: status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
return (
|
||||
<span className="min-w-0 truncate pr-3 text-sm text-muted-foreground">
|
||||
{label}
|
||||
<span className="animate-pulse"> …</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface ScrollToBottomButtonProps {
|
||||
visible: boolean;
|
||||
/** The session is still streaming: the pill carries the status label
|
||||
while the floating status row is hidden away from the live edge. */
|
||||
working?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => {
|
||||
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, working = false, onClick }) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-full left-1/2 -translate-x-1/2 mb-2 transition-all duration-150',
|
||||
visible ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' : 'opacity-0 translate-y-2 scale-95 pointer-events-none',
|
||||
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
|
||||
visible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
|
||||
aria-label={t('chat.scrollToBottom.aria')}
|
||||
>
|
||||
<Icon name="arrow-down" className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* The same column that centres the composer, so the pill's left
|
||||
edge lines up exactly with the input frame. */}
|
||||
<div className="chat-input-column">
|
||||
{/* The soft shadow lives on this wrapper, away from the glass
|
||||
button's backdrop-filter: sharing one element made the
|
||||
shadow intermittently drop after hide/show cycles. */}
|
||||
<div className="inline-flex max-w-full rounded-full shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.10)] dark:shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.35)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={t('chat.scrollToBottom.aria')}
|
||||
className={cn(
|
||||
// Glass material with a hairline real border — much
|
||||
// lighter than the oc-glass-floating stack.
|
||||
'oc-glass-popover inline-flex h-8 max-w-full items-center rounded-full [corner-shape:round] text-left',
|
||||
'border border-black/[0.06] dark:border-white/[0.08]',
|
||||
visible ? 'pointer-events-auto' : 'pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Icon name="arrow-down" className="h-4 w-4" />
|
||||
</span>
|
||||
{working && visible ? <PillWorkingStatus /> : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup';
|
||||
import type { TurnActivityRecord } from '../lib/turns/types';
|
||||
import type { ToolPopupContent } from '../message/types';
|
||||
import type { StreamPhase } from '../message/types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
|
||||
interface DiffStats {
|
||||
additions: number;
|
||||
@@ -21,7 +20,6 @@ interface TurnActivityProps {
|
||||
expandedTools: Set<string>;
|
||||
onToggleTool: (toolId: string) => void;
|
||||
onShowPopup: (content: ToolPopupContent) => void;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
streamPhase: StreamPhase;
|
||||
showHeader: boolean;
|
||||
animateRows?: boolean;
|
||||
|
||||
@@ -18,13 +18,13 @@ const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, rend
|
||||
data-scroll-spy-id={turn.turnId}
|
||||
>
|
||||
{stickyUserHeader ? (
|
||||
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] [overflow-anchor:none]">
|
||||
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] pb-4 sm:pb-8 [overflow-anchor:none]">
|
||||
<div className="relative z-10">
|
||||
{renderMessage(turn.userMessage)}
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-full z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -7,6 +7,16 @@ everything between typing and sending.
|
||||
own state and wires these modules together; it should not grow logic that
|
||||
belongs to one of them.
|
||||
|
||||
`ChatContainer.tsx` keeps one `ChatInput` mounted while a new-session draft
|
||||
becomes its first session. Draft-only UI first fades for 120ms while the editor
|
||||
stays in place. The parent then moves the editor to its final session position
|
||||
with a 180ms transform-only FLIP animation. Reduced-motion mode skips these
|
||||
transitions. `session-ui-store.ts` marks sessions materialized from a submitted
|
||||
draft, so selecting an existing session while a draft is open switches without
|
||||
animation. Do not restore separate draft and session composer branches:
|
||||
remounting the editor loses focus and interrupts the transition. Keep the
|
||||
existing mobile fixed-position rules unchanged.
|
||||
|
||||
## Layers
|
||||
|
||||
| Directory | Owns |
|
||||
@@ -50,6 +60,15 @@ copy.
|
||||
exactly what gets sent, so nothing downstream serializes a rich document model
|
||||
back into a prompt.
|
||||
|
||||
The document is not, however, the string it was given: CodeMirror normalizes
|
||||
line endings, so a `\r\n` pair becomes one break and the document ends up
|
||||
shorter than the inserted string. **Never derive a caret position from the
|
||||
length of text you are inserting** — a caret past the end makes `dispatch`
|
||||
throw, the transaction never applies, and the un-normalized text stays in React
|
||||
state to crash again on the next restore. Every edit that moves the caret goes
|
||||
through `replaceWithCaret` (`editor/documentEdits.ts`), which measures the
|
||||
change instead of the string.
|
||||
|
||||
The composer previously painted a transparent `<textarea>` over a mirror
|
||||
`<div>`. That restricted highlighting to styles which do not change glyph
|
||||
advance width — colour, background, underline — because anything else made the
|
||||
@@ -61,20 +80,54 @@ question of design, not of feasibility.
|
||||
Selection rendering: every device runs CodeMirror's `drawSelection()` — it
|
||||
keeps typing on the drawn-selection code path, and removing it makes
|
||||
CodeMirror enforce cursor association on the native selection, which iOS
|
||||
answers with severe input lag. Every device also layers
|
||||
`composerNativeSelectionExtension` (`editor/theme.ts`) on top: it re-shows
|
||||
answers with severe input lag. **That much is not platform-specific and must
|
||||
not be undone.** What differs is who paints the selection, and
|
||||
`composerSelectionExtension` (`editor/theme.ts`) picks that per platform.
|
||||
|
||||
When CodeMirror 6.43.9's iOS predicate does not match,
|
||||
`composerNativeSelectionExtension` layers over `drawSelection()`: it re-shows
|
||||
the native selection, and — only while a range is selected — the native caret,
|
||||
hiding the painted layers those replace. The native selection is the one that
|
||||
shows for two reasons: the painted layer sits behind the content, so tokens
|
||||
with their own background (inline code, fences) cover it completely; and
|
||||
iOS's selection drag handles attach to the visible native selection and take
|
||||
their colour from the caret, so a transparent caret means invisible handles.
|
||||
The range-only caret scoping is load-bearing — a native caret visible while
|
||||
typing makes WebKit re-render its caret UI after every keystroke, felt as
|
||||
severe input lag. The selection tint comes from `--primary`, not the selection
|
||||
token:
|
||||
themes define `--interactive-selection` with its own alpha, so a translucent
|
||||
mix of it is nearly invisible.
|
||||
with their own background (inline code, fences) cover it completely; and the
|
||||
platform's selection drag handles attach to the visible native selection and
|
||||
take their colour from the caret, so a transparent caret means invisible
|
||||
handles. The range-only caret scoping is load-bearing — a native caret visible
|
||||
while typing makes the browser re-render its caret UI after every keystroke,
|
||||
felt as severe input lag.
|
||||
|
||||
When CodeMirror 6.43.9's exact iOS predicate matches,
|
||||
`composerIOSSelectionExtension` leaves selection-handle geometry and appearance
|
||||
to CodeMirror. CodeMirror puts the handles in `.cm-selectionLayer`, normally at
|
||||
`z-index: -1`; the extension raises that layer above the content so opaque
|
||||
token backgrounds cannot cover them, and leaves it transparent to touch.
|
||||
The handle dots extend 8px past their range; matching scroller padding and
|
||||
negative margin expand the clip area without moving the text or changing the
|
||||
composer height. iOS still paints its taller system selection overlay even
|
||||
when CSS makes `::selection` transparent. The extension therefore suppresses
|
||||
CodeMirror's synthetic selection rectangles on iOS while leaving its handles,
|
||||
cursor path and `nativeSelectionHidden` facet active. Otherwise the grey system
|
||||
highlight and themed rectangle overlap with visibly different heights.
|
||||
Do not add a second custom layer or custom handles here: overlapping translucent
|
||||
rectangles make selection darker at their seams and imitated handles drift from
|
||||
the geometry WebKit actually manipulates. What iOS avoids is installing the
|
||||
native-selection workaround above: explicitly restoring native paint and caret
|
||||
makes WebKit re-measure them after every decoration redraw, and the composer
|
||||
rebuilds every decoration on every keystroke. That cost is felt worst during
|
||||
IME composition.
|
||||
|
||||
The non-iOS native selection tint comes from `--primary`, not the selection
|
||||
token: themes define `--interactive-selection` with its own alpha, so mixing it
|
||||
with transparent again is nearly invisible. The iOS system overlay owns its
|
||||
visible selection fill.
|
||||
|
||||
The content element keeps the existing correction policy: on in the mobile UI,
|
||||
off elsewhere. CodeMirror also reads the attribute and reverts Apple and
|
||||
Android's insert-period-on-double-space only when its value is exactly `off`.
|
||||
`editor/autocorrect.ts` uses the HTML standard's
|
||||
[ASCII case-insensitive `autocorrect` keywords](https://html.spec.whatwg.org/multipage/interaction.html#attr-autocorrect)
|
||||
to keep desktop word correction off while avoiding that CodeMirror-only
|
||||
revert. Its platform checks deliberately match CodeMirror's own browser flags.
|
||||
|
||||
`composerLanguage.ts` retokenizes the whole document on every change. The
|
||||
composer holds a prompt, not a source file: it is short enough that a full pass
|
||||
@@ -88,10 +141,14 @@ and the send path reading the same grammar.
|
||||
drawn caret through a class it only writes while applying an update, so the
|
||||
selection has to be the update that follows the focus.
|
||||
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
|
||||
inline comments and context into OpenCode's one-primary-plus-parts shape. The
|
||||
oldest queued message becomes primary; **inline comments attach to the last
|
||||
body the user authored** rather than becoming their own part; PR instructions
|
||||
precede the PR diff.
|
||||
context drafts and linked references into OpenCode's one-primary-plus-parts
|
||||
shape. The oldest queued message becomes primary. **Every attached context
|
||||
item (inline comments, terminal selections, browser annotations, PR context,
|
||||
linked issue/PR) becomes its own synthetic text part carrying structured
|
||||
metadata** built by `lib/messages/contextParts.ts`; the timeline reads that
|
||||
metadata back to render context blocks. PR instructions precede the PR diff.
|
||||
Queueing a message leaves context drafts in their store on purpose — the send
|
||||
that later delivers the queue consumes them.
|
||||
- `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory,
|
||||
session) identity. Writes are debounced while typing but forced at every edge
|
||||
where the page may stop running, because a pending timer is not a saved
|
||||
@@ -101,6 +158,9 @@ and the send path reading the same grammar.
|
||||
- `state/useDraftTarget.ts` — the draft can target a directory that does not
|
||||
exist yet (a worktree being created). It must survive not appearing in the
|
||||
branch list, or the selector snaps back to the project root mid-creation.
|
||||
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
|
||||
state and registers its application shortcuts locally. The selectors only
|
||||
consume their shared prefix while the draft target UI is mounted.
|
||||
|
||||
## Mobile
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
appendInlineText,
|
||||
appendWithLineBreaks,
|
||||
buildImagePasteInsertion,
|
||||
getMarkdownAutoPairEdit,
|
||||
shouldWrapSelectionAsLink,
|
||||
withInlineInsertionBoundaries,
|
||||
} from '../text';
|
||||
@@ -119,3 +120,39 @@ describe('shouldWrapSelectionAsLink', () => {
|
||||
expect(shouldWrapSelectionAsLink('https://x.dev', '[docs](https://y.dev)')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMarkdownAutoPairEdit', () => {
|
||||
test('completes a fenced block with the caret on the middle line', () => {
|
||||
expect(getMarkdownAutoPairEdit('``', '`', 2, 2)).toEqual({
|
||||
from: 2,
|
||||
to: 2,
|
||||
insert: '`\n\n```',
|
||||
selectionStart: 4,
|
||||
selectionEnd: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test('completes a fence at the start of any line', () => {
|
||||
expect(getMarkdownAutoPairEdit('intro\n``tail', '`', 8, 8)).toEqual({
|
||||
from: 8,
|
||||
to: 8,
|
||||
insert: '`\n\n```',
|
||||
selectionStart: 10,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
});
|
||||
|
||||
test('does not complete two backticks in the middle of a line', () => {
|
||||
expect(getMarkdownAutoPairEdit('text ``', '`', 7, 7)).toBeNull();
|
||||
});
|
||||
|
||||
test('wraps selected text and keeps the text selected', () => {
|
||||
expect(getMarkdownAutoPairEdit('hello', '*', 1, 4)).toEqual({
|
||||
from: 1,
|
||||
to: 4,
|
||||
insert: '*ell*',
|
||||
selectionStart: 2,
|
||||
selectionEnd: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,9 +34,11 @@ import {
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ComposerLanguageContext } from '../language/tokenize';
|
||||
import type { ComposerAutoCorrect } from './autocorrect';
|
||||
import { composerLanguage, setLanguageContext } from './composerLanguage';
|
||||
import { replaceWithCaret } from './documentEdits';
|
||||
import type { ComposerEditorViewStore } from './viewStore';
|
||||
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
|
||||
import { composerEditorTheme, composerSelectionExtension } from './theme';
|
||||
import { handleComposerHostMouseDown } from './hostMouseDown';
|
||||
|
||||
export interface ComposerSelection {
|
||||
@@ -63,8 +65,8 @@ export interface ComposerEditorHandle {
|
||||
selectAll(): void;
|
||||
/** Replace the current selection, leaving the caret after the insertion. */
|
||||
insertText(text: string): void;
|
||||
/** Replace an explicit range; the caret lands at `caret` or after the text. */
|
||||
replaceRange(from: number, to: number, text: string, caret?: number): void;
|
||||
/** Replace a range; selection defaults to a caret after the inserted text. */
|
||||
replaceRange(from: number, to: number, text: string, selectionStart?: number, selectionEnd?: number): void;
|
||||
/** Viewport coordinates of the caret, for positioning popups. */
|
||||
caretCoords(position?: number): { top: number; bottom: number; left: number } | null;
|
||||
/** The scrollable element, for measuring and scroll compensation. */
|
||||
@@ -89,8 +91,11 @@ export interface ComposerEditorProps {
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
spellCheck?: boolean;
|
||||
/** Mobile keyboards; ignored on desktop. */
|
||||
autoCorrect?: boolean;
|
||||
/**
|
||||
* The content element's autocorrect keyword. See `autocorrect.ts` for the
|
||||
* case-sensitive CodeMirror workaround.
|
||||
*/
|
||||
autoCorrect?: ComposerAutoCorrect;
|
||||
autoCapitalize?: 'none' | 'sentences';
|
||||
/** Fill the available height instead of growing with the content. */
|
||||
fillContainer?: boolean;
|
||||
@@ -157,7 +162,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
placeholder,
|
||||
editable = true,
|
||||
spellCheck = false,
|
||||
autoCorrect = false,
|
||||
autoCorrect = 'off',
|
||||
autoCapitalize = 'none',
|
||||
fillContainer = false,
|
||||
maxLines = 8,
|
||||
@@ -234,14 +239,13 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
doc: handlersRef.current.value,
|
||||
extensions: [
|
||||
history(),
|
||||
// `drawSelection()` must stay even though the native
|
||||
// selection is what actually shows (see the theme's
|
||||
// comment on `composerNativeSelectionExtension`):
|
||||
// removing it makes CodeMirror enforce cursor
|
||||
// association on the native selection, which iOS
|
||||
// answers with severe input lag.
|
||||
// `drawSelection()` must stay on every platform.
|
||||
// `composerSelectionExtension()` changes only who
|
||||
// paints the selection; removing `drawSelection()`
|
||||
// makes CodeMirror enforce cursor association on the
|
||||
// native selection, which iOS answers with severe lag.
|
||||
drawSelection(),
|
||||
composerNativeSelectionExtension,
|
||||
composerSelectionExtension(),
|
||||
EditorView.lineWrapping,
|
||||
// Highest precedence: the composer's own keys must win
|
||||
// over CodeMirror's defaults (Enter sends, ArrowUp
|
||||
@@ -288,7 +292,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
}),
|
||||
EditorView.contentAttributes.of({
|
||||
spellcheck: String(handlersRef.current.spellCheck ?? false),
|
||||
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off',
|
||||
autocorrect: handlersRef.current.autoCorrect ?? 'off',
|
||||
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
|
||||
...(handlersRef.current['aria-label']
|
||||
? { 'aria-label': handlersRef.current['aria-label'] }
|
||||
@@ -344,17 +348,18 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (current === value) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: current.length, insert: value },
|
||||
// An external rewrite (draft restore, history navigation,
|
||||
// "add to chat", dictation insert) lands the caret at the END,
|
||||
// matching what a plain textarea did when its value was
|
||||
// replaced. Every rewrite that reaches here appends or
|
||||
// replaces wholesale; keeping the old caret instead left it
|
||||
// stranded before the inserted text, and the next insertion
|
||||
// or keystroke landed inside the previous one.
|
||||
selection: { anchor: value.length },
|
||||
});
|
||||
// Skip every controlled writeback while the browser is composing.
|
||||
// A stale value echo can differ from CodeMirror's newer document,
|
||||
// and replacing it would interrupt the IME session and move the caret.
|
||||
if (view.compositionStarted) return;
|
||||
// An external rewrite (draft restore, history navigation,
|
||||
// "add to chat", dictation insert) lands the caret at the END,
|
||||
// matching what a plain textarea did when its value was replaced.
|
||||
// Every rewrite that reaches here appends or replaces wholesale;
|
||||
// keeping the old caret instead left it stranded before the
|
||||
// inserted text, and the next insertion or keystroke landed inside
|
||||
// the previous one.
|
||||
view.dispatch(replaceWithCaret(view.state, 0, current.length, value));
|
||||
// A large insert can push the caret below the fold, and a
|
||||
// transaction-time `scrollIntoView` cannot reach it: wrapped-line
|
||||
// heights are still estimates during the update, and the
|
||||
@@ -451,7 +456,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
if (!view) return;
|
||||
const content = view.contentDOM;
|
||||
content.setAttribute('spellcheck', String(spellCheck));
|
||||
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off');
|
||||
content.setAttribute('autocorrect', autoCorrect);
|
||||
content.setAttribute('autocapitalize', autoCapitalize);
|
||||
}, [autoCapitalize, autoCorrect, spellCheck]);
|
||||
|
||||
@@ -508,17 +513,18 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
if (!view || !text) return;
|
||||
const { from, to } = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: from + text.length },
|
||||
...replaceWithCaret(view.state, from, to, text),
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
},
|
||||
replaceRange(from, to, text, caret) {
|
||||
replaceRange(from, to, text, selectionStart, selectionEnd = selectionStart) {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const caret = selectionStart === undefined
|
||||
? undefined
|
||||
: { anchor: selectionStart, head: selectionEnd ?? selectionStart };
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: caret ?? from + text.length },
|
||||
...replaceWithCaret(view.state, from, to, text, caret),
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { composerAutoCorrect, type ComposerAutoCorrect } from '../autocorrect';
|
||||
|
||||
const platform = (overrides: Partial<Navigator>): Navigator => ({
|
||||
maxTouchPoints: 0,
|
||||
platform: '',
|
||||
userAgent: '',
|
||||
vendor: '',
|
||||
...overrides,
|
||||
} as Navigator);
|
||||
|
||||
const codeMirrorKeepsDoubleSpacePeriod = (
|
||||
autoCorrect: ComposerAutoCorrect,
|
||||
): boolean => autoCorrect !== 'off';
|
||||
|
||||
const affectedPlatforms: Array<[string, Navigator]> = [
|
||||
['macOS', platform({ platform: 'MacIntel' })],
|
||||
['iPhone', platform({
|
||||
platform: 'iPhone',
|
||||
userAgent: 'Mozilla/5.0 Mobile/15E148 Safari/604.1',
|
||||
vendor: 'Apple Computer, Inc.',
|
||||
})],
|
||||
['iPadOS touch detection', platform({
|
||||
maxTouchPoints: 5,
|
||||
userAgent: 'Mozilla/5.0 Version/17.4 Safari/605.1.15',
|
||||
vendor: 'Apple Computer, Inc.',
|
||||
})],
|
||||
['Android', platform({
|
||||
platform: 'Linux armv8l',
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8)',
|
||||
})],
|
||||
];
|
||||
|
||||
const unaffectedPlatforms: Array<[string, Navigator]> = [
|
||||
['Windows', platform({ platform: 'Win32' })],
|
||||
['Linux', platform({ platform: 'Linux x86_64' })],
|
||||
];
|
||||
|
||||
describe('composerAutoCorrect', () => {
|
||||
test('matches the pinned CodeMirror period-revert guard', () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(import.meta.resolve('@codemirror/view')),
|
||||
'utf8',
|
||||
);
|
||||
const semantics = source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\s+/g, '');
|
||||
|
||||
expect(/getAttribute\(["']autocorrect["']\)==["']off["']/.test(semantics)).toBe(true);
|
||||
expect(semantics).toContain(
|
||||
'constios=safari&&(/Mobile\\/\\w+/.test(nav.userAgent)||nav.maxTouchPoints>2)',
|
||||
);
|
||||
expect(semantics).toContain('mac:ios||/Mac/.test(nav.platform)');
|
||||
expect(semantics).toContain('android:/Android\\b/.test(nav.userAgent)');
|
||||
});
|
||||
|
||||
for (const [name, navigator] of affectedPlatforms) {
|
||||
test(`preserves the ${name} platform period without enabling autocorrect`, () => {
|
||||
const autoCorrect = composerAutoCorrect({ isMobile: false, navigator });
|
||||
|
||||
expect(autoCorrect.toLowerCase()).toBe('off');
|
||||
// @codemirror/view 6.39.13 reverts the native period only for exact "off".
|
||||
expect(codeMirrorKeepsDoubleSpacePeriod(autoCorrect)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, navigator] of unaffectedPlatforms) {
|
||||
test(`leaves desktop correction off on ${name}`, () => {
|
||||
expect(composerAutoCorrect({ isMobile: false, navigator })).toBe('off');
|
||||
});
|
||||
}
|
||||
|
||||
test('uses CodeMirror platform detection rather than a macOS user agent', () => {
|
||||
expect(composerAutoCorrect({
|
||||
isMobile: false,
|
||||
navigator: platform({
|
||||
platform: 'Linux x86_64',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
}),
|
||||
})).toBe('off');
|
||||
});
|
||||
|
||||
test('preserves the existing mobile autocorrect policy', () => {
|
||||
expect(composerAutoCorrect({
|
||||
isMobile: true,
|
||||
navigator: platform({ platform: 'Win32' }),
|
||||
})).toBe('on');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
import { replaceWithCaret } from '../documentEdits';
|
||||
|
||||
const apply = (doc: string, from: number, to: number, insert: string, caret?: { anchor: number; head: number }) => {
|
||||
const state = EditorState.create({ doc });
|
||||
const next = state.update(replaceWithCaret(state, from, to, insert, caret)).state;
|
||||
return { text: next.doc.toString(), selection: next.selection.main };
|
||||
};
|
||||
|
||||
describe('replaceWithCaret', () => {
|
||||
test('puts the caret at the end of a wholesale replacement', () => {
|
||||
const { text, selection } = apply('old', 0, 3, 'a new draft');
|
||||
|
||||
expect(text).toBe('a new draft');
|
||||
expect(selection.anchor).toBe(11);
|
||||
expect(selection.head).toBe(11);
|
||||
});
|
||||
|
||||
// Issue #3013: CodeMirror collapses `\r\n` into one line break, so a caret
|
||||
// taken from the JS string length falls outside the document and dispatch
|
||||
// throws `RangeError: Selection points outside of document`.
|
||||
test('keeps the caret inside the document when CRLF is normalized away', () => {
|
||||
const { text, selection } = apply('a', 0, 1, 'x\r\ny');
|
||||
|
||||
expect(text).toBe('x\ny');
|
||||
expect(selection.anchor).toBe(3);
|
||||
});
|
||||
|
||||
test('survives a draft made only of CRLF breaks', () => {
|
||||
const { text, selection } = apply('a', 0, 1, '\r\n\r\n\r\n');
|
||||
|
||||
expect(text).toBe('\n\n\n');
|
||||
expect(selection.anchor).toBe(3);
|
||||
});
|
||||
|
||||
test('places the caret after text inserted at the selection', () => {
|
||||
const { text, selection } = apply('hello world', 5, 5, ',\r\n there');
|
||||
|
||||
expect(text).toBe('hello,\n there world');
|
||||
expect(selection.anchor).toBe(13);
|
||||
});
|
||||
|
||||
test('honours an explicit caret', () => {
|
||||
const { selection } = apply('hello', 0, 5, 'goodbye', { anchor: 2, head: 4 });
|
||||
|
||||
expect(selection.anchor).toBe(2);
|
||||
expect(selection.head).toBe(4);
|
||||
});
|
||||
|
||||
test('clamps an explicit caret that the normalized document cannot hold', () => {
|
||||
const { text, selection } = apply('a', 0, 1, 'x\r\ny', { anchor: 4, head: 4 });
|
||||
|
||||
expect(text).toBe('x\ny');
|
||||
expect(selection.anchor).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,29 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorState, type Extension } from '@codemirror/state';
|
||||
|
||||
import {
|
||||
COMPOSER_EDITOR_THEME_SPEC,
|
||||
IOS_SELECTION_THEME_SPEC,
|
||||
NATIVE_SELECTION_THEME_SPEC,
|
||||
composerEditorTheme,
|
||||
composerIOSSelectionExtension,
|
||||
composerNativeSelectionExtension,
|
||||
composerSelectionExtension,
|
||||
isCodeMirrorIOSNavigator,
|
||||
} from '../theme';
|
||||
|
||||
const selectors = Object.keys(COMPOSER_EDITOR_THEME_SPEC);
|
||||
const declarations = JSON.stringify(COMPOSER_EDITOR_THEME_SPEC);
|
||||
|
||||
function installationError(extension: Extension): string | null {
|
||||
try {
|
||||
EditorState.create({ extensions: [extension] });
|
||||
return null;
|
||||
} catch (error) {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
describe('composerEditorTheme', () => {
|
||||
/**
|
||||
* EditorView.theme compiles its selectors when this module is imported and
|
||||
@@ -20,13 +33,7 @@ describe('composerEditorTheme', () => {
|
||||
* surfaces only in the running app, where it takes the composer down.
|
||||
*/
|
||||
test('its selectors compile and the theme can be installed', () => {
|
||||
let failure: unknown = null;
|
||||
try {
|
||||
EditorState.create({ extensions: [composerEditorTheme] });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeNull();
|
||||
expect(installationError(composerEditorTheme)).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -46,6 +53,14 @@ describe('composerEditorTheme', () => {
|
||||
expect(rule.borderLeftColor.startsWith('var(--')).toBe(true);
|
||||
});
|
||||
|
||||
test('the drawn caret is wide enough to remain prominent', () => {
|
||||
const cursorRule = selectors.find((selector) => selector.includes('.cm-cursor'));
|
||||
const rule = (COMPOSER_EDITOR_THEME_SPEC as Record<string, Record<string, string>>)[cursorRule!];
|
||||
expect(rule.borderLeftWidth).toBe('2px');
|
||||
expect(rule.transform).toBe('scaleY(1.15)');
|
||||
expect(rule.transformOrigin).toBe('center');
|
||||
});
|
||||
|
||||
/**
|
||||
* CodeMirror's own `.cm-cursor` rule and its `&dark` override are one and
|
||||
* two classes deep respectively; a bare `.cm-cursor` selector loses to the
|
||||
@@ -93,6 +108,10 @@ describe('composerEditorTheme', () => {
|
||||
expect(rule.background.includes('transparent')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('the common theme does not re-show the native selection', () => {
|
||||
expect(selectors.some((selector) => selector.includes('::selection'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerNativeSelectionTheme', () => {
|
||||
@@ -100,21 +119,16 @@ describe('composerNativeSelectionTheme', () => {
|
||||
const nativeDeclarations = JSON.stringify(NATIVE_SELECTION_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* Every device layers this over `drawSelection()`: the native selection
|
||||
* paints over token backgrounds (the painted layer is hidden behind them)
|
||||
* and iOS attaches its selection handles to it. `drawSelection()` must
|
||||
* NOT be removed for that: without it CodeMirror starts enforcing cursor
|
||||
* association on the native selection while typing in wrapped text, and
|
||||
* iOS answers those programmatic selection moves with severe input lag.
|
||||
* Every device except iOS layers this over `drawSelection()`: the native
|
||||
* selection paints over token backgrounds (the painted layer is hidden
|
||||
* behind them) and the platform attaches its selection handles to it.
|
||||
* `drawSelection()` must NOT be removed for that: without it CodeMirror
|
||||
* starts enforcing cursor association on the native selection while typing
|
||||
* in wrapped text, and iOS answers those programmatic selection moves with
|
||||
* severe input lag.
|
||||
*/
|
||||
test('it compiles and can be installed', () => {
|
||||
let failure: unknown = null;
|
||||
try {
|
||||
EditorState.create({ extensions: [composerNativeSelectionExtension] });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeNull();
|
||||
expect(installationError(composerNativeSelectionExtension)).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -142,9 +156,9 @@ describe('composerNativeSelectionTheme', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* iOS colours its selection drag handles from the caret colour. With
|
||||
* `drawSelection()`'s `caret-color: transparent !important` in effect the
|
||||
* handles are drawn — invisibly. The native caret must come back with
|
||||
* A platform showing native handles colours them from the caret colour.
|
||||
* With `drawSelection()`'s `caret-color: transparent !important` in effect
|
||||
* the handles are drawn — invisibly. The native caret must come back with
|
||||
* enough weight to win, and the drawn cursor layer must go so there are
|
||||
* not two carets.
|
||||
*
|
||||
@@ -187,3 +201,106 @@ describe('composerNativeSelectionTheme', () => {
|
||||
expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerIOSSelectionExtension', () => {
|
||||
const layerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller > .cm-selectionLayer'];
|
||||
const scrollerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller'];
|
||||
const selectionBackgroundRule = IOS_SELECTION_THEME_SPEC['& .cm-selectionBackground'];
|
||||
|
||||
test('it compiles and can be installed', () => {
|
||||
expect(installationError(composerIOSSelectionExtension)).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* CodeMirror renders its selection layer at `z-index: -1`, behind the
|
||||
* text. Inline code and code fences have opaque backgrounds and otherwise
|
||||
* cover both the selection and the iOS handles. The base value is inline,
|
||||
* so raising it without `!important` silently does nothing.
|
||||
*/
|
||||
test('CodeMirror selection and handles are raised above token backgrounds', () => {
|
||||
expect(layerRule.zIndex).toBe('100 !important');
|
||||
});
|
||||
|
||||
/**
|
||||
* The layer now sits over the content and would intercept taps and drags
|
||||
* by default. It only paints; CodeMirror/WebKit still own the gestures.
|
||||
*/
|
||||
test('the layer does not intercept touch', () => {
|
||||
expect(layerRule.pointerEvents).toBe('none');
|
||||
});
|
||||
|
||||
/**
|
||||
* A higher z-index cannot escape overflow clipping. CodeMirror's dots
|
||||
* extend 8px past the range, so the scroller needs that much internal room;
|
||||
* the matching negative margin keeps the text and composer height fixed.
|
||||
*/
|
||||
test('the scroller reserves unclipped room for both handles', () => {
|
||||
expect(scrollerRule.paddingBlock).toBe('8px');
|
||||
expect(scrollerRule.marginBlock).toBe('-8px');
|
||||
});
|
||||
|
||||
test('the CodeMirror fill does not stack over the iOS system highlight', () => {
|
||||
expect(selectionBackgroundRule.background).toBe('transparent !important');
|
||||
});
|
||||
|
||||
/**
|
||||
* A second custom layer was visually indistinguishable from duplicate
|
||||
* native selection UI. iOS must only reposition the one layer that
|
||||
* CodeMirror already uses for both selection rectangles and handles.
|
||||
*/
|
||||
test('it does not add a second selection implementation', () => {
|
||||
expect(Object.keys(IOS_SELECTION_THEME_SPEC)).toEqual([
|
||||
'& .cm-scroller',
|
||||
'& .cm-scroller > .cm-selectionLayer',
|
||||
'& .cm-selectionBackground',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerSelectionExtension', () => {
|
||||
/**
|
||||
* The split is the point: iOS is the only platform that pays for a visible
|
||||
* native selection during composition, and CodeMirror 6.43.9 draws its
|
||||
* handles. Collapsing the two branches into one would
|
||||
* either restore the latency on iOS or leave every other platform without
|
||||
* discoverable range selection.
|
||||
*/
|
||||
test('the CodeMirror iOS path uses its handles; other platforms keep native selection', () => {
|
||||
expect(composerSelectionExtension(true)).toBe(composerIOSSelectionExtension);
|
||||
expect(composerSelectionExtension(false)).toBe(composerNativeSelectionExtension);
|
||||
});
|
||||
|
||||
/**
|
||||
* The composer may remove the native fallback only when CodeMirror's own
|
||||
* browser predicate enables its replacement handles. This deliberately
|
||||
* includes CodeMirror's vendor and touch thresholds rather than using a
|
||||
* broader application-level iOS heuristic.
|
||||
*/
|
||||
test('the platform predicate matches CodeMirror 6.43.9', () => {
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6) Mobile/15E148 Safari/604.1',
|
||||
'Apple Computer, Inc.',
|
||||
5,
|
||||
)).toBe(true);
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
||||
'Apple Computer, Inc.',
|
||||
5,
|
||||
)).toBe(true);
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
||||
'Google Inc.',
|
||||
5,
|
||||
)).toBe(false);
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
||||
'Apple Computer, Inc.',
|
||||
0,
|
||||
)).toBe(false);
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0)',
|
||||
'Apple Computer, Inc.',
|
||||
5,
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const composerEditorSource = readFileSync(
|
||||
new URL('../ComposerEditor.tsx', import.meta.url),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const writebackEffect = (): string => {
|
||||
const start = composerEditorSource.indexOf('// Controlled value:');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const end = composerEditorSource.indexOf('}, [value]);', start);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return composerEditorSource.slice(start, end);
|
||||
};
|
||||
|
||||
describe('composer value writeback composition guard (issue #2527)', () => {
|
||||
test('checks equality, then composition, before dispatching', () => {
|
||||
const effect = writebackEffect();
|
||||
const equalityCheck = effect.indexOf('if (current === value) return;');
|
||||
const compositionGuard = effect.indexOf('if (view.compositionStarted) return;');
|
||||
const dispatch = effect.indexOf('view.dispatch(');
|
||||
|
||||
expect(equalityCheck).toBeGreaterThan(-1);
|
||||
expect(compositionGuard).toBeGreaterThan(equalityCheck);
|
||||
expect(dispatch).toBeGreaterThan(compositionGuard);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export type ComposerAutoCorrect = 'on' | 'off' | 'Off';
|
||||
|
||||
type PlatformNavigator = Pick<Navigator,
|
||||
'maxTouchPoints' | 'platform' | 'userAgent' | 'vendor'
|
||||
>;
|
||||
|
||||
/** Keep desktop autocorrect off without triggering CodeMirror's period revert. */
|
||||
export function composerAutoCorrect(options: {
|
||||
isMobile: boolean;
|
||||
navigator?: PlatformNavigator;
|
||||
}): ComposerAutoCorrect {
|
||||
if (options.isMobile) return 'on';
|
||||
|
||||
const nav = options.navigator
|
||||
?? (typeof navigator === 'undefined'
|
||||
? { maxTouchPoints: 0, platform: '', userAgent: '', vendor: '' }
|
||||
: navigator);
|
||||
// These must match CodeMirror's flags because its revert checks exact "off".
|
||||
const ios = /Apple Computer/.test(nav.vendor)
|
||||
&& (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2);
|
||||
return ios || /Mac/.test(nav.platform) || /Android\b/.test(nav.userAgent)
|
||||
? 'Off'
|
||||
: 'off';
|
||||
}
|
||||
@@ -41,7 +41,7 @@ const languageContextField = StateField.define<ComposerLanguageContext>({
|
||||
},
|
||||
});
|
||||
|
||||
export const EMPTY_CONTEXT: ComposerLanguageContext = {
|
||||
const EMPTY_CONTEXT: ComposerLanguageContext = {
|
||||
inputMode: 'normal',
|
||||
knownAgentNames: new Set(),
|
||||
confirmedMentions: new Set(),
|
||||
@@ -90,8 +90,3 @@ export function composerLanguage(initial: ComposerLanguageContext = EMPTY_CONTEX
|
||||
decorationField,
|
||||
];
|
||||
}
|
||||
|
||||
/** The context currently in effect, for callers that need to read it back. */
|
||||
export function readLanguageContext(view: EditorView): ComposerLanguageContext {
|
||||
return view.state.field(languageContextField);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { EditorState, TransactionSpec } from '@codemirror/state';
|
||||
|
||||
/**
|
||||
* Replace a document range and leave the caret inside the resulting document.
|
||||
*
|
||||
* CodeMirror normalizes line endings on the way in: a `\r\n` pair becomes one
|
||||
* line break, so the inserted string is longer than the text it produces. A
|
||||
* caret derived from the JavaScript string therefore lands past the end of the
|
||||
* document and `dispatch` throws `RangeError: Selection points outside of
|
||||
* document`. The transaction never applies, so the un-normalized text stays in
|
||||
* React state, gets persisted as a draft, and crashes the chat again on every
|
||||
* restore (issue #3013).
|
||||
*
|
||||
* Deriving the caret from the change set instead keeps it correct for whatever
|
||||
* CodeMirror actually inserted, without this module having to know the
|
||||
* normalization rules.
|
||||
*/
|
||||
export const replaceWithCaret = (
|
||||
state: EditorState,
|
||||
from: number,
|
||||
to: number,
|
||||
insert: string,
|
||||
caret?: { anchor: number; head: number },
|
||||
): TransactionSpec => {
|
||||
const changes = state.changes({ from, to, insert });
|
||||
const clamp = (position: number): number => Math.min(Math.max(position, 0), changes.newLength);
|
||||
// What CodeMirror inserted, measured on the document rather than on the
|
||||
// string: the new length minus everything the change left untouched.
|
||||
const insertedLength = changes.newLength - (state.doc.length - (to - from));
|
||||
const anchor = caret ? clamp(caret.anchor) : from + insertedLength;
|
||||
const head = caret ? clamp(caret.head) : anchor;
|
||||
return { changes, selection: { anchor, head } };
|
||||
};
|
||||
@@ -5,6 +5,7 @@
|
||||
* language layer emits, so the composer and the message list stay in step.
|
||||
*/
|
||||
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
@@ -19,6 +20,8 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': {
|
||||
padding: '0',
|
||||
// Keep the drawn empty-document cursor inside the scroller's horizontal clip.
|
||||
paddingInlineStart: '1px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
@@ -30,7 +33,10 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
|
||||
// `caret-color: transparent !important` at the highest precedence and
|
||||
// draws its own `.cm-cursor` element, whose base style is a hard-coded
|
||||
// `border-left: 1.2px solid black`. Styling `caret-color` here therefore
|
||||
// does nothing at all — the border is what has to be coloured.
|
||||
// does nothing at all — the border is what has to be coloured. A 2px
|
||||
// stroke makes the insertion point remain visible against every composer
|
||||
// surface without relying on a fixed colour. A slight vertical scale makes
|
||||
// it extend beyond the glyphs without changing CodeMirror's line geometry.
|
||||
//
|
||||
// CodeMirror recolours it for dark editors through `&dark .cm-cursor`,
|
||||
// which needs the theme to declare itself dark. OpenChamber themes are not
|
||||
@@ -43,6 +49,9 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
|
||||
// moment this module is imported.
|
||||
'&.cm-editor .cm-cursor, &.cm-editor .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--surface-foreground)',
|
||||
borderLeftWidth: '2px',
|
||||
transform: 'scaleY(1.15)',
|
||||
transformOrigin: 'center',
|
||||
},
|
||||
'.cm-line': { padding: '0' },
|
||||
'.cm-scroller': {
|
||||
@@ -72,23 +81,16 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
|
||||
'&.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
||||
background: 'color-mix(in srgb, var(--interactive-selection) 55%, transparent)',
|
||||
},
|
||||
// The native selection still shows through in places CodeMirror does not
|
||||
// draw over, such as the placeholder. Same colour as the native-selection
|
||||
// theme below, for the same reason: the selection token carries its own
|
||||
// alpha and reads as nearly invisible when mixed down again.
|
||||
'& ::selection': {
|
||||
background: 'color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||
},
|
||||
};
|
||||
|
||||
export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* Every device keeps `drawSelection()` but shows the NATIVE selection through
|
||||
* it, for two independent reasons:
|
||||
* Outside CodeMirror's iOS branch, devices keep `drawSelection()` but show the
|
||||
* NATIVE selection through it, for two independent reasons:
|
||||
*
|
||||
* - iOS attaches its selection handles (the draggable pins after a
|
||||
* double-tap) to the *visible* native selection, and `drawSelection()`
|
||||
* - Their selection drag handles (the draggable pins after a double-tap)
|
||||
* attach to the *visible* native selection, and `drawSelection()`
|
||||
* hides it with `.cm-line ::selection { background: transparent
|
||||
* !important }`, so the handles never appear and range selection is
|
||||
* undiscoverable.
|
||||
@@ -97,12 +99,15 @@ export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
|
||||
* the selection is invisible inside those spans. The native selection
|
||||
* paints over element backgrounds.
|
||||
*
|
||||
* Dropping `drawSelection()` entirely is NOT an option: without it CodeMirror
|
||||
* clears the `nativeSelectionHidden` facet and starts enforcing cursor
|
||||
* association on the native selection while typing in wrapped text —
|
||||
* programmatic selection moves that iOS answers with severe input lag (each
|
||||
* one also resets the keyboard's autocorrect context). Typing must stay on
|
||||
* the drawn-selection code path; only the paint changes.
|
||||
* Dropping `drawSelection()` entirely is NOT an option, on any platform:
|
||||
* without it CodeMirror clears the `nativeSelectionHidden` facet and starts
|
||||
* enforcing cursor association on the native selection while typing in
|
||||
* wrapped text — programmatic selection moves that iOS answers with severe
|
||||
* input lag (each one also resets the keyboard's autocorrect context). Typing
|
||||
* must stay on the drawn-selection code path; only the paint changes.
|
||||
*
|
||||
* CodeMirror's iOS branch does NOT use this arrangement —
|
||||
* `composerIOSSelectionExtension` below explains why.
|
||||
*
|
||||
* Both rules below fight `drawSelection()`'s own `Prec.highest` theme, so
|
||||
* they carry `!important` and one class more specificity
|
||||
@@ -146,17 +151,106 @@ export const NATIVE_SELECTION_THEME_SPEC = {
|
||||
},
|
||||
};
|
||||
|
||||
export const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
|
||||
const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* The native-selection arrangement, installed on every device: the theme
|
||||
* above plus the `.oc-native-range` marker class that scopes its caret rules
|
||||
* to the moments a range is actually selected. `editorAttributes`
|
||||
* The native-selection arrangement, installed outside CodeMirror's iOS branch:
|
||||
* the theme above plus the `.oc-native-range` marker class that scopes its
|
||||
* caret rules to the moments a range is actually selected. `editorAttributes`
|
||||
* re-evaluates on every update, so the class follows the selection with no
|
||||
* listener of its own.
|
||||
*/
|
||||
export const composerNativeSelectionExtension = [
|
||||
export const composerNativeSelectionExtension: Extension = [
|
||||
composerNativeSelectionTheme,
|
||||
EditorView.editorAttributes.of((view) =>
|
||||
view.state.selection.main.empty ? null : { class: 'oc-native-range' }),
|
||||
];
|
||||
|
||||
/**
|
||||
* When its iOS predicate matches, CodeMirror 6.43.9 draws the range handles
|
||||
* into the same layer as the selection, so CodeMirror owns both their geometry
|
||||
* and appearance.
|
||||
*
|
||||
* That layer normally renders at `z-index: -1`, behind the content. Inline
|
||||
* code and code fences have opaque backgrounds and would cover both the tint
|
||||
* and handles. Raising the one existing layer fixes that without introducing
|
||||
* a second set of rectangles or trying to imitate WebKit's controls. The
|
||||
* layer remains transparent to touch so WebKit receives selection gestures.
|
||||
*
|
||||
* What iOS avoids is the native-selection workaround above: explicitly
|
||||
* restoring the native highlight and caret makes WebKit re-measure and repaint
|
||||
* that UI after every decoration redraw. `composerLanguage.ts` rebuilds the
|
||||
* whole decoration set on every keystroke, so the cost is felt worst during
|
||||
* IME composition where each intermediate replacement pays for it. WebKit's
|
||||
* unavoidable system selection overlay remains the only visible fill.
|
||||
*/
|
||||
export const IOS_SELECTION_THEME_SPEC = {
|
||||
// The handles extend 8px above/below their range. The scroller clips them
|
||||
// at its own edge even when the layer has a high z-index, so reserve that
|
||||
// room inside the clipping box and pull the box outward by the same amount.
|
||||
// Text and composer height stay where they were; only the clip area grows.
|
||||
'& .cm-scroller': {
|
||||
marginBlock: '-8px',
|
||||
paddingBlock: '8px',
|
||||
},
|
||||
'& .cm-scroller > .cm-selectionLayer': {
|
||||
// CodeMirror writes `z-index: -1` inline. `!important` is intentional:
|
||||
// without it token backgrounds cover the selection and its handles.
|
||||
zIndex: '100 !important',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
// iOS keeps showing its taller system selection overlay even when
|
||||
// ::selection is transparent. Painting CodeMirror's themed rectangles as
|
||||
// well produces two visibly misaligned fills, so only the synthetic
|
||||
// background is suppressed. The handles in this layer remain visible.
|
||||
'& .cm-selectionBackground': {
|
||||
background: 'transparent !important',
|
||||
},
|
||||
};
|
||||
|
||||
export const composerIOSSelectionExtension: Extension =
|
||||
EditorView.theme(IOS_SELECTION_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* Which selection paint the composer installs. The split is the platform's,
|
||||
* not a preference: iOS is the one place where restoring native selection
|
||||
* paint and caret costs measurable input latency, and the only place
|
||||
* CodeMirror supplies replacement drag handles.
|
||||
*
|
||||
* The caller can pass the policy, so the choice stays testable and is made
|
||||
* once per editor rather than once per module load.
|
||||
*/
|
||||
export function composerSelectionExtension(
|
||||
useCodeMirrorIOSHandles: boolean = usesCodeMirrorIOSSelectionHandles(),
|
||||
): Extension {
|
||||
return useCodeMirrorIOSHandles
|
||||
? composerIOSSelectionExtension
|
||||
: composerNativeSelectionExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors @codemirror/view 6.43.9's iOS predicate. This branch may only rely
|
||||
* on the drawn handles when CodeMirror itself will create them; a broader iOS
|
||||
* heuristic could remove the native fallback without installing a replacement.
|
||||
*/
|
||||
export function isCodeMirrorIOSNavigator(
|
||||
userAgent: string,
|
||||
vendor: string,
|
||||
maxTouchPoints: number,
|
||||
): boolean {
|
||||
const isIE = /Edge\/(\d+)/.test(userAgent)
|
||||
|| /MSIE \d/.test(userAgent)
|
||||
|| /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.test(userAgent);
|
||||
if (isIE || !/Apple Computer/.test(vendor)) return false;
|
||||
return /Mobile\/\w+/.test(userAgent) || maxTouchPoints > 2;
|
||||
}
|
||||
|
||||
function usesCodeMirrorIOSSelectionHandles(): boolean {
|
||||
const nav = globalThis.navigator;
|
||||
if (!nav) return false;
|
||||
return isCodeMirrorIOSNavigator(
|
||||
nav.userAgent || '',
|
||||
nav.vendor || '',
|
||||
nav.maxTouchPoints ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,5 +114,3 @@ function matchMention(
|
||||
});
|
||||
return query === null ? null : { kind: 'mention', query };
|
||||
}
|
||||
|
||||
export type { FileMentionAutocompleteInputSource };
|
||||
|
||||
@@ -23,6 +23,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
|
||||
import { normalizePath } from '../attachments/filePaths';
|
||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
/** How long a cached branch list is served before it is refreshed. */
|
||||
const BRANCHES_SWR_TTL_MS = 30_000;
|
||||
@@ -35,6 +37,7 @@ export interface DraftTargetProject {
|
||||
color?: string | null;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
|
||||
iconBackground?: string | null;
|
||||
kind?: 'chat' | 'project';
|
||||
}
|
||||
|
||||
/** A project's display name, falling back to its directory name. */
|
||||
@@ -43,7 +46,15 @@ export function getProjectDisplayLabel(project: { label?: string; path: string }
|
||||
}
|
||||
|
||||
export function useDraftTarget(enabled: boolean) {
|
||||
const projects = useProjectsStore((state) => state.projects) as DraftTargetProject[];
|
||||
const configuredProjects: readonly DraftTargetProject[] = useProjectsStore((state) => state.projects);
|
||||
const { t } = useI18n();
|
||||
const chatProject = React.useMemo<DraftTargetProject>(() => ({
|
||||
id: CHAT_DRAFT_PROJECT_ID,
|
||||
path: '',
|
||||
label: t('layout.mainTab.chat'),
|
||||
kind: 'chat',
|
||||
}), [t]);
|
||||
const projects = React.useMemo(() => [chatProject, ...configuredProjects], [chatProject, configuredProjects]);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||
@@ -53,6 +64,7 @@ export function useDraftTarget(enabled: boolean) {
|
||||
const { git: runtimeGit } = useRuntimeAPIs();
|
||||
|
||||
const selectedDraftProject = React.useMemo(() => {
|
||||
if (newSessionDraft?.target === 'chat') return chatProject;
|
||||
const explicit = newSessionDraft?.selectedProjectId
|
||||
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
|
||||
: null;
|
||||
@@ -67,14 +79,16 @@ export function useDraftTarget(enabled: boolean) {
|
||||
return active;
|
||||
}
|
||||
|
||||
return projects[0] ?? null;
|
||||
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
|
||||
return configuredProjects[0] ?? chatProject;
|
||||
}, [activeProjectId, chatProject, configuredProjects, newSessionDraft?.selectedProjectId, newSessionDraft?.target, projects]);
|
||||
|
||||
const selectedDraftProjectPath = React.useMemo(
|
||||
() => normalizePath(selectedDraftProject?.path ?? null),
|
||||
[selectedDraftProject?.path],
|
||||
() => selectedDraftProject?.kind === 'chat' ? null : normalizePath(selectedDraftProject?.path ?? null),
|
||||
[selectedDraftProject?.kind, selectedDraftProject?.path],
|
||||
);
|
||||
const draftProjectLabel = selectedDraftProject ? getProjectDisplayLabel(selectedDraftProject) : null;
|
||||
const draftProjectLabel = selectedDraftProject && selectedDraftProject.kind !== 'chat'
|
||||
? getProjectDisplayLabel(selectedDraftProject)
|
||||
: null;
|
||||
|
||||
const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath);
|
||||
const selectedDraftProjectBranchesFetchedAt = useGitStore(
|
||||
@@ -258,6 +272,10 @@ export function useDraftTarget(enabled: boolean) {
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
if (project.kind === 'chat') {
|
||||
setNewSessionDraftTarget({ projectId: CHAT_DRAFT_PROJECT_ID, directoryOverride: null }, { force: true });
|
||||
return;
|
||||
}
|
||||
if (activeProjectId !== projectId) {
|
||||
setActiveProjectIdOnly(projectId);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,15 @@ import React from 'react';
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
|
||||
|
||||
// Android mobile browsers are the pan-mode holdouts this pin exists for on
|
||||
// the CHAT screen too: interactive-widget=resizes-content is ignored by a
|
||||
// fair share of Android WebView/Chrome builds, and unlike iOS Safari they do
|
||||
// not reliably reveal the focused field either — the composer just stays
|
||||
// behind the keyboard. iOS keeps its browser-native reveal on the chat
|
||||
// screen, so this stays Android-only there.
|
||||
// Callers are browser-only React effects, so navigator always exists here.
|
||||
const isAndroidBrowser = (): boolean => /Android/i.test(navigator.userAgent);
|
||||
|
||||
export interface MobileViewportPinOptions {
|
||||
isMobile: boolean;
|
||||
/** Composer expanded to fullscreen on mobile. */
|
||||
@@ -96,12 +105,14 @@ export function useMobileViewportPin(options: MobileViewportPinOptions): void {
|
||||
};
|
||||
}, [editorRef, formRef, isFullscreen, isMobile]);
|
||||
|
||||
// Draft screen with the keyboard up: anchor the normal-height composer to
|
||||
// the visible bottom. The chat screen does not need this — its own
|
||||
// focused-field reveal works there.
|
||||
// Keyboard up: anchor the normal-height composer to the visible bottom.
|
||||
// Draft screen on every mobile browser; chat screen only on Android,
|
||||
// where neither viewport resizing nor the focused-field reveal can be
|
||||
// relied on (iOS chat keeps the browser's own reveal).
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isMobile || isCapacitorApp()) return;
|
||||
if (!isDraftScreen || isFullscreen || !isFocused) return;
|
||||
if (isFullscreen || !isFocused) return;
|
||||
if (!isDraftScreen && !isAndroidBrowser()) return;
|
||||
const vv = window.visualViewport;
|
||||
const form = formRef.current;
|
||||
if (!vv || !form) return;
|
||||
|
||||
+53
-30
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { CONTEXT_METADATA_KEY, contextPayloadFromDraft } from '@/lib/messages/contextParts';
|
||||
import {
|
||||
buildOutgoingMessage,
|
||||
type OutgoingMessageDeps,
|
||||
@@ -26,7 +28,6 @@ const deps = (overrides: Partial<OutgoingMessageDeps> = {}): OutgoingMessageDeps
|
||||
},
|
||||
sanitizeAttachments: (files) => [...(files ?? [])],
|
||||
collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]),
|
||||
appendComments: (text, comments) => `${text}\n[${comments.length} comments]`,
|
||||
buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null),
|
||||
...overrides,
|
||||
});
|
||||
@@ -37,7 +38,7 @@ const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageIn
|
||||
composerAttachments: [],
|
||||
inlineComments: [],
|
||||
syntheticTexts: [],
|
||||
linkedIssueContext: null,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
...overrides,
|
||||
});
|
||||
@@ -130,36 +131,51 @@ describe('agent mentions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline comments', () => {
|
||||
test('attach to the composer text when nothing was queued', () => {
|
||||
const commentDraft = (overrides: Partial<InlineCommentDraft> = {}): InlineCommentDraft => ({
|
||||
id: 'icd-1',
|
||||
sessionKey: 's1',
|
||||
source: 'diff',
|
||||
fileLabel: 'src/app.ts',
|
||||
startLine: 3,
|
||||
endLine: 5,
|
||||
side: 'modified',
|
||||
code: 'const x = 1;',
|
||||
language: 'ts',
|
||||
text: 'fix this',
|
||||
createdAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('context drafts', () => {
|
||||
test('each becomes a synthetic part carrying structured metadata', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'body',
|
||||
inlineComments: [{}, {}],
|
||||
inlineComments: [commentDraft(), commentDraft({ id: 'icd-2', source: 'file', side: undefined })],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('body\n[2 comments]');
|
||||
expect(result.primaryText).toBe('body');
|
||||
expect(result.additionalParts).toHaveLength(2);
|
||||
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
|
||||
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual(contextPayloadFromDraft(commentDraft()));
|
||||
expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual(contextPayloadFromDraft(commentDraft({ id: 'icd-2', source: 'file', side: undefined })));
|
||||
expect(result.additionalParts[0].text).toContain('Comment on `src/app.ts` lines 3-5 (modified):');
|
||||
expect(result.additionalParts[0].text).toContain('fix this');
|
||||
});
|
||||
|
||||
test('attach to the last authored part when messages were queued', () => {
|
||||
test('context parts precede other synthetic context', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'queued' }],
|
||||
composerText: 'typed',
|
||||
inlineComments: [{}],
|
||||
composerText: 'body',
|
||||
inlineComments: [commentDraft()],
|
||||
syntheticTexts: ['conflict note'],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('queued');
|
||||
expect(result.additionalParts[0].text).toBe('typed\n[1 comments]');
|
||||
expect(result.additionalParts.map((p) => p.text.startsWith('Comment on') ? 'comment' : p.text))
|
||||
.toEqual(['comment', 'conflict note']);
|
||||
});
|
||||
|
||||
test('fall back to primary when the queue produced no additional parts', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'only queued' }],
|
||||
inlineComments: [{}],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('only queued\n[1 comments]');
|
||||
});
|
||||
|
||||
test('no comments changes nothing', () => {
|
||||
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).primaryText)
|
||||
.toBe('body');
|
||||
test('no drafts changes nothing', () => {
|
||||
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).additionalParts)
|
||||
.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,26 +183,31 @@ describe('synthetic context', () => {
|
||||
test('a linked PR sends its instructions before its diff', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'review this',
|
||||
linkedPr: { instructions: 'how to read it', context: 'the diff' },
|
||||
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'how to read it', context: 'the diff' },
|
||||
}), deps());
|
||||
expect(result.additionalParts.map((p) => p.text))
|
||||
.toEqual(['how to read it', 'the diff']);
|
||||
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
|
||||
expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual({ kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' });
|
||||
});
|
||||
|
||||
test('a linked issue is sent as context', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'fix it',
|
||||
linkedIssueContext: 'issue body',
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
|
||||
}), deps());
|
||||
expect(result.additionalParts).toEqual([{ text: 'issue body', synthetic: true }]);
|
||||
expect(result.additionalParts).toHaveLength(1);
|
||||
expect(result.additionalParts[0].text).toBe('issue body');
|
||||
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' });
|
||||
});
|
||||
|
||||
test('synthetic texts precede the linked references', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'x',
|
||||
syntheticTexts: ['conflict note'],
|
||||
linkedIssueContext: 'issue body',
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
|
||||
}), deps());
|
||||
expect(result.additionalParts.map((p) => p.text))
|
||||
.toEqual(['conflict note', 'issue body']);
|
||||
@@ -211,7 +232,9 @@ describe('synthetic context', () => {
|
||||
});
|
||||
|
||||
test('context alone is still worth sending', () => {
|
||||
const result = buildOutgoingMessage(input({ linkedIssueContext: 'issue body' }), deps());
|
||||
const result = buildOutgoingMessage(input({
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
|
||||
}), deps());
|
||||
expect(result.isEmpty).toBe(false);
|
||||
});
|
||||
|
||||
@@ -230,8 +253,8 @@ describe('full assembly order', () => {
|
||||
queued: [{ content: 'q1' }, { content: 'q2' }],
|
||||
composerText: 'typed /deploy',
|
||||
syntheticTexts: ['synthetic'],
|
||||
linkedIssueContext: 'issue',
|
||||
linkedPr: { instructions: 'pr-how', context: 'pr-diff' },
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
|
||||
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
|
||||
}), deps());
|
||||
|
||||
expect(result.primaryText).toBe('q1');
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
*/
|
||||
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { contextPayloadFromDraft, createContextPart, type ContextPartMetadata } from '@/lib/messages/contextParts';
|
||||
|
||||
export interface OutgoingPart {
|
||||
text: string;
|
||||
attachments?: AttachedFile[];
|
||||
/** Synthetic parts are context for the model, not shown as user content. */
|
||||
synthetic?: boolean;
|
||||
/** Structured context (see contextParts.ts), persisted with the part. */
|
||||
metadata?: ContextPartMetadata;
|
||||
}
|
||||
|
||||
export interface OutgoingMessage {
|
||||
@@ -43,12 +47,12 @@ export interface OutgoingMessageInput {
|
||||
/** The composer's own text, or null when this send skips it. */
|
||||
composerText: string | null;
|
||||
composerAttachments: readonly AttachedFile[];
|
||||
/** Inline review comments, appended to the user's last authored text. */
|
||||
inlineComments: readonly unknown[];
|
||||
/** Context drafts (code comments, terminal selections, annotations, PR context). */
|
||||
inlineComments: readonly InlineCommentDraft[];
|
||||
/** Synthetic context produced elsewhere (conflict resolution, and such). */
|
||||
syntheticTexts: readonly string[];
|
||||
linkedIssueContext: string | null;
|
||||
linkedPr: { instructions: string; context: string } | null;
|
||||
linkedIssue: { number: number; title: string; url: string; contextText: string } | null;
|
||||
linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,8 +68,6 @@ export interface OutgoingMessageDeps {
|
||||
sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[];
|
||||
/** Skills named inline with `/name`. */
|
||||
collectSkillNames: (text: string) => string[];
|
||||
/** Append inline review comments to a message body. */
|
||||
appendComments: (text: string, comments: readonly unknown[]) => string;
|
||||
/** Instruction telling the model which skills the user named. */
|
||||
buildSkillInstruction: (names: string[]) => string | null;
|
||||
}
|
||||
@@ -134,33 +136,29 @@ export function buildOutgoingMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Inline comments attach to the last thing the user authored, so they read
|
||||
// as a continuation of it rather than as a separate turn.
|
||||
if (input.inlineComments.length > 0) {
|
||||
const lastAuthored = input.queued.length > 0 && additionalParts.length > 0
|
||||
? additionalParts[additionalParts.length - 1]
|
||||
: null;
|
||||
if (lastAuthored) {
|
||||
lastAuthored.text = deps.appendComments(lastAuthored.text, input.inlineComments);
|
||||
} else {
|
||||
primaryText = deps.appendComments(primaryText, input.inlineComments);
|
||||
}
|
||||
// Everything below is context for the model, never plain user text. Each
|
||||
// attached context item becomes its own synthetic part carrying structured
|
||||
// metadata, so the timeline can render it as a context block after the
|
||||
// server echoes the message back.
|
||||
for (const draft of input.inlineComments) {
|
||||
additionalParts.push(createContextPart(contextPayloadFromDraft(draft)));
|
||||
}
|
||||
|
||||
// Everything below is context for the model, never user-visible content.
|
||||
for (const text of input.syntheticTexts) {
|
||||
additionalParts.push({ text, synthetic: true });
|
||||
}
|
||||
|
||||
if (input.linkedIssueContext) {
|
||||
additionalParts.push({ text: input.linkedIssueContext, synthetic: true });
|
||||
if (input.linkedIssue) {
|
||||
const { number, title, url, contextText } = input.linkedIssue;
|
||||
additionalParts.push(createContextPart({ kind: 'github-issue', number, title, url }, contextText));
|
||||
}
|
||||
|
||||
if (input.linkedPr) {
|
||||
// Instructions before context: the model is told how to read the diff
|
||||
// before it is given the diff.
|
||||
additionalParts.push({ text: input.linkedPr.instructions, synthetic: true });
|
||||
additionalParts.push({ text: input.linkedPr.context, synthetic: true });
|
||||
const { number, title, url, instructions, context } = input.linkedPr;
|
||||
additionalParts.push({ text: instructions, synthetic: true });
|
||||
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
|
||||
}
|
||||
|
||||
const skillInstruction = deps.buildSkillInstruction(skillNames);
|
||||
|
||||
@@ -91,7 +91,7 @@ export function buildImagePasteInsertion(pastedText: string, citationText: strin
|
||||
* A single-line URL pasted over a selection becomes a markdown link rather
|
||||
* than replacing the selected text.
|
||||
*/
|
||||
export const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
|
||||
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
|
||||
|
||||
/**
|
||||
* Whether a pasted URL should wrap the selection as `[selected](url)`. A URL
|
||||
@@ -104,3 +104,61 @@ export function shouldWrapSelectionAsLink(url: string, selected: string): boolea
|
||||
&& selected.trim().length > 0
|
||||
&& !selected.includes('](');
|
||||
}
|
||||
|
||||
const MARKDOWN_WRAP_PAIRS: Record<string, [string, string]> = {
|
||||
'`': ['`', '`'],
|
||||
'*': ['*', '*'],
|
||||
'_': ['_', '_'],
|
||||
'~': ['~', '~'],
|
||||
'(': ['(', ')'],
|
||||
'[': ['[', ']'],
|
||||
'{': ['{', '}'],
|
||||
'"': ['"', '"'],
|
||||
"'": ["'", "'"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Markdown source-mode conveniences handled before CodeMirror inserts a key.
|
||||
* The returned text change and selection belong to one editor transaction so
|
||||
* the caret cannot be applied against the previous document.
|
||||
*/
|
||||
export function getMarkdownAutoPairEdit(
|
||||
value: string,
|
||||
key: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
): {
|
||||
from: number;
|
||||
to: number;
|
||||
insert: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
} | null {
|
||||
const pair = MARKDOWN_WRAP_PAIRS[key];
|
||||
if (selectionEnd > selectionStart && pair) {
|
||||
const selected = value.slice(selectionStart, selectionEnd);
|
||||
const [open, close] = pair;
|
||||
return {
|
||||
from: selectionStart,
|
||||
to: selectionEnd,
|
||||
insert: `${open}${selected}${close}`,
|
||||
selectionStart: selectionStart + open.length,
|
||||
selectionEnd: selectionEnd + open.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (key === '`' && selectionStart === selectionEnd) {
|
||||
const before = value.slice(0, selectionStart);
|
||||
if (/(^|\n)``$/.test(before)) {
|
||||
return {
|
||||
from: selectionStart,
|
||||
to: selectionEnd,
|
||||
insert: '`\n\n```',
|
||||
selectionStart: selectionStart + 2,
|
||||
selectionEnd: selectionStart + 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface AutocompleteRowTooltipProps {
|
||||
description?: string;
|
||||
active: boolean;
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
export function AutocompleteRowTooltip({ description, active, children }: AutocompleteRowTooltipProps) {
|
||||
const [delayedActive, setDelayedActive] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !description) {
|
||||
setDelayedActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => setDelayedActive(true), 200);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [active, description]);
|
||||
|
||||
if (!description) return children;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} open={active && delayedActive} onOpenChange={() => {}}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
{active && delayedActive ? (
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
|
||||
>
|
||||
<p className="typography-meta whitespace-pre-wrap">{description}</p>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
||||
<Icon name="add-circle" className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
requestAnimationFrame(handlePickLocalFiles);
|
||||
|
||||
@@ -2,164 +2,381 @@
|
||||
* Context chips above the composer.
|
||||
*
|
||||
* Each chip stands for context that will be attached to the next message but
|
||||
* is not part of its text: review comments left in a diff, captured dev-server
|
||||
* logs, preview annotations, terminal selections. They are shown so the user
|
||||
* knows what is riding along and can drop any of it before sending.
|
||||
* is not part of its text: review comments left in a diff, preview
|
||||
* annotations, terminal selections, PR context, chat quotes. Hovering (or
|
||||
* tapping) a chip opens a stacked preview of its items above the composer,
|
||||
* where a comment the user wrote can be edited in place and any item removed
|
||||
* before sending.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { InlineCommentDraft, InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import {
|
||||
EMPTY_INLINE_COMMENT_DRAFTS,
|
||||
getInlineCommentDraftKey,
|
||||
useInlineCommentDraftStore,
|
||||
type InlineCommentDraft,
|
||||
type InlineCommentDraftTarget,
|
||||
type InlineCommentSource,
|
||||
} from '@/stores/useInlineCommentDraftStore';
|
||||
import type { Theme } from '@/types/theme';
|
||||
|
||||
export interface ComposerContextChipsProps {
|
||||
/** Terminal selections, which show their own label and line range. */
|
||||
terminalDrafts: readonly InlineCommentDraft[];
|
||||
reviewCount: number;
|
||||
prCommentCount: number;
|
||||
prCheckCount: number;
|
||||
previewConsoleCount: number;
|
||||
previewAnnotationCount: number;
|
||||
draftTarget: InlineCommentDraftTarget | null;
|
||||
onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
|
||||
onRemoveReviewDrafts: () => void;
|
||||
onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => void;
|
||||
colors: Theme['colors'];
|
||||
}
|
||||
|
||||
/** A chip showing how many items of one kind are attached, with a clear action. */
|
||||
function CountChip(props: {
|
||||
/** Chip groups: every terminal selection is its own chip; the rest group by kind. */
|
||||
type ChipGroup = {
|
||||
key: string;
|
||||
icon: IconName;
|
||||
iconClassName?: string;
|
||||
label: string;
|
||||
count: number;
|
||||
removeLabel: string;
|
||||
drafts: InlineCommentDraft[];
|
||||
};
|
||||
|
||||
const REVIEW_SOURCES: readonly InlineCommentSource[] = ['diff', 'file', 'plan', 'file-quote'];
|
||||
|
||||
/** Sources whose drafts carry a user-written comment that can be edited. */
|
||||
const editableSource = (source: InlineCommentSource): boolean => source !== 'terminal';
|
||||
|
||||
/** Captured code/output kinds read better monospaced; quoted prose does not. */
|
||||
const monoSource = (source: InlineCommentSource): boolean =>
|
||||
source !== 'chat-quote' && source !== 'preview-annotation' && source !== 'file-quote';
|
||||
|
||||
const basename = (path: string): string => {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] ?? path;
|
||||
};
|
||||
|
||||
const ENTRY_ACTION_CLASS = 'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]';
|
||||
const ENTRY_LABEL_CLASS = 'text-[10px] font-medium uppercase tracking-wide text-[var(--surface-mutedForeground)] opacity-60';
|
||||
|
||||
const DraftPreviewEntry: React.FC<{
|
||||
draft: InlineCommentDraft;
|
||||
index: number;
|
||||
title: string;
|
||||
editing: boolean;
|
||||
onStartEdit: () => void;
|
||||
onEndEdit: () => void;
|
||||
onRemove: () => void;
|
||||
colors: Theme['colors'];
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
|
||||
style={{
|
||||
backgroundColor: props.colors?.surface?.elevated,
|
||||
borderColor: props.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
{props.icon}
|
||||
<span className="text-xs font-medium text-muted-foreground">{props.label}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: props.colors?.status?.info }}>
|
||||
{props.count}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
|
||||
style={{ minHeight: 0, minWidth: 0 }}
|
||||
onClick={props.onRemove}
|
||||
aria-label={props.removeLabel}
|
||||
title={props.removeLabel}
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComposerContextChips(props: ComposerContextChipsProps) {
|
||||
onSaveComment: ((text: string) => void) | null;
|
||||
}> = ({ draft, index, title, editing, onStartEdit, onEndEdit, onRemove, onSaveComment }) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
terminalDrafts,
|
||||
reviewCount,
|
||||
prCommentCount,
|
||||
prCheckCount,
|
||||
previewConsoleCount,
|
||||
previewAnnotationCount,
|
||||
draftTarget,
|
||||
onRemoveDraft,
|
||||
onRemoveReviewDrafts,
|
||||
onRemovePreviewDrafts,
|
||||
colors,
|
||||
} = props;
|
||||
const [editText, setEditText] = React.useState(draft.text);
|
||||
const editRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!editing) return;
|
||||
setEditText(draft.text);
|
||||
queueMicrotask(() => {
|
||||
const element = editRef.current;
|
||||
if (element) {
|
||||
element.focus();
|
||||
element.setSelectionRange(element.value.length, element.value.length);
|
||||
}
|
||||
});
|
||||
// The draft text at edit start is the baseline; later store updates are
|
||||
// our own saves.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editing]);
|
||||
|
||||
const commitEdit = () => {
|
||||
if (onSaveComment && editText !== draft.text) {
|
||||
onSaveComment(editText);
|
||||
}
|
||||
onEndEdit();
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditText(draft.text);
|
||||
onEndEdit();
|
||||
};
|
||||
|
||||
// Keep focus in the textarea while a header button is pressed: without
|
||||
// this the textarea's blur commits first, the header re-renders under the
|
||||
// pointer, and the click lands on the button that replaced the pressed one
|
||||
// (save punches through to edit, cancel to remove).
|
||||
const keepEditorFocus = (event: React.PointerEvent) => {
|
||||
if (editing) event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{terminalDrafts.map((draft) => (
|
||||
<div
|
||||
key={draft.id}
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1"
|
||||
title={draft.code}
|
||||
>
|
||||
<Icon name="terminal" className="h-3.5 w-3.5" />
|
||||
<span className="truncate text-xs font-medium text-[var(--surface-mutedForeground)]">
|
||||
{t('chat.chatInput.terminalContext', {
|
||||
terminal: draft.fileLabel,
|
||||
start: draft.startLine,
|
||||
end: draft.endLine,
|
||||
})}
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5"
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-mutedForeground) 8%, transparent)' }}>
|
||||
<span className="text-xs font-medium text-[var(--surface-mutedForeground)]">{index + 1}.</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--surface-foreground)]" title={title}>
|
||||
{title}
|
||||
</span>
|
||||
{onSaveComment ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
|
||||
onClick={() => draftTarget && onRemoveDraft(draftTarget, draft.id)}
|
||||
aria-label={t('chat.chatInput.terminalContextRemove')}
|
||||
title={t('chat.chatInput.terminalContextRemove')}
|
||||
className={ENTRY_ACTION_CLASS}
|
||||
style={{ minHeight: 0, minWidth: 0 }}
|
||||
onPointerDown={keepEditorFocus}
|
||||
onClick={editing ? commitEdit : onStartEdit}
|
||||
aria-label={t('chat.chatInput.contextPreview.edit')}
|
||||
title={t('chat.chatInput.contextPreview.edit')}
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
<Icon name={editing ? 'check' : 'pencil'} className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={ENTRY_ACTION_CLASS}
|
||||
style={{ minHeight: 0, minWidth: 0 }}
|
||||
onPointerDown={keepEditorFocus}
|
||||
onClick={editing ? cancelEdit : onRemove}
|
||||
aria-label={t('chat.chatInput.contextPreview.remove')}
|
||||
title={t('chat.chatInput.contextPreview.remove')}
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 px-3 py-2">
|
||||
{draft.code.trim() ? (
|
||||
<div>
|
||||
<div className={ENTRY_LABEL_CLASS}>{t('chat.chatInput.contextPreview.selectedLabel')}</div>
|
||||
<div
|
||||
className={
|
||||
monoSource(draft.source)
|
||||
? 'mt-0.5 whitespace-pre-wrap break-words font-mono text-xs text-[var(--surface-foreground)]'
|
||||
: 'mt-0.5 whitespace-pre-wrap break-words text-sm text-[var(--surface-foreground)]'
|
||||
}
|
||||
>
|
||||
{draft.code}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{onSaveComment && (editing || draft.text.trim()) ? (
|
||||
<div>
|
||||
<div className={ENTRY_LABEL_CLASS}>{t('chat.chatInput.contextPreview.commentLabel')}</div>
|
||||
{editing ? (
|
||||
<textarea
|
||||
ref={editRef}
|
||||
rows={2}
|
||||
value={editText}
|
||||
onChange={(event) => setEditText(event.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
commitEdit();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setEditText(draft.text);
|
||||
onEndEdit();
|
||||
}
|
||||
}}
|
||||
placeholder={t('chat.textSelection.comment.placeholder')}
|
||||
className="mt-0.5 w-full resize-none rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-2 py-1 text-sm text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)]"
|
||||
style={{ minHeight: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-0.5 whitespace-pre-wrap break-words text-sm text-[var(--surface-foreground)]">{draft.text}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function ComposerContextChips({ draftTarget, colors }: ComposerContextChipsProps) {
|
||||
const { t } = useI18n();
|
||||
const draftKey = draftTarget
|
||||
? getInlineCommentDraftKey(getRuntimeKey(), draftTarget.directory, draftTarget.sessionKey)
|
||||
: null;
|
||||
const drafts = useInlineCommentDraftStore(
|
||||
React.useCallback(
|
||||
(state) => (draftKey ? state.drafts[draftKey] ?? EMPTY_INLINE_COMMENT_DRAFTS : EMPTY_INLINE_COMMENT_DRAFTS),
|
||||
[draftKey],
|
||||
),
|
||||
);
|
||||
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
|
||||
|
||||
const [openGroupKey, setOpenGroupKey] = React.useState<string | null>(null);
|
||||
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
|
||||
const editingRef = React.useRef<string | null>(null);
|
||||
editingRef.current = editingDraftId;
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const closeTimerRef = React.useRef<number | null>(null);
|
||||
|
||||
const cancelClose = React.useCallback(() => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
// Hover-away close. Suspended while a comment is being edited: entering or
|
||||
// leaving edit mode reflows the panel under the pointer, and a synthetic
|
||||
// mouseleave from that reflow must not tear the editor down.
|
||||
const scheduleClose = React.useCallback(() => {
|
||||
if (editingRef.current) return;
|
||||
cancelClose();
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
closeTimerRef.current = null;
|
||||
setOpenGroupKey(null);
|
||||
}, 150);
|
||||
}, [cancelClose]);
|
||||
React.useEffect(() => cancelClose, [cancelClose]);
|
||||
|
||||
// Clicking outside the chips + panel closes the preview even when a reflow
|
||||
// swallowed the mouseleave (e.g. right after finishing an edit).
|
||||
React.useEffect(() => {
|
||||
if (!openGroupKey) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
// SAFETY: a pointer event target inside the document is always a
|
||||
// Node; `contains` only needs that.
|
||||
if (containerRef.current?.contains(event.target as Node)) return;
|
||||
setOpenGroupKey(null);
|
||||
setEditingDraftId(null);
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, [openGroupKey]);
|
||||
|
||||
const titleFor = React.useCallback((draft: InlineCommentDraft): string => {
|
||||
switch (draft.source) {
|
||||
case 'terminal':
|
||||
return t('chat.chatInput.terminalContext', {
|
||||
terminal: draft.fileLabel,
|
||||
start: draft.startLine,
|
||||
end: draft.endLine,
|
||||
});
|
||||
case 'preview-annotation':
|
||||
return t('chat.message.context.browserAnnotation', { page: draft.fileLabel });
|
||||
case 'pr-comment':
|
||||
return t('chat.message.context.prComment', { label: draft.fileLabel });
|
||||
case 'pr-check':
|
||||
return t('chat.message.context.prCheck', { label: draft.fileLabel });
|
||||
case 'chat-quote':
|
||||
return t('chat.message.context.chatQuote');
|
||||
case 'file-quote':
|
||||
return draft.startLine > 0 && draft.endLine > 0
|
||||
? (draft.startLine === draft.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine })
|
||||
: t('chat.message.context.codeComment', { file: basename(draft.fileLabel), start: draft.startLine, end: draft.endLine }))
|
||||
: t('chat.message.context.fileQuote', { file: basename(draft.fileLabel) });
|
||||
default:
|
||||
return draft.startLine === draft.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine })
|
||||
: t('chat.message.context.codeComment', { file: basename(draft.fileLabel), start: draft.startLine, end: draft.endLine });
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const groups = React.useMemo<ChipGroup[]>(() => {
|
||||
const result: ChipGroup[] = [];
|
||||
const byKind = (
|
||||
key: string,
|
||||
icon: IconName,
|
||||
label: string,
|
||||
match: (draft: InlineCommentDraft) => boolean,
|
||||
iconClassName?: string,
|
||||
) => {
|
||||
const matched = drafts.filter(match);
|
||||
if (matched.length > 0) {
|
||||
result.push({ key, icon, iconClassName, label, count: matched.length, drafts: matched });
|
||||
}
|
||||
};
|
||||
for (const draft of drafts) {
|
||||
if (draft.source !== 'terminal') continue;
|
||||
result.push({
|
||||
key: `terminal-${draft.id}`,
|
||||
icon: 'terminal',
|
||||
label: t('chat.chatInput.terminalContext', {
|
||||
terminal: draft.fileLabel,
|
||||
start: draft.startLine,
|
||||
end: draft.endLine,
|
||||
}),
|
||||
count: 0,
|
||||
drafts: [draft],
|
||||
});
|
||||
}
|
||||
byKind('review', 'chat-1', t('chat.chatInput.reviewComments'), (draft) => REVIEW_SOURCES.includes(draft.source));
|
||||
byKind('pr-comment', 'git-pull-request', t('chat.chatInput.prCommentContext'), (draft) => draft.source === 'pr-comment');
|
||||
byKind('pr-check', 'close-circle', t('chat.chatInput.prCheckContext'), (draft) => draft.source === 'pr-check', 'text-[var(--status-error)]');
|
||||
byKind('chat-quote', 'chat-1', t('chat.chatInput.chatQuoteContext'), (draft) => draft.source === 'chat-quote');
|
||||
byKind('annotation', 'global', t('chat.chatInput.previewAnnotations'), (draft) => draft.source === 'preview-annotation');
|
||||
return result;
|
||||
}, [drafts, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (openGroupKey && !groups.some((group) => group.key === openGroupKey)) {
|
||||
setOpenGroupKey(null);
|
||||
setEditingDraftId(null);
|
||||
}
|
||||
}, [groups, openGroupKey]);
|
||||
|
||||
if (!draftTarget || drafts.length === 0) return null;
|
||||
|
||||
const openGroup = openGroupKey ? groups.find((group) => group.key === openGroupKey) ?? null : null;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
{openGroup ? (
|
||||
<div
|
||||
className="oc-glass-popover absolute bottom-full left-0 z-30 mb-1.5 w-full max-w-[480px] overflow-hidden rounded-xl border border-[var(--interactive-border)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
|
||||
onMouseEnter={cancelClose}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<div className="max-h-[min(50vh,420px)] divide-y divide-[var(--interactive-border)] overflow-y-auto">
|
||||
{openGroup.drafts.map((draft, index) => (
|
||||
<DraftPreviewEntry
|
||||
key={draft.id}
|
||||
draft={draft}
|
||||
index={index}
|
||||
title={titleFor(draft)}
|
||||
editing={editingDraftId === draft.id}
|
||||
onStartEdit={() => setEditingDraftId(draft.id)}
|
||||
onEndEdit={() => setEditingDraftId((current) => (current === draft.id ? null : current))}
|
||||
onRemove={() => removeDraft(draftTarget, draft.id)}
|
||||
onSaveComment={editableSource(draft.source)
|
||||
? (text) => updateDraft(draftTarget, draft.id, { text })
|
||||
: null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{reviewCount > 0 ? (
|
||||
<CountChip
|
||||
label={t('chat.chatInput.reviewComments')}
|
||||
count={reviewCount}
|
||||
removeLabel={t('chat.chatInput.reviewCommentsRemove')}
|
||||
onRemove={onRemoveReviewDrafts}
|
||||
colors={colors}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{prCommentCount > 0 ? (
|
||||
<CountChip
|
||||
label={t('chat.chatInput.prCommentContext')}
|
||||
count={prCommentCount}
|
||||
removeLabel={t('chat.chatInput.prCommentContextRemove')}
|
||||
onRemove={() => onRemovePreviewDrafts('pr-comment')}
|
||||
colors={colors}
|
||||
icon={<Icon name="git-pull-request" className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{prCheckCount > 0 ? (
|
||||
<CountChip
|
||||
label={t('chat.chatInput.prCheckContext')}
|
||||
count={prCheckCount}
|
||||
removeLabel={t('chat.chatInput.prCheckContextRemove')}
|
||||
onRemove={() => onRemovePreviewDrafts('pr-check')}
|
||||
colors={colors}
|
||||
icon={<Icon name="close-circle" className="h-3.5 w-3.5 text-[var(--status-error)]" />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{previewConsoleCount > 0 ? (
|
||||
<CountChip
|
||||
label={t('chat.chatInput.devServerLogs')}
|
||||
count={previewConsoleCount}
|
||||
removeLabel={t('chat.chatInput.devServerLogsRemove')}
|
||||
onRemove={() => onRemovePreviewDrafts('preview-console')}
|
||||
colors={colors}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{previewAnnotationCount > 0 ? (
|
||||
<CountChip
|
||||
label={t('chat.chatInput.previewAnnotations')}
|
||||
count={previewAnnotationCount}
|
||||
removeLabel={t('chat.chatInput.previewContextRemove')}
|
||||
onRemove={() => onRemovePreviewDrafts('preview-annotation')}
|
||||
colors={colors}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group.key}
|
||||
type="button"
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-xl border px-2.5 py-1 text-left"
|
||||
style={{
|
||||
backgroundColor: colors?.surface?.elevated,
|
||||
borderColor: colors?.interactive?.border,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpenGroupKey(group.key);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
onClick={() => {
|
||||
if (editingRef.current) return;
|
||||
setOpenGroupKey((current) => (current === group.key ? null : group.key));
|
||||
}}
|
||||
aria-expanded={openGroupKey === group.key}
|
||||
>
|
||||
<Icon name={group.icon} className={`h-3.5 w-3.5 shrink-0 text-muted-foreground ${group.iconClassName ?? ''}`} />
|
||||
<span className="truncate text-xs font-medium text-muted-foreground">{group.label}</span>
|
||||
{group.count > 0 ? (
|
||||
<span className="text-xs font-semibold" style={{ color: colors?.status?.info }}>
|
||||
{group.count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -23,8 +24,10 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
||||
import { useKeybind } from '@/hooks/useKeybind';
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { normalizePath } from '../attachments/filePaths';
|
||||
import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget';
|
||||
@@ -54,10 +57,12 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
|
||||
projectColor ? PROJECT_COLOR_MAP[projectColor] ?? undefined : undefined;
|
||||
|
||||
/** A project's icon (custom image, configured icon, or a folder) plus its name. */
|
||||
export function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
|
||||
function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
|
||||
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const iconColor = getProjectIconColor(project.color);
|
||||
const fallbackIcon = projectIconName ? (
|
||||
const fallbackIcon = project.kind === 'chat' ? (
|
||||
<Icon name="chat-4" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
|
||||
) : projectIconName ? (
|
||||
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
|
||||
@@ -103,25 +108,61 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
onDirectoryChange,
|
||||
theme,
|
||||
} = props;
|
||||
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
|
||||
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const handlePickerKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (openPicker === null || !shouldDismissDropdown(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpenPicker(null);
|
||||
};
|
||||
|
||||
useKeybind('open_draft_project_picker', () => {
|
||||
projectTriggerRef.current?.focus();
|
||||
setOpenPicker('project');
|
||||
});
|
||||
useKeybind('open_draft_worktree_picker', () => {
|
||||
if (!showBranchSelector) return false;
|
||||
worktreeTriggerRef.current?.focus();
|
||||
setOpenPicker('worktree');
|
||||
});
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
onProjectChange(projectId);
|
||||
setOpenPicker(null);
|
||||
};
|
||||
|
||||
const handleDirectoryChange = (directory: string) => {
|
||||
onDirectoryChange(directory);
|
||||
setOpenPicker(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
|
||||
<Select
|
||||
value={selectedProject.id}
|
||||
onValueChange={onProjectChange}
|
||||
open={openPicker === 'project'}
|
||||
onOpenChange={(open) => setOpenPicker(open ? 'project' : null)}
|
||||
onValueChange={handleProjectChange}
|
||||
disableGlobalShortcuts
|
||||
>
|
||||
<SelectTrigger
|
||||
ref={projectTriggerRef}
|
||||
onKeyDown={handlePickerKeyDown}
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
||||
>
|
||||
<SelectValue>
|
||||
{<ProjectLabel project={selectedProject} theme={theme} />}
|
||||
{selectedProject.kind === 'chat'
|
||||
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
|
||||
: <ProjectLabel project={selectedProject} theme={theme} />}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent fitContent>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent onKeyDown={handlePickerKeyDown}>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
|
||||
{<ProjectLabel project={project} theme={theme} />}
|
||||
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
<ProjectLabel project={project} theme={theme} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -130,9 +171,14 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
{showBranchSelector ? (
|
||||
<Select
|
||||
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
|
||||
onValueChange={onDirectoryChange}
|
||||
open={openPicker === 'worktree'}
|
||||
onOpenChange={(open) => setOpenPicker(open ? 'worktree' : null)}
|
||||
onValueChange={handleDirectoryChange}
|
||||
disableGlobalShortcuts
|
||||
>
|
||||
<SelectTrigger
|
||||
ref={worktreeTriggerRef}
|
||||
onKeyDown={handlePickerKeyDown}
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
||||
>
|
||||
@@ -140,11 +186,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
{selectedBranchLabel ?? t('chat.chatInput.branch')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-max min-w-48">
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
|
||||
{projectRootBranchOption ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
|
||||
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
|
||||
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{projectRootBranchOption.label}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
@@ -163,13 +209,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
</button>
|
||||
</div>
|
||||
{worktreeBranchOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
|
||||
<SelectItem key={option.value} value={option.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{option.pending ? '⏳ ' : ''}{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
{selectedDirectory && !selectedBranchIsKnown ? (
|
||||
<SelectItem value={selectedDirectory} className="max-w-[24rem] truncate">
|
||||
<SelectItem value={selectedDirectory} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{selectedBranchLabel}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
@@ -195,7 +241,9 @@ export function MobileDraftTargetTriggers(
|
||||
className="inline-flex h-7 min-w-0 max-w-[42vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => onOpenPicker('project')}
|
||||
>
|
||||
{<ProjectLabel project={selectedProject} theme={theme} />}
|
||||
{selectedProject.kind === 'chat'
|
||||
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
|
||||
: <ProjectLabel project={selectedProject} theme={theme} />}
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
{showBranchSelector ? (
|
||||
@@ -258,13 +306,7 @@ export function MobileDraftTargetSheets(
|
||||
className="h-9"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
{projects
|
||||
.filter((project) => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return true;
|
||||
return getProjectDisplayLabel(project).toLowerCase().includes(needle)
|
||||
|| project.path.toLowerCase().includes(needle);
|
||||
})
|
||||
{rankByQuery(projects, query, (project) => [getProjectDisplayLabel(project), project.path])
|
||||
.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
@@ -275,7 +317,7 @@ export function MobileDraftTargetSheets(
|
||||
onOpenPickerChange(null);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1">{<ProjectLabel project={project} theme={theme} />}</span>
|
||||
<span className="min-w-0 flex-1"><ProjectLabel project={project} theme={theme} /></span>
|
||||
{project.id === selectedProject.id ? (
|
||||
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
@@ -298,8 +340,7 @@ export function MobileDraftTargetSheets(
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
{(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const matches = (label: string) => !needle || label.toLowerCase().includes(needle);
|
||||
const matches = (label: string) => matchesRankQuery([label], query);
|
||||
const selectedValue = selectedDirectory
|
||||
?? branchItems[0]?.value
|
||||
?? normalizePath(selectedProject.path)
|
||||
@@ -343,8 +384,7 @@ export function MobileDraftTargetSheets(
|
||||
{t('chat.chatInput.worktreeNew')}
|
||||
</button>
|
||||
</div>
|
||||
{worktreeBranchOptions
|
||||
.filter((option) => matches(option.label))
|
||||
{rankByQuery(worktreeBranchOptions, query, (option) => [option.label])
|
||||
.map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))}
|
||||
{selectedDirectory && !selectedBranchIsKnown && matches(selectedBranchLabel ?? '')
|
||||
? renderRow(selectedDirectory, selectedBranchLabel, 'unknown-current')
|
||||
|
||||
@@ -5,7 +5,12 @@ import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn, isMacOS } from '@/lib/utils';
|
||||
import {
|
||||
formatShortcutForDisplay,
|
||||
getEffectiveShortcutCombo,
|
||||
} from '@/lib/shortcuts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
type FocusModeButtonProps = {
|
||||
footerIconButtonClass: string;
|
||||
@@ -17,6 +22,12 @@ type FocusModeButtonProps = {
|
||||
export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
|
||||
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
|
||||
const { t } = useI18n();
|
||||
const expandInputShortcutOverride = useUIStore((state) => state.shortcutOverrides.expand_input);
|
||||
const expandInputCombo = getEffectiveShortcutCombo(
|
||||
'expand_input',
|
||||
expandInputShortcutOverride === undefined ? undefined : { expand_input: expandInputShortcutOverride },
|
||||
);
|
||||
const shortcut = expandInputCombo ? formatShortcutForDisplay(expandInputCombo) : null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
@@ -43,9 +54,7 @@ export const FocusModeButton = React.memo(function FocusModeButton(props: FocusM
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<div className="flex flex-col gap-0.5 text-center">
|
||||
<span>{t('chat.chatInput.focusMode.label')}</span>
|
||||
<span className="font-mono opacity-60">
|
||||
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
|
||||
</span>
|
||||
{shortcut ? <span className="font-mono opacity-60">{shortcut}</span> : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -84,7 +84,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
|
||||
data-mobile-composer-pill="true"
|
||||
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
|
||||
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
|
||||
>
|
||||
<ComposerAttachmentControls
|
||||
|
||||
@@ -93,7 +93,8 @@ export const RevertedMessageDock: React.FC<RevertedMessageDockProps> = React.mem
|
||||
if (!sessionId || restoringId) return;
|
||||
setRestoringId(messageId);
|
||||
try {
|
||||
const nextMessage = userMessages.find((message) => message.id > messageId);
|
||||
const messageIndex = userMessages.findIndex((message) => message.id === messageId);
|
||||
const nextMessage = messageIndex >= 0 ? userMessages[messageIndex + 1] : undefined;
|
||||
if (nextMessage) {
|
||||
await revertToMessage(sessionId, nextMessage.id, { skipRedoPush: true });
|
||||
} else {
|
||||
|
||||
@@ -103,7 +103,7 @@ const STYLE_CLASS: Record<AnyStyle, string> = {
|
||||
mentionAgent: 'text-[var(--status-success)]',
|
||||
mentionCommand: 'text-[var(--primary)]',
|
||||
mentionSnippet: 'text-[var(--status-warning)]',
|
||||
code: 'rounded-[3px] bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
|
||||
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)]',
|
||||
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
|
||||
// A `~path` is written for the reader's benefit, not to attach anything —
|
||||
// it takes the same colour as a file mention, since it names the same kind
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { mentionServerQuery, rankFileMentionResults, tokenizeMentionQuery } from './fileMentionResults';
|
||||
|
||||
const hit = (relativePath: string) => {
|
||||
const name = relativePath.split('/').filter(Boolean).pop() ?? relativePath;
|
||||
return {
|
||||
name,
|
||||
path: `/root/${relativePath}`,
|
||||
relativePath,
|
||||
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
describe('tokenizeMentionQuery', () => {
|
||||
test('normalizes leading ./ and slashes and splits on whitespace', () => {
|
||||
expect(tokenizeMentionQuery('./Solo Team')).toEqual(['solo', 'team']);
|
||||
expect(tokenizeMentionQuery(' ')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mentionServerQuery', () => {
|
||||
test('uses the longest token for the server search', () => {
|
||||
expect(mentionServerQuery('team solo-is-a')).toBe('solo-is-a');
|
||||
expect(mentionServerQuery('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankFileMentionResults', () => {
|
||||
test('ranks files and directories together by match quality, not by category', () => {
|
||||
const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')];
|
||||
const directories = [hit('machine-learning/tensorflow/'), hit('solo-is-a-team-size/')];
|
||||
|
||||
const ranked = rankFileMentionResults(files, directories, 'solo');
|
||||
const paths = ranked.map((entry) => entry.relativePath);
|
||||
|
||||
expect(paths.slice(0, 2)).toEqual(['solo-is-a-team-size/', 'solo-is-a-team-size/index.md']);
|
||||
expect(paths).not.toContain('machine-learning/tensorflow/');
|
||||
});
|
||||
|
||||
test('multi-token queries match tokens in any order across the path', () => {
|
||||
const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')];
|
||||
|
||||
const ranked = rankFileMentionResults(files, [], 'team solo');
|
||||
expect(ranked.map((entry) => entry.relativePath)).toEqual(['solo-is-a-team-size/index.md']);
|
||||
});
|
||||
|
||||
test('tags each result with its kind', () => {
|
||||
const ranked = rankFileMentionResults([hit('a/readme.md')], [hit('a/')], 'a');
|
||||
expect(ranked.find((entry) => entry.relativePath === 'a/')?.kind).toBe('directory');
|
||||
expect(ranked.find((entry) => entry.relativePath === 'a/readme.md')?.kind).toBe('file');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
|
||||
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
|
||||
|
||||
export type FileMentionHit = ProjectFileSearchHit & { kind: 'file' | 'directory' };
|
||||
|
||||
export const tokenizeMentionQuery = (query: string): string[] =>
|
||||
(query ?? '')
|
||||
.trim()
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/^\/+/, '')
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
/**
|
||||
* The opencode file search takes a single term, so multi-word queries send the
|
||||
* most selective (longest) token and the remaining tokens filter client-side.
|
||||
*/
|
||||
export const mentionServerQuery = (query: string): string => {
|
||||
const tokens = tokenizeMentionQuery(query);
|
||||
if (tokens.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return tokens.reduce((longest, token) => (token.length > longest.length ? token : longest));
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge directory and file hits into one list ranked by match quality against
|
||||
* the full relative path. Multi-token queries require every token to appear
|
||||
* somewhere in the path, in any order.
|
||||
*/
|
||||
export function rankFileMentionResults(
|
||||
files: ProjectFileSearchHit[],
|
||||
directories: ProjectFileSearchHit[],
|
||||
query: string,
|
||||
limit = 20,
|
||||
): FileMentionHit[] {
|
||||
const merged: FileMentionHit[] = [
|
||||
...directories.map((hit) => ({ ...hit, kind: 'directory' as const })),
|
||||
...files.map((hit) => ({ ...hit, kind: 'file' as const })),
|
||||
];
|
||||
|
||||
const tokens = tokenizeMentionQuery(query);
|
||||
if (tokens.length === 0) {
|
||||
return merged.slice(0, limit);
|
||||
}
|
||||
|
||||
const pathOf = (hit: FileMentionHit) => hit.relativePath || hit.name;
|
||||
const candidates = tokens.length === 1
|
||||
? merged
|
||||
: merged.filter((hit) => {
|
||||
const haystack = pathOf(hit).toLowerCase();
|
||||
return tokens.every((token) => haystack.includes(token));
|
||||
});
|
||||
|
||||
const primary = tokens.reduce((longest, token) => (token.length > longest.length ? token : longest));
|
||||
return scoreByFuzzyQuery(candidates, primary, pathOf, { limit, threshold: 0.4 }).map(
|
||||
(scored) => scored.item,
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,29 @@ export const normalizeReferencePath = (value: string): string => normalizeFilePa
|
||||
|
||||
export const isAbsoluteReferencePath = (value: string): boolean => isAbsoluteFilePath(value);
|
||||
|
||||
export const localPathFromFileUrl = (value: string): string | null => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'file:' || (parsed.hostname && parsed.hostname !== 'localhost')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedPath = decodeURIComponent(parsed.pathname);
|
||||
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
|
||||
return decodedPath.slice(1);
|
||||
}
|
||||
return decodedPath.startsWith('/') ? decodedPath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const trimPathCandidate = (value: string): string => {
|
||||
let next = (value || '').trim();
|
||||
if (!next) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { fileReferenceExists } from './fileReferenceStat';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const calls: Array<{ url: string; headers: Headers }> = [];
|
||||
|
||||
const stubFetchWith = (respond: () => Response) => {
|
||||
calls.length = 0;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input instanceof Request ? input.url : input.toString();
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({ url, headers });
|
||||
return respond();
|
||||
// SAFETY: the stub preserves the fetch signature; every caller in this
|
||||
// file restores globalThis.fetch in afterEach.
|
||||
}) as typeof fetch;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('fileReferenceExists directory scoping (issue 3019)', () => {
|
||||
test('sends the session directory on the stat probe', async () => {
|
||||
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-b/src/index.ts', isFile: true, size: 12 }), { status: 200 }));
|
||||
|
||||
const exists = await fileReferenceExists('/repo-b/src/index.ts', '/repo-b');
|
||||
|
||||
expect(exists).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toBe('/api/fs/stat?path=%2Frepo-b%2Fsrc%2Findex.ts&optional=true');
|
||||
expect(calls[0].headers.get('x-opencode-directory')).toBe('/repo-b');
|
||||
});
|
||||
|
||||
test('treats a workspace rejection under one directory as unknown under another directory', async () => {
|
||||
// Directory A resolves the workspace on the server (the browsed
|
||||
// lastDirectory), so the probe for a path under B is rejected with 400
|
||||
// and resolves false. The same path probed under B itself must issue a
|
||||
// fresh request rather than reuse A's cached rejection.
|
||||
stubFetchWith(() => {
|
||||
const directoryHint = calls[calls.length - 1]?.headers.get('x-opencode-directory') ?? null;
|
||||
if (directoryHint !== '/repo-b') {
|
||||
return new Response(JSON.stringify({ error: 'Path is outside of active workspace' }), { status: 400 });
|
||||
}
|
||||
return new Response(JSON.stringify({ path: '/repo-b/lib/main.ts', isFile: true, size: 12 }), { status: 200 });
|
||||
});
|
||||
|
||||
const rejectedUnderA = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-a');
|
||||
const acceptedUnderB = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-b');
|
||||
|
||||
expect(rejectedUnderA).toBe(false);
|
||||
expect(acceptedUnderB).toBe(true);
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('serves a repeated probe under the same directory from the cache', async () => {
|
||||
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-c/lib.ts', isFile: true, size: 4 }), { status: 200 }));
|
||||
|
||||
await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
|
||||
const warm = await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
|
||||
|
||||
expect(warm).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
import { normalizeReferencePath } from './fileReferenceParser';
|
||||
|
||||
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
|
||||
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
|
||||
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
|
||||
|
||||
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
|
||||
let activeFileReferenceStatCount = 0;
|
||||
const pendingFileReferenceStats: Array<() => void> = [];
|
||||
|
||||
const getFileReferenceStatCacheMax = (): number => (
|
||||
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
|
||||
);
|
||||
|
||||
// NUL cannot occur in a real path, so a directory-qualified key cannot collide
|
||||
// with a differently scoped entry.
|
||||
const statCacheKey = (directory: string, normalizedPath: string): string => `${directory}\u0000${normalizedPath}`;
|
||||
|
||||
export const fileReferenceExists = (resolvedPath: string, effectiveDirectory: string): Promise<boolean> => {
|
||||
const normalizedPath = normalizeReferencePath(resolvedPath);
|
||||
if (!normalizedPath) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const cacheKey = statCacheKey(effectiveDirectory, normalizedPath);
|
||||
const cached = FILE_REFERENCE_STAT_CACHE.get(cacheKey);
|
||||
if (cached) {
|
||||
FILE_REFERENCE_STAT_CACHE.delete(cacheKey);
|
||||
FILE_REFERENCE_STAT_CACHE.set(cacheKey, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const request = new Promise<boolean>((resolve) => {
|
||||
const run = () => {
|
||||
activeFileReferenceStatCount += 1;
|
||||
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
// The stat route resolves the workspace from this header. Without it
|
||||
// the server falls back to the browsed lastDirectory, which rejects
|
||||
// session-local files with 400 whenever the two directories differ.
|
||||
headers: effectiveDirectory ? { 'x-opencode-directory': effectiveDirectory } : undefined,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
|
||||
resolve(payload?.exists !== false);
|
||||
})
|
||||
.catch(() => resolve(false))
|
||||
.finally(() => {
|
||||
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
|
||||
pendingFileReferenceStats.shift()?.();
|
||||
});
|
||||
};
|
||||
|
||||
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingFileReferenceStats.push(run);
|
||||
});
|
||||
|
||||
const maxCacheEntries = getFileReferenceStatCacheMax();
|
||||
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
|
||||
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
|
||||
if (typeof oldest !== 'string') {
|
||||
break;
|
||||
}
|
||||
FILE_REFERENCE_STAT_CACHE.delete(oldest);
|
||||
}
|
||||
FILE_REFERENCE_STAT_CACHE.set(cacheKey, request);
|
||||
return request;
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
CHAT_LIST_ANCHOR_OFFSET,
|
||||
getAnchoredTurnMetrics,
|
||||
getRowBottom,
|
||||
resolveChatListAnchoredEndSpace,
|
||||
resolveTimelineIsAtEnd,
|
||||
type TimelineListMeasurementState,
|
||||
} from './timelineScrollAnchoring';
|
||||
|
||||
const buildState = ({
|
||||
positions,
|
||||
sizes,
|
||||
scroll = 0,
|
||||
scrollLength = 700,
|
||||
}: {
|
||||
readonly positions: readonly number[];
|
||||
readonly sizes: readonly number[];
|
||||
readonly scroll?: number;
|
||||
readonly scrollLength?: number;
|
||||
}): TimelineListMeasurementState => ({
|
||||
data: positions.map((_, index) => index),
|
||||
scroll,
|
||||
scrollLength,
|
||||
positionAtIndex: (index) => positions[index],
|
||||
sizeAtIndex: (index) => sizes[index],
|
||||
});
|
||||
|
||||
describe('getRowBottom', () => {
|
||||
test('measures row bottoms from list row position and size', () => {
|
||||
const state = buildState({ positions: [0, 120], sizes: [80, 40] });
|
||||
|
||||
expect(getRowBottom(state, 1)).toBe(160);
|
||||
});
|
||||
|
||||
test('returns null for unmeasured rows', () => {
|
||||
const state = buildState({ positions: [0], sizes: [80] });
|
||||
|
||||
expect(getRowBottom(state, 5)).toBeNull();
|
||||
});
|
||||
|
||||
test('treats a zero-height row as one pixel tall', () => {
|
||||
const state = buildState({ positions: [0, 120], sizes: [120, 0] });
|
||||
|
||||
expect(getRowBottom(state, 1)).toBe(121);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnchoredTurnMetrics', () => {
|
||||
test('returns null for an empty timeline', () => {
|
||||
const state = buildState({ positions: [], sizes: [] });
|
||||
|
||||
expect(getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 0,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
test('treats the active turn as fitting when it fits above the composer', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 300, 460],
|
||||
sizes: [240, 80, 140],
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.turnHeight).toBe(300);
|
||||
expect(metrics?.usableViewportHeight).toBe(564);
|
||||
expect(metrics?.overflowsUsableViewport).toBe(false);
|
||||
expect(metrics?.targetScrollToRevealEnd).toBe(36);
|
||||
expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
|
||||
});
|
||||
|
||||
test('targets the real row end instead of any temporary reserved tail', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 1720, 1880],
|
||||
sizes: [1600, 80, 120],
|
||||
scroll: 1900,
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.lastBottom).toBe(2000);
|
||||
expect(metrics?.targetScrollToRevealEnd).toBe(1436);
|
||||
expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
|
||||
});
|
||||
|
||||
test('reports overflow only for the current anchored turn', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 900, 1180],
|
||||
sizes: [800, 220, 300],
|
||||
scroll: 900,
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.turnHeight).toBe(580);
|
||||
expect(metrics?.usableViewportHeight).toBe(564);
|
||||
expect(metrics?.overflowsUsableViewport).toBe(true);
|
||||
});
|
||||
|
||||
test('returns the minimal positive scroll delta needed to reveal the turn end', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 900, 1180],
|
||||
sizes: [800, 220, 360],
|
||||
scroll: 900,
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 180,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.lastBottom).toBe(1540);
|
||||
expect(metrics?.visibleUsableBottom).toBe(1464);
|
||||
expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
|
||||
});
|
||||
|
||||
test('subtracts composer height from usable viewport height', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 300],
|
||||
sizes: [120, 470],
|
||||
scrollLength: 700,
|
||||
});
|
||||
|
||||
const withoutComposer = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 0,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
const withComposer = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 1,
|
||||
composerOverlayHeight: 220,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(withoutComposer?.overflowsUsableViewport).toBe(false);
|
||||
expect(withComposer?.overflowsUsableViewport).toBe(true);
|
||||
});
|
||||
|
||||
test('clamps an out-of-range anchor index to the last row', () => {
|
||||
const state = buildState({
|
||||
positions: [0, 300],
|
||||
sizes: [240, 80],
|
||||
scrollLength: 760,
|
||||
});
|
||||
|
||||
const metrics = getAnchoredTurnMetrics({
|
||||
state,
|
||||
anchorIndex: 99,
|
||||
composerOverlayHeight: 0,
|
||||
anchorOffset: 16,
|
||||
});
|
||||
|
||||
expect(metrics?.anchorTop).toBe(300);
|
||||
expect(metrics?.turnHeight).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimelineIsAtEnd', () => {
|
||||
test('uses a tight distance band against the full content length', () => {
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
|
||||
});
|
||||
|
||||
test('falls back to the list flags when distances are unavailable', () => {
|
||||
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
|
||||
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
|
||||
});
|
||||
|
||||
test('reports nothing without a state', () => {
|
||||
expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveChatListAnchoredEndSpace', () => {
|
||||
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'a' }];
|
||||
|
||||
test('returns nothing when no anchor is set', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, null, (row) => row.id)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('returns nothing when the anchor is not in the list', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, 'z', (row) => row.id)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('resolves the last occurrence so a resent message anchors to its live row', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, 'a', (row) => row.id)).toEqual({
|
||||
anchorIndex: 2,
|
||||
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
|
||||
});
|
||||
});
|
||||
|
||||
test('honours an explicit anchor offset', () => {
|
||||
expect(resolveChatListAnchoredEndSpace(rows, 'b', (row) => row.id, { anchorOffset: 40 })).toEqual({
|
||||
anchorIndex: 1,
|
||||
anchorOffset: 40,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// Anchored-turn scroll geometry for the chat timeline.
|
||||
//
|
||||
// The timeline has three mutually exclusive scroll modes:
|
||||
//
|
||||
// • `following-end` — stay pinned to the live edge as content grows.
|
||||
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
|
||||
// of the viewport and the reply streams into reserved space below it. The
|
||||
// viewport does NOT move until the turn outgrows the usable viewport.
|
||||
// • `free-scrolling` — the user took over; nothing moves the scroll
|
||||
// position until they opt back in.
|
||||
//
|
||||
// This module is pure geometry: it reads measurements from the virtualized
|
||||
// list and answers "how far, if at all, must we scroll to reveal the end of
|
||||
// the anchored turn". Keeping it free of DOM and React makes the mode machine
|
||||
// testable without a renderer.
|
||||
//
|
||||
// "Usable viewport" is the visible height minus the composer overlay (the
|
||||
// composer floats over the list) minus the anchor offset, so a turn is only
|
||||
// considered overflowing when it genuinely cannot be read.
|
||||
|
||||
export type TimelineScrollMode = 'following-end' | 'anchoring-new-turn' | 'free-scrolling';
|
||||
|
||||
// Distance from the top of the viewport at which an anchored user message
|
||||
// parks. Small enough to read as "at the top", large enough not to collide
|
||||
// with the timeline's top fade.
|
||||
export const CHAT_LIST_ANCHOR_OFFSET = 16;
|
||||
|
||||
export interface TimelineListMeasurementState {
|
||||
readonly data: readonly unknown[];
|
||||
readonly scroll: number;
|
||||
readonly scrollLength: number;
|
||||
readonly positionAtIndex: (index: number) => number | undefined;
|
||||
readonly sizeAtIndex: (index: number) => number | undefined;
|
||||
}
|
||||
|
||||
export interface AnchoredTurnMetrics {
|
||||
readonly anchorTop: number;
|
||||
readonly lastBottom: number;
|
||||
readonly turnHeight: number;
|
||||
readonly usableViewportHeight: number;
|
||||
readonly visibleUsableBottom: number;
|
||||
readonly overflowsUsableViewport: boolean;
|
||||
readonly targetScrollToRevealEnd: number;
|
||||
readonly scrollDeltaToRevealEnd: number;
|
||||
}
|
||||
|
||||
export const getRowBottom = (
|
||||
state: TimelineListMeasurementState,
|
||||
index: number,
|
||||
): number | null => {
|
||||
const top = state.positionAtIndex(index);
|
||||
const height = state.sizeAtIndex(index);
|
||||
if (
|
||||
typeof top !== 'number'
|
||||
|| typeof height !== 'number'
|
||||
|| !Number.isFinite(top)
|
||||
|| !Number.isFinite(height)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Rows measured at zero height would make an anchored turn look empty and
|
||||
// suppress the reveal scroll; treat them as one pixel tall instead.
|
||||
return top + Math.max(1, height);
|
||||
};
|
||||
|
||||
export const getAnchoredTurnMetrics = ({
|
||||
state,
|
||||
anchorIndex,
|
||||
composerOverlayHeight,
|
||||
anchorOffset,
|
||||
}: {
|
||||
readonly state: TimelineListMeasurementState;
|
||||
readonly anchorIndex: number;
|
||||
readonly composerOverlayHeight: number;
|
||||
readonly anchorOffset: number;
|
||||
}): AnchoredTurnMetrics | null => {
|
||||
if (state.data.length === 0) return null;
|
||||
|
||||
const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
|
||||
const anchorTop = state.positionAtIndex(boundedAnchorIndex);
|
||||
// The LAST row bottom, not the content length: the reserved anchored end
|
||||
// space lives past it, and targeting that reserved tail would scroll the
|
||||
// real content off the top.
|
||||
const lastBottom = getRowBottom(state, state.data.length - 1);
|
||||
if (typeof anchorTop !== 'number' || !Number.isFinite(anchorTop) || lastBottom === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usableViewportHeight = Math.max(
|
||||
0,
|
||||
state.scrollLength - composerOverlayHeight - anchorOffset,
|
||||
);
|
||||
const turnHeight = Math.max(0, lastBottom - anchorTop);
|
||||
const visibleUsableBottom = state.scroll + usableViewportHeight;
|
||||
const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
|
||||
// Never negative: revealing the end must not scroll the timeline backwards.
|
||||
const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
|
||||
|
||||
return {
|
||||
anchorTop,
|
||||
lastBottom,
|
||||
turnHeight,
|
||||
usableViewportHeight,
|
||||
visibleUsableBottom,
|
||||
overflowsUsableViewport: turnHeight > usableViewportHeight,
|
||||
targetScrollToRevealEnd,
|
||||
scrollDeltaToRevealEnd,
|
||||
};
|
||||
};
|
||||
|
||||
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
|
||||
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
|
||||
// follow while the user had genuinely scrolled away, yanking them back on the
|
||||
// next stream chunk. Distance is measured against the full content length —
|
||||
// reserved anchored end space included — so a parked anchored turn counts as
|
||||
// the live edge.
|
||||
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
|
||||
|
||||
export const resolveTimelineIsAtEnd = (
|
||||
state: {
|
||||
readonly contentLength?: number;
|
||||
readonly scroll?: number;
|
||||
readonly scrollLength?: number;
|
||||
readonly isNearEnd?: boolean;
|
||||
readonly isAtEnd?: boolean;
|
||||
} | undefined,
|
||||
): boolean | undefined => {
|
||||
if (!state) return undefined;
|
||||
const { contentLength, scroll, scrollLength } = state;
|
||||
if (
|
||||
typeof contentLength === 'number'
|
||||
&& typeof scroll === 'number'
|
||||
&& typeof scrollLength === 'number'
|
||||
&& Number.isFinite(contentLength)
|
||||
) {
|
||||
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
|
||||
}
|
||||
return state.isNearEnd ?? state.isAtEnd;
|
||||
};
|
||||
|
||||
export interface ChatListAnchoredEndSpace {
|
||||
readonly anchorIndex: number;
|
||||
readonly anchorOffset: number;
|
||||
}
|
||||
|
||||
// Finds the anchored row from the BACK of the list: a retried or re-sent
|
||||
// message id can appear more than once, and the live one is always the last.
|
||||
export const resolveChatListAnchoredEndSpace = <Item, AnchorId>(
|
||||
items: readonly Item[],
|
||||
anchorId: AnchorId | null,
|
||||
getAnchorId: (item: Item) => AnchorId | null,
|
||||
options: { readonly anchorOffset?: number } = {},
|
||||
): ChatListAnchoredEndSpace | undefined => {
|
||||
if (anchorId === null) return undefined;
|
||||
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (item !== undefined && getAnchorId(item) === anchorId) {
|
||||
return {
|
||||
anchorIndex: index,
|
||||
anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { isFollowReleaseKey, isMiddleButtonPan, nestedScrollableConsumesWheelUp } from './timelineScrollIntent';
|
||||
|
||||
const key = (
|
||||
k: string,
|
||||
modifiers: Partial<Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>> = {},
|
||||
) => ({ key: k, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers });
|
||||
|
||||
describe('isFollowReleaseKey', () => {
|
||||
test('upward navigation keys release follow', () => {
|
||||
for (const k of ['ArrowUp', 'PageUp', 'Home']) expect(isFollowReleaseKey(key(k))).toBe(true);
|
||||
expect(isFollowReleaseKey(key(' ', { shiftKey: true }))).toBe(true);
|
||||
});
|
||||
|
||||
test('downward keys, plain space, and modified shortcuts do not', () => {
|
||||
for (const k of ['ArrowDown', 'PageDown', 'End', ' ', 'Pause', 'Enter']) {
|
||||
expect(isFollowReleaseKey(key(k))).toBe(false);
|
||||
}
|
||||
expect(isFollowReleaseKey(key('Home', { ctrlKey: true }))).toBe(false);
|
||||
expect(isFollowReleaseKey(key('ArrowUp', { metaKey: true }))).toBe(false);
|
||||
expect(isFollowReleaseKey(key('ArrowUp', { altKey: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The helpers only use Element#closest, scrollTop, and identity, so a minimal
|
||||
// DOM stand-in built on EventTarget is enough — no renderer or jsdom.
|
||||
class FakeElement extends EventTarget {
|
||||
scrollTop = 0;
|
||||
constructor(private readonly scrollable: boolean, private readonly parent: FakeElement | null = null) {
|
||||
super();
|
||||
}
|
||||
closest(selector: string): FakeElement | null {
|
||||
if (selector !== '[data-scrollable]') throw new Error(`unexpected selector ${selector}`);
|
||||
if (this.scrollable) return this;
|
||||
return this.parent?.closest(selector) ?? null;
|
||||
}
|
||||
}
|
||||
// SAFETY: the helpers narrow with `instanceof Element` / `instanceof HTMLElement`;
|
||||
// registering the fakes under those globals keeps the narrowing honest in bun.
|
||||
const installDomGlobals = () => {
|
||||
const previous = { Element: globalThis.Element, HTMLElement: globalThis.HTMLElement };
|
||||
Object.assign(globalThis, { Element: FakeElement, HTMLElement: FakeElement });
|
||||
return () => Object.assign(globalThis, previous);
|
||||
};
|
||||
// With the globals above installed, FakeElement IS the HTMLElement the helpers
|
||||
// narrow to; reading it back through the global bridges the static type without
|
||||
// asserting anything the runtime does not hold.
|
||||
const asRoot = (element: FakeElement): HTMLElement => {
|
||||
if (!(element instanceof globalThis.HTMLElement)) throw new Error('DOM globals not installed');
|
||||
return element;
|
||||
};
|
||||
|
||||
describe('nested scroller handling', () => {
|
||||
test('an upward wheel over a nested scroller with room above stays there', () => {
|
||||
const restore = installDomGlobals();
|
||||
try {
|
||||
const root = new FakeElement(false);
|
||||
const box = new FakeElement(true, root);
|
||||
const inner = new FakeElement(false, box);
|
||||
box.scrollTop = 40;
|
||||
expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(true);
|
||||
box.scrollTop = 0;
|
||||
expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(false);
|
||||
expect(nestedScrollableConsumesWheelUp(asRoot(root), new FakeElement(false, root))).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a middle-button press pans the timeline unless it lands in a nested scroller', () => {
|
||||
const restore = installDomGlobals();
|
||||
try {
|
||||
const root = new FakeElement(false);
|
||||
const row = new FakeElement(false, root);
|
||||
const box = new FakeElement(true, root);
|
||||
expect(isMiddleButtonPan(asRoot(root), { button: 1, target: row })).toBe(true);
|
||||
expect(isMiddleButtonPan(asRoot(root), { button: 1, target: box })).toBe(false);
|
||||
expect(isMiddleButtonPan(asRoot(root), { button: 0, target: row })).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Gesture classification for the chat timeline's follow opt-out.
|
||||
//
|
||||
// The timeline releases live follow on REAL upward gestures only. Wheel and
|
||||
// touch carry their direction; this module answers the same question for the
|
||||
// inputs that do not: which keys mean "scroll up", when a middle-button press
|
||||
// starts a pan, and when an upward wheel belongs to a nested scroller (a tool
|
||||
// output box) that can still consume it. Pure functions, no DOM ownership,
|
||||
// so the rules are testable without a renderer.
|
||||
|
||||
// A nested scroller inside the timeline marks itself with this attribute
|
||||
// (see ToolPart). Wheel-up over it scrolls the box, not the conversation, for
|
||||
// as long as the box has room above.
|
||||
const NESTED_SCROLLABLE_SELECTOR = '[data-scrollable]';
|
||||
|
||||
export const isFollowReleaseKey = (
|
||||
event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
|
||||
): boolean => {
|
||||
// Modified keys are shortcuts, not navigation.
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return false;
|
||||
if (event.key === ' ') return event.shiftKey;
|
||||
return event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home';
|
||||
};
|
||||
|
||||
const nestedScrollable = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => {
|
||||
if (!(target instanceof Element)) return null;
|
||||
const nested = target.closest(NESTED_SCROLLABLE_SELECTOR);
|
||||
return nested instanceof HTMLElement && nested !== root ? nested : null;
|
||||
};
|
||||
|
||||
// An upward wheel over a nested scroller that still has content above stays
|
||||
// with that scroller; the timeline must not treat it as leaving the end.
|
||||
export const nestedScrollableConsumesWheelUp = (root: HTMLElement, target: EventTarget | null): boolean => {
|
||||
const nested = nestedScrollable(root, target);
|
||||
return nested !== null && nested.scrollTop > 0;
|
||||
};
|
||||
|
||||
// Middle-button press starts the platform's autoscroll pan (Windows/Linux
|
||||
// Chromium); the pan then scrolls without wheel events, so the press itself is
|
||||
// the gesture. Inside a nested scroller the pan belongs to that scroller.
|
||||
export const isMiddleButtonPan = (root: HTMLElement, event: Pick<MouseEvent, 'button' | 'target'>): boolean =>
|
||||
event.button === 1 && nestedScrollable(root, event.target) === null;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { commitStreamedText } from './streamTextCommit';
|
||||
|
||||
describe('commitStreamedText', () => {
|
||||
test('holds an incomplete short paragraph entirely', () => {
|
||||
expect(commitStreamedText('An unfinished thought abo')).toBe('');
|
||||
});
|
||||
|
||||
test('commits up to the last complete line', () => {
|
||||
expect(commitStreamedText('First paragraph.\n\nSecond par')).toBe('First paragraph.\n\n');
|
||||
});
|
||||
|
||||
test('reveals code fences line by line', () => {
|
||||
const text = '```py\nprint("a")\nprint("b';
|
||||
expect(commitStreamedText(text)).toBe('```py\nprint("a")\n');
|
||||
});
|
||||
|
||||
test('releases a long held paragraph at the last sentence boundary', () => {
|
||||
const sentence = 'A finished sentence lives here. ';
|
||||
const text = sentence.repeat(12) + 'and an unfinished trail';
|
||||
expect(commitStreamedText(text)).toBe(sentence.repeat(12));
|
||||
});
|
||||
|
||||
test('falls back to the last word boundary without sentences', () => {
|
||||
const words = 'word '.repeat(70);
|
||||
const text = words + 'unfinishe';
|
||||
expect(commitStreamedText(text)).toBe(words);
|
||||
});
|
||||
|
||||
test('keeps unbreakable runs intact rather than splitting them', () => {
|
||||
const run = 'x'.repeat(400);
|
||||
expect(commitStreamedText(run)).toBe(run);
|
||||
});
|
||||
|
||||
test('empty input stays empty', () => {
|
||||
expect(commitStreamedText('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// Block-level streaming reveal.
|
||||
//
|
||||
// Token-by-token streaming mutates the trailing paragraph in place on every
|
||||
// tick: words rewrap, the last line jitters, and the reader's eye fights the
|
||||
// motion. Committing only up to the last COMPLETE line keeps every rendered
|
||||
// block immutable once it appears — prose arrives a paragraph at a time (a
|
||||
// markdown paragraph is one logical line), code fences reveal line by line,
|
||||
// tables row by row — and the only remaining motion is the follow scroll.
|
||||
//
|
||||
// A paragraph with no newline for a long stretch must not stall the stream,
|
||||
// so once the held tail outgrows a threshold it is committed at the last
|
||||
// sentence boundary (falling back to the last word boundary).
|
||||
|
||||
const HOLD_MAX_CHARS = 320;
|
||||
|
||||
const SENTENCE_END = /[.!?…][)"'»”’]?\s/g;
|
||||
|
||||
export const commitStreamedText = (text: string): string => {
|
||||
if (text.length === 0) return text;
|
||||
|
||||
const lastNewline = text.lastIndexOf('\n');
|
||||
const committed = lastNewline === -1 ? '' : text.slice(0, lastNewline + 1);
|
||||
const held = text.slice(committed.length);
|
||||
|
||||
if (held.length <= HOLD_MAX_CHARS) {
|
||||
return committed;
|
||||
}
|
||||
|
||||
// The held paragraph got long: release it up to the last finished
|
||||
// sentence so the block still never mutates mid-sentence.
|
||||
let lastSentenceEnd = -1;
|
||||
for (const match of held.matchAll(SENTENCE_END)) {
|
||||
lastSentenceEnd = match.index + match[0].length;
|
||||
}
|
||||
if (lastSentenceEnd > 0) {
|
||||
return committed + held.slice(0, lastSentenceEnd);
|
||||
}
|
||||
|
||||
// No sentence boundary either (a URL, a very long token run): release up
|
||||
// to the last word boundary, keeping only the incomplete word held.
|
||||
const lastSpace = held.lastIndexOf(' ');
|
||||
if (lastSpace > 0) {
|
||||
return committed + held.slice(0, lastSpace + 1);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
@@ -64,8 +64,7 @@ describe('buildLiveStreamingEntry', () => {
|
||||
const entry = turnEntry(assistant);
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId: 'assistant_other',
|
||||
liveParts: [textPart('part_live', 'live')],
|
||||
livePartsByMessageId: { assistant_other: [textPart('part_live', 'live')] },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
@@ -79,8 +78,7 @@ describe('buildLiveStreamingEntry', () => {
|
||||
const liveParts = [reasoningPart('part_1_live', 'thinking')];
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId: 'assistant_1',
|
||||
liveParts,
|
||||
livePartsByMessageId: { assistant_1: liveParts },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
@@ -102,8 +100,7 @@ describe('buildLiveStreamingEntry', () => {
|
||||
const liveParts = [textPart('part_1_live', 'live')];
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId: 'assistant_1',
|
||||
liveParts,
|
||||
livePartsByMessageId: { assistant_1: liveParts },
|
||||
showTextJustificationActivity: false,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
@@ -121,8 +118,7 @@ describe('buildLiveStreamingEntry', () => {
|
||||
const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId: 'assistant_1',
|
||||
liveParts: [synthetic, visible],
|
||||
livePartsByMessageId: { assistant_1: [synthetic, visible] },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
@@ -131,4 +127,39 @@ describe('buildLiveStreamingEntry', () => {
|
||||
if (next.kind !== 'turn') return;
|
||||
expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
|
||||
});
|
||||
|
||||
test('keeps a finished step message on its live parts after the stream moves on', () => {
|
||||
const finished = message('assistant_1', 'assistant', 'user_1', []);
|
||||
const streaming = message('assistant_2', 'assistant', 'user_1', []);
|
||||
const entry = turnEntry(finished);
|
||||
if (entry.kind !== 'turn') return;
|
||||
entry.turn.assistantMessageIds = ['assistant_1', 'assistant_2'];
|
||||
entry.turn.assistantMessages = [finished, streaming];
|
||||
const finishedLive = [textPart('part_tool_done', 'tool output')];
|
||||
const streamingLive = [textPart('part_streaming', 'streaming')];
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
livePartsByMessageId: { assistant_1: finishedLive, assistant_2: streamingLive },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
|
||||
expect(next.kind).toBe('turn');
|
||||
if (next.kind !== 'turn') return;
|
||||
expect(next.turn.assistantMessages[0]?.parts).toEqual(finishedLive);
|
||||
expect(next.turn.assistantMessages[1]?.parts).toEqual(streamingLive);
|
||||
});
|
||||
|
||||
test('never erases record parts with an empty live array', () => {
|
||||
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'kept')]);
|
||||
const entry = turnEntry(assistant);
|
||||
|
||||
const next = buildLiveStreamingEntry(entry, {
|
||||
livePartsByMessageId: { assistant_1: [] },
|
||||
showTextJustificationActivity: true,
|
||||
showTurnChangedFiles: false,
|
||||
});
|
||||
|
||||
expect(next).toBe(entry);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,13 @@ export type StreamingTailEntry =
|
||||
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
|
||||
|
||||
type BuildLiveStreamingEntryOptions = {
|
||||
activeStreamingMessageId: string | null | undefined;
|
||||
liveParts: Part[];
|
||||
// Live parts for EVERY message of the streaming tail, not only the one
|
||||
// currently streaming: when the stream moves to the next step message, the
|
||||
// previous message's base record can still lag behind the part store, and
|
||||
// rendering it from that stale snapshot briefly drops its completed tool
|
||||
// parts — remounting them (and replaying their reveal animation) once the
|
||||
// record catches up.
|
||||
livePartsByMessageId: Readonly<Record<string, Part[]>>;
|
||||
showTextJustificationActivity: boolean;
|
||||
showTurnChangedFiles: boolean;
|
||||
mergeHiddenUserTurns?: { planModeEnabled: boolean };
|
||||
@@ -24,10 +29,12 @@ type BuildLiveStreamingEntryOptions = {
|
||||
|
||||
const withLiveParts = (
|
||||
message: ChatMessageEntry,
|
||||
activeStreamingMessageId: string,
|
||||
liveParts: Part[],
|
||||
livePartsByMessageId: Readonly<Record<string, Part[]>>,
|
||||
): ChatMessageEntry => {
|
||||
if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) {
|
||||
const liveParts = livePartsByMessageId[message.info.id];
|
||||
// An empty live array is ambiguous — the store may simply not have loaded
|
||||
// this message's parts — and must never erase parts the record does have.
|
||||
if (!liveParts || liveParts.length === 0 || message.parts === liveParts) {
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -41,13 +48,10 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
|
||||
entry: TEntry,
|
||||
options: BuildLiveStreamingEntryOptions,
|
||||
): TEntry => {
|
||||
const activeStreamingMessageId = options.activeStreamingMessageId;
|
||||
if (!activeStreamingMessageId) {
|
||||
return entry;
|
||||
}
|
||||
const livePartsByMessageId = options.livePartsByMessageId;
|
||||
|
||||
if (entry.kind === 'ungrouped') {
|
||||
const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts);
|
||||
const message = withLiveParts(entry.message, livePartsByMessageId);
|
||||
if (message === entry.message) {
|
||||
return entry;
|
||||
}
|
||||
@@ -59,7 +63,7 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
|
||||
|
||||
let changed = false;
|
||||
const assistantMessages = entry.turn.assistantMessages.map((message) => {
|
||||
const next = withLiveParts(message, activeStreamingMessageId, options.liveParts);
|
||||
const next = withLiveParts(message, livePartsByMessageId);
|
||||
if (next !== message) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -43,27 +43,42 @@ export type DecorateContext = {
|
||||
onPreviewLoopback?: (url: string) => void;
|
||||
};
|
||||
|
||||
// Reference the app's icon sprite (injected into <body> by the shared Icon
|
||||
// component) so DOM-built controls use the same themed icons as the rest of
|
||||
// the app. Sprite symbols are registered under `#oc-<name>`.
|
||||
const spriteIcon = (name: IconName): string =>
|
||||
`<svg class="remixicon size-3.5" viewBox="0 0 24 24" aria-hidden="true"><use href="#oc-${name}"></use></svg>`;
|
||||
|
||||
const ICONS = {
|
||||
copy: spriteIcon('file-copy'),
|
||||
check: spriteIcon('check'),
|
||||
download: spriteIcon('download'),
|
||||
zoomIn: spriteIcon('add'),
|
||||
zoomOut: spriteIcon('subtract'),
|
||||
fit: spriteIcon('refresh'),
|
||||
textWrap: spriteIcon('text-wrap'),
|
||||
} as const;
|
||||
copy: 'file-copy',
|
||||
check: 'check',
|
||||
download: 'download',
|
||||
zoomIn: 'add',
|
||||
zoomOut: 'subtract',
|
||||
fit: 'refresh',
|
||||
textWrap: 'text-wrap',
|
||||
image: 'file-image',
|
||||
} as const satisfies Record<string, IconName>;
|
||||
|
||||
const ICON_BTN_CLASS =
|
||||
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--interactive-focus-ring)]';
|
||||
|
||||
const setIconHtml = (el: Element, html: string): void => {
|
||||
el.innerHTML = html;
|
||||
const setIcon = (el: Element, icon: keyof typeof ICONS): void => {
|
||||
const iconName = ICONS[icon];
|
||||
const svg = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', 'remixicon size-3.5');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
const use = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use.setAttribute('href', `#oc-${iconName}`);
|
||||
svg.appendChild(use);
|
||||
el.replaceChildren(svg);
|
||||
};
|
||||
|
||||
const decorateImageLabels = (root: HTMLElement): void => {
|
||||
for (const label of Array.from(root.querySelectorAll<HTMLElement>('[data-openchamber-markdown-image-label="true"]'))) {
|
||||
if (label.querySelector('[data-openchamber-markdown-image-label-icon]')) continue;
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'inline-flex shrink-0';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true');
|
||||
setIcon(icon, 'image');
|
||||
label.prepend(icon);
|
||||
}
|
||||
};
|
||||
|
||||
const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string): HTMLButtonElement => {
|
||||
@@ -73,7 +88,7 @@ const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string):
|
||||
button.setAttribute('data-md-action', slot);
|
||||
button.setAttribute('title', title);
|
||||
button.setAttribute('aria-label', title);
|
||||
setIconHtml(button, ICONS[icon]);
|
||||
setIcon(button, icon);
|
||||
return button;
|
||||
};
|
||||
|
||||
@@ -118,6 +133,9 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
const code = pre.querySelector<HTMLElement>(':scope > code');
|
||||
if (!code || code.hasAttribute('data-md-code-lines')) return;
|
||||
|
||||
// The real gutter takes over the reserved footprint.
|
||||
pre.removeAttribute('data-md-gutter-reserved');
|
||||
|
||||
const text = code.textContent ?? '';
|
||||
const hasTrailingNewline = text.endsWith('\n');
|
||||
const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n');
|
||||
@@ -138,9 +156,8 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
row.setAttribute('data-md-code-line', '');
|
||||
|
||||
const number = document.createElement('span');
|
||||
number.setAttribute('data-md-code-line-number', '');
|
||||
number.setAttribute('data-md-code-line-number', String(index + 1));
|
||||
number.setAttribute('aria-hidden', 'true');
|
||||
number.textContent = String(index + 1);
|
||||
|
||||
const content = document.createElement('span');
|
||||
content.setAttribute('data-md-code-line-content', '');
|
||||
@@ -150,7 +167,6 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
} else {
|
||||
content.textContent = sourceLine;
|
||||
}
|
||||
|
||||
row.append(number, content);
|
||||
fragment.appendChild(row);
|
||||
if (index < sourceLines.length - 1 || hasTrailingNewline) {
|
||||
@@ -183,11 +199,11 @@ export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: bool
|
||||
};
|
||||
|
||||
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
|
||||
setIconHtml(button, ICONS.check);
|
||||
setIcon(button, 'check');
|
||||
button.setAttribute('title', copiedTitle);
|
||||
button.setAttribute('aria-label', copiedTitle);
|
||||
window.setTimeout(() => {
|
||||
setIconHtml(button, ICONS[restore]);
|
||||
setIcon(button, restore);
|
||||
button.setAttribute('title', restoreTitle);
|
||||
button.setAttribute('aria-label', restoreTitle);
|
||||
}, 2000);
|
||||
@@ -250,7 +266,15 @@ const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
pre.style.margin = '0';
|
||||
pre.style.background = 'transparent';
|
||||
pre.classList.add('min-w-0', 'w-full', 'flex-1');
|
||||
if (!ctx.deferCodeLineNumberSync) layoutCodeLines(pre);
|
||||
if (!ctx.deferCodeLineNumberSync) {
|
||||
layoutCodeLines(pre);
|
||||
} else {
|
||||
// Streaming defers the per-line gutter markup, but the gutter's
|
||||
// horizontal footprint is reserved immediately — otherwise the
|
||||
// end-of-stream decorate pass shifts every code line right by the
|
||||
// gutter column and the finished message visibly jumps.
|
||||
pre.setAttribute('data-md-gutter-reserved', '');
|
||||
}
|
||||
body.appendChild(pre);
|
||||
wrapper.appendChild(header);
|
||||
wrapper.appendChild(body);
|
||||
@@ -479,7 +503,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
preview.setAttribute('data-md-url', href);
|
||||
preview.setAttribute('title', ctx.labels.previewTitle);
|
||||
preview.setAttribute('aria-label', ctx.labels.previewLabel);
|
||||
setIconHtml(preview, ICONS.download);
|
||||
setIcon(preview, 'download');
|
||||
anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
|
||||
}
|
||||
}
|
||||
@@ -487,6 +511,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
|
||||
/** Run all idempotent DOM decoration passes over freshly-rendered markdown. */
|
||||
export const decorateMarkdown = (root: HTMLElement, ctx: DecorateContext): void => {
|
||||
decorateImageLabels(root);
|
||||
decorateInlineCode(root);
|
||||
decorateMermaid(root, ctx);
|
||||
decorateCodeBlocks(root, ctx);
|
||||
@@ -516,6 +541,67 @@ const closeAllMenus = (container: HTMLElement): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const getContainingMarkdownCode = (node: Node): HTMLElement | null => {
|
||||
const element = node.nodeType === 1 ? node as Element : node.parentElement;
|
||||
return element?.closest<HTMLElement>('pre code[data-md-code-lines]') ?? null;
|
||||
};
|
||||
|
||||
const getMarkdownCodeSelectionText = (range: Range): string | null => {
|
||||
const code = getContainingMarkdownCode(range.startContainer);
|
||||
if (!code || code !== getContainingMarkdownCode(range.endContainer)) return null;
|
||||
// Line numbers are CSS-generated, so the DOM range is already the exact
|
||||
// source selection, including boundaries between rows and empty lines.
|
||||
return range.toString();
|
||||
};
|
||||
|
||||
type MarkdownCopyState = {
|
||||
registrations: number;
|
||||
handler: (event: ClipboardEvent) => void;
|
||||
menuHandler: (event: Event) => void;
|
||||
};
|
||||
|
||||
const markdownCopyStates = new WeakMap<Document, MarkdownCopyState>();
|
||||
|
||||
const registerMarkdownCodeCopy = (doc: Document): (() => void) => {
|
||||
let state = markdownCopyStates.get(doc);
|
||||
if (!state) {
|
||||
const getSelectedText = (): string | null => {
|
||||
const selection = doc.getSelection();
|
||||
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
|
||||
return getMarkdownCodeSelectionText(selection.getRangeAt(0));
|
||||
};
|
||||
const handler = (event: ClipboardEvent) => {
|
||||
if (!event.clipboardData) return;
|
||||
const text = getSelectedText();
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.clipboardData.setData('text/plain', text);
|
||||
};
|
||||
const menuHandler = (event: Event) => {
|
||||
const text = getSelectedText();
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
void copyTextToClipboard(text);
|
||||
};
|
||||
state = { registrations: 0, handler, menuHandler };
|
||||
markdownCopyStates.set(doc, state);
|
||||
doc.addEventListener('copy', handler, true);
|
||||
doc.defaultView?.addEventListener('openchamber:copy', menuHandler);
|
||||
}
|
||||
state.registrations += 1;
|
||||
|
||||
return () => {
|
||||
const current = markdownCopyStates.get(doc);
|
||||
if (!current) return;
|
||||
current.registrations -= 1;
|
||||
if (current.registrations > 0) return;
|
||||
doc.removeEventListener('copy', current.handler, true);
|
||||
doc.defaultView?.removeEventListener('openchamber:copy', current.menuHandler);
|
||||
markdownCopyStates.delete(doc);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach a single delegated click listener for all in-markdown actions: code
|
||||
* copy, table copy/download menus, mermaid copy/download, loopback preview.
|
||||
@@ -525,6 +611,7 @@ export const attachMarkdownInteractions = (
|
||||
container: HTMLElement,
|
||||
ctx: DecorateContext,
|
||||
): (() => void) => {
|
||||
const unregisterCodeCopy = registerMarkdownCodeCopy(container.ownerDocument);
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
@@ -540,7 +627,12 @@ export const attachMarkdownInteractions = (
|
||||
if (action === 'copy-code') {
|
||||
const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code');
|
||||
const text = code ? getMarkdownCodeText(code) : '';
|
||||
if (text) void copyTextToClipboard(text).then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy));
|
||||
if (text) {
|
||||
actionEl.setAttribute('data-md-copy-pending', '');
|
||||
void copyTextToClipboard(text)
|
||||
.then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy))
|
||||
.finally(() => actionEl.removeAttribute('data-md-copy-pending'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -626,5 +718,8 @@ export const attachMarkdownInteractions = (
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
return () => container.removeEventListener('click', handleClick);
|
||||
return () => {
|
||||
unregisterCodeCopy();
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
import { DetachedMarkdownDomCache, type DetachedMarkdownDom } from './detachedMarkdownDomCache';
|
||||
|
||||
Object.assign(globalThis, { document: new Window().document });
|
||||
|
||||
const keyFor = ({ scope, id, locale, directory }: DetachedMarkdownDom) => ({ scope, id, locale, directory });
|
||||
|
||||
const createEntry = (
|
||||
document: Document,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
partId: string,
|
||||
): DetachedMarkdownDom => {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const node = document.createElement('p');
|
||||
node.textContent = `${messageId}:${partId}`;
|
||||
fragment.appendChild(node);
|
||||
return {
|
||||
scope: `runtime:${sessionId}`,
|
||||
id: `${messageId}:${partId}`,
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
fragment,
|
||||
};
|
||||
};
|
||||
|
||||
describe('DetachedMarkdownDomCache', () => {
|
||||
test('consumes the original DOM fragment once and rejects another locale', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const entry = createEntry(document, 'session-a', 'message-a', 'part-a');
|
||||
const originalNode = entry.fragment.firstChild;
|
||||
|
||||
cache.store(entry);
|
||||
expect(cache.take({ ...keyFor(entry), locale: 'zh' })).toBeNull();
|
||||
cache.store(entry);
|
||||
const restored = cache.take(keyFor(entry));
|
||||
expect(restored?.firstChild).toBe(originalNode);
|
||||
expect(cache.take(keyFor(entry))).toBeNull();
|
||||
});
|
||||
|
||||
test('bounds entries per session and evicts the least recently used session', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
cache.store(createEntry(document, 'session-a', 'message-1', 'part'));
|
||||
cache.store(createEntry(document, 'session-a', 'message-2', 'part'));
|
||||
cache.store(createEntry(document, 'session-a', 'message-3', 'part'));
|
||||
cache.store(createEntry(document, 'session-b', 'message-4', 'part'));
|
||||
cache.store(createEntry(document, 'session-c', 'message-5', 'part'));
|
||||
expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
|
||||
expect(cache.take({
|
||||
scope: 'runtime:session-a',
|
||||
id: 'message-2:part',
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
})).toBeNull();
|
||||
expect(cache.take({
|
||||
scope: 'runtime:session-c',
|
||||
id: 'message-5:part',
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
})).not.toBeNull();
|
||||
});
|
||||
|
||||
test('isolates identities by runtime and replaces an identity without growing stats', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const first = createEntry(document, 'session', 'message', 'part');
|
||||
const replacement = createEntry(document, 'session', 'message', 'part');
|
||||
const replacementNode = replacement.fragment.firstChild;
|
||||
const otherRuntime = createEntry(document, 'other-runtime-session', 'message', 'part');
|
||||
|
||||
cache.store(first);
|
||||
cache.store(replacement);
|
||||
cache.store(otherRuntime);
|
||||
|
||||
expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
|
||||
expect(cache.take(keyFor(otherRuntime))).not.toBeNull();
|
||||
expect(cache.take(keyFor(replacement))?.firstChild).toBe(replacementNode);
|
||||
});
|
||||
|
||||
test('does not restore file-link DOM under another directory', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const entry = createEntry(document, 'session', 'message', 'part');
|
||||
|
||||
cache.store(entry);
|
||||
|
||||
expect(cache.take({ ...keyFor(entry), directory: '/repo-b' })).toBeNull();
|
||||
});
|
||||
|
||||
test('refreshes session LRU and clears all entries', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const sessionA = createEntry(document, 'session-a', 'message-a', 'part');
|
||||
const sessionB = createEntry(document, 'session-b', 'message-b', 'part');
|
||||
const sessionC = createEntry(document, 'session-c', 'message-c', 'part');
|
||||
cache.store(sessionA);
|
||||
cache.store(sessionB);
|
||||
cache.store(sessionA);
|
||||
cache.store(sessionC);
|
||||
expect(cache.take(keyFor(sessionB))).toBeNull();
|
||||
|
||||
cache.clear();
|
||||
expect(cache.stats()).toEqual({ sessions: 0, entries: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
export type DetachedMarkdownDomKey = {
|
||||
scope: string;
|
||||
id: string;
|
||||
locale: string;
|
||||
directory: string;
|
||||
};
|
||||
|
||||
export type DetachedMarkdownDom = DetachedMarkdownDomKey & {
|
||||
// The fragment owns the original nodes. take() consumes it once by moving
|
||||
// those nodes back into a renderer; nothing is cloned or serialized.
|
||||
fragment: DocumentFragment;
|
||||
};
|
||||
|
||||
export type DetachedMarkdownDomCacheStats = {
|
||||
sessions: number;
|
||||
entries: number;
|
||||
};
|
||||
|
||||
// Holds detached, fully decorated Markdown DOM. The cache is intentionally
|
||||
// small: it accelerates recent-session and reverse-scroll remounts without
|
||||
// retaining whole session trees or depending on browser-specific byte guesses.
|
||||
type DetachedMarkdownDomCacheLimits = {
|
||||
maxSessions: number;
|
||||
maxEntriesPerSession: number;
|
||||
};
|
||||
|
||||
type SessionCache = Map<string, DetachedMarkdownDom>;
|
||||
|
||||
const DEFAULT_LIMITS: DetachedMarkdownDomCacheLimits = {
|
||||
// Eight buckets cover a broader recent-session working set without
|
||||
// coupling eviction to React commit or microtask timing.
|
||||
maxSessions: 8,
|
||||
maxEntriesPerSession: 4,
|
||||
};
|
||||
|
||||
export class DetachedMarkdownDomCache {
|
||||
private readonly maxSessions: number;
|
||||
private readonly maxEntriesPerSession: number;
|
||||
private readonly sessions = new Map<string, SessionCache>();
|
||||
|
||||
constructor(limits: DetachedMarkdownDomCacheLimits = DEFAULT_LIMITS) {
|
||||
this.maxSessions = Math.max(1, limits.maxSessions);
|
||||
this.maxEntriesPerSession = Math.max(1, limits.maxEntriesPerSession);
|
||||
}
|
||||
|
||||
store(entry: DetachedMarkdownDom): void {
|
||||
const sessionKey = entry.scope;
|
||||
const entryKey = entry.id;
|
||||
|
||||
let session = this.sessions.get(sessionKey);
|
||||
if (session === undefined) {
|
||||
session = new Map();
|
||||
this.sessions.set(sessionKey, session);
|
||||
} else {
|
||||
this.refreshSession(sessionKey, session);
|
||||
}
|
||||
|
||||
// A part has one DOM version inside its authoritative runtime/session.
|
||||
session.delete(entryKey);
|
||||
session.set(entryKey, entry);
|
||||
|
||||
while (session.size > this.maxEntriesPerSession) {
|
||||
this.removeOldestEntry(session);
|
||||
}
|
||||
while (this.sessions.size > this.maxSessions) {
|
||||
this.removeOldestSession();
|
||||
}
|
||||
}
|
||||
|
||||
take(key: DetachedMarkdownDomKey): DocumentFragment | null {
|
||||
const sessionKey = key.scope;
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return null;
|
||||
const entryKey = key.id;
|
||||
|
||||
this.refreshSession(sessionKey, session);
|
||||
const entry = session.get(entryKey);
|
||||
if (entry === undefined) return null;
|
||||
|
||||
// A mismatched probe (different locale or directory for the same part)
|
||||
// must not destroy the entry — the matching renderer may still come for
|
||||
// it. Only a real hit transfers ownership out of the cache.
|
||||
if (entry.locale !== key.locale || entry.directory !== key.directory) return null;
|
||||
|
||||
// A fragment is a move-only resource; taking it removes cache ownership.
|
||||
session.delete(entryKey);
|
||||
if (session.size === 0) this.sessions.delete(sessionKey);
|
||||
return entry.fragment;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
stats(): DetachedMarkdownDomCacheStats {
|
||||
let entries = 0;
|
||||
for (const session of this.sessions.values()) {
|
||||
entries += session.size;
|
||||
}
|
||||
return {
|
||||
sessions: this.sessions.size,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
private refreshSession(sessionKey: string, session: SessionCache): void {
|
||||
this.sessions.delete(sessionKey);
|
||||
this.sessions.set(sessionKey, session);
|
||||
}
|
||||
|
||||
private removeOldestEntry(session: SessionCache): void {
|
||||
const oldestKey = session.keys().next().value;
|
||||
if (oldestKey === undefined) return;
|
||||
session.delete(oldestKey);
|
||||
}
|
||||
|
||||
private removeOldestSession(): void {
|
||||
const oldestKey = this.sessions.keys().next().value;
|
||||
if (oldestKey === undefined) return;
|
||||
this.sessions.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
export const detachedMarkdownDomCache = new DetachedMarkdownDomCache();
|
||||
@@ -0,0 +1,125 @@
|
||||
// Bounded LRU for rendered markdown / Shiki highlight results.
|
||||
//
|
||||
// Used by `markdownCore` (per-block HTML) and by the main-thread markdown
|
||||
// worker client (highlight results) so unchanged content is never re-rendered
|
||||
// or re-tokenized. Keys are short content fingerprints (not the full source) so
|
||||
// cache maps do not duplicate large strings. Entry byte sizes are recorded once
|
||||
// at insert time — get/evict never re-walk the payload.
|
||||
|
||||
export type HighlightResultCacheOptions = {
|
||||
maxEntries: number;
|
||||
maxBytes: number;
|
||||
};
|
||||
|
||||
type CacheEntry<T> = {
|
||||
value: T;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
/** UTF-16 storage estimate for a JS string (chars × 2). Avoids TextEncoder allocs. */
|
||||
export const utf16Bytes = (value: string): number => value.length * 2;
|
||||
|
||||
/** Final avalanche so near-identical sources do not land in adjacent buckets. */
|
||||
const mix32 = (hash: number): number => {
|
||||
let h = hash;
|
||||
h ^= h >>> 16;
|
||||
h = Math.imul(h, 0x85ebca6b);
|
||||
h ^= h >>> 13;
|
||||
return h >>> 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Short stable fingerprint for cache keys: length + two independent 32-bit
|
||||
* multiplicative hashes (~64 bits of key space).
|
||||
*
|
||||
* These caches are content-addressed and global, so a collision does not merely
|
||||
* mis-color a block — the cache returns a *different* block's rendered HTML and
|
||||
* the user is shown source they never wrote. One 32-bit hash is not enough for
|
||||
* that failure mode: a few thousand same-length entries reach a birthday
|
||||
* collision probability worth caring about, and the result would be
|
||||
* undiagnosable in the field. Two multiplies per character are free next to
|
||||
* Shiki tokenization.
|
||||
*/
|
||||
export const contentFingerprint = (value: string): string => {
|
||||
let h1 = 0x811c9dc5;
|
||||
let h2 = 0xc2b2ae35;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const code = value.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ code, 0x01000193);
|
||||
h2 = Math.imul(h2 ^ code, 0x27220a95);
|
||||
}
|
||||
return `${value.length.toString(36)}_${mix32(h1).toString(36)}_${mix32(h2).toString(36)}`;
|
||||
};
|
||||
|
||||
/** Approximate byte cost of token-run lines without JSON.stringify. */
|
||||
export const estimateTokenRunsBytes = (
|
||||
lines: ReadonlyArray<ReadonlyArray<readonly [number, string, number]>>,
|
||||
): number => {
|
||||
let total = 0;
|
||||
for (const line of lines) {
|
||||
total += 4;
|
||||
for (const run of line) {
|
||||
total += 8 + utf16Bytes(run[1]);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
export class HighlightResultCache<T> {
|
||||
private readonly maxEntries: number;
|
||||
private readonly maxBytes: number;
|
||||
private readonly map = new Map<string, CacheEntry<T>>();
|
||||
private totalBytes = 0;
|
||||
|
||||
constructor(options: HighlightResultCacheOptions) {
|
||||
this.maxEntries = Math.max(1, options.maxEntries);
|
||||
this.maxBytes = Math.max(1, options.maxBytes);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
get bytes(): number {
|
||||
return this.totalBytes;
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
const entry = this.map.get(key);
|
||||
if (entry === undefined) return undefined;
|
||||
// Refresh LRU order without recomputing size.
|
||||
this.map.delete(key);
|
||||
this.map.set(key, entry);
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: T, bytes: number): void {
|
||||
const existing = this.map.get(key);
|
||||
if (existing !== undefined) {
|
||||
this.totalBytes -= existing.bytes;
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
const entryBytes = Math.max(0, bytes);
|
||||
while (
|
||||
this.map.size > 0
|
||||
&& (this.map.size >= this.maxEntries || this.totalBytes + entryBytes > this.maxBytes)
|
||||
) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
const oldestEntry = this.map.get(oldest);
|
||||
if (oldestEntry !== undefined) this.totalBytes -= oldestEntry.bytes;
|
||||
this.map.delete(oldest);
|
||||
// Always allow a single oversized entry so huge files still cache once.
|
||||
if (this.map.size === 0) break;
|
||||
}
|
||||
|
||||
this.map.set(key, { value, bytes: entryBytes });
|
||||
this.totalBytes += entryBytes;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
this.totalBytes = 0;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
|
||||
import { isVSCodeRuntime } from '@/stores/utils/vscodeRuntime';
|
||||
import {
|
||||
contentFingerprint,
|
||||
estimateTokenRunsBytes,
|
||||
HighlightResultCache,
|
||||
utf16Bytes,
|
||||
} from './highlightResultCache';
|
||||
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
|
||||
|
||||
// Main-thread client for the markdown Shiki worker. Moves syntax tokenization
|
||||
@@ -6,47 +13,117 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse }
|
||||
// ready-to-splice Shiki HTML. On any failure (no worker support, worker crash,
|
||||
// tokenization error) the promise resolves to `null` and the caller keeps the
|
||||
// escaped plain-text code — highlighting never falls back onto the main thread.
|
||||
//
|
||||
// Results are memoized by content fingerprint (+ lang / theme). Unchanged
|
||||
// content must not re-enter the worker — that was the sustained ~40 msg/s
|
||||
// re-highlight load in openchamber/openchamber#2769. In-flight requests with
|
||||
// the same key coalesce so remount storms share one round-trip. Cache keys are
|
||||
// fingerprints (not full source) so large files are not duplicated in the Map.
|
||||
//
|
||||
// This module is the only sender to the worker, so memoizing here is sufficient
|
||||
// and the worker itself stays stateless apart from the Shiki instance. A second
|
||||
// cache inside the worker would only duplicate these payloads in another heap.
|
||||
//
|
||||
// `highlight` / `highlightLines` results are theme-independent: the worker
|
||||
// tokenizes with the CSS-variable `MARKDOWN_SHIKI_THEME`, so a theme switch
|
||||
// repaints via CSS and must not invalidate these entries. Only
|
||||
// `highlightTokens` resolves concrete colors, so only its key carries a theme.
|
||||
|
||||
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
|
||||
|
||||
type CachedHighlight =
|
||||
| { type: 'highlight'; html: string }
|
||||
| { type: 'highlightLines'; lines: string[] }
|
||||
| { type: 'highlightTokens'; lines: MarkdownTokenRun[][] };
|
||||
|
||||
const CLIENT_CACHE_MAX_ENTRIES = 2000;
|
||||
const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
||||
|
||||
const resultCache = new HighlightResultCache<CachedHighlight>({
|
||||
maxEntries: CLIENT_CACHE_MAX_ENTRIES,
|
||||
maxBytes: CLIENT_CACHE_MAX_BYTES,
|
||||
});
|
||||
|
||||
const inflight = new Map<string, Promise<CachedHighlight | null>>();
|
||||
|
||||
let worker: Worker | undefined;
|
||||
let workerCreation: Promise<Worker | undefined> | undefined;
|
||||
let workerObjectUrl: string | undefined;
|
||||
let nextId = 0;
|
||||
const pending = new Map<number, PendingResolver>();
|
||||
// Theme names whose full definition we've already shipped to the live worker, so
|
||||
// repeat tokenization sends only the name (not the whole theme object) again.
|
||||
const sentThemes = new Set<string>();
|
||||
|
||||
const entryBytes = (key: string, value: CachedHighlight): number => {
|
||||
const keyBytes = utf16Bytes(key);
|
||||
if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html);
|
||||
if (value.type === 'highlightLines') {
|
||||
let total = keyBytes;
|
||||
for (const line of value.lines) total += utf16Bytes(line);
|
||||
return total;
|
||||
}
|
||||
return keyBytes + estimateTokenRunsBytes(value.lines);
|
||||
};
|
||||
|
||||
const failAll = (): void => {
|
||||
pending.forEach((resolve) => resolve(null));
|
||||
pending.clear();
|
||||
sentThemes.clear();
|
||||
// Drop in-flight waiters; cached results remain valid (pure fn of inputs).
|
||||
inflight.clear();
|
||||
worker?.terminate();
|
||||
worker = undefined;
|
||||
workerCreation = undefined;
|
||||
if (workerObjectUrl) {
|
||||
URL.revokeObjectURL(workerObjectUrl);
|
||||
workerObjectUrl = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getWorker = (): Worker | undefined => {
|
||||
if (worker) return worker;
|
||||
const createWorker = async (): Promise<Worker | undefined> => {
|
||||
if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined;
|
||||
try {
|
||||
worker = new Worker(MarkdownShikiWorkerUrl, { type: 'module' });
|
||||
let workerUrl = MarkdownShikiWorkerUrl;
|
||||
if (isVSCodeRuntime(null)) {
|
||||
const response = await fetch(workerUrl);
|
||||
if (!response.ok) throw new Error(`Shiki worker request failed with ${response.status}`);
|
||||
workerObjectUrl = URL.createObjectURL(await response.blob());
|
||||
workerUrl = workerObjectUrl;
|
||||
}
|
||||
|
||||
const instance = new Worker(workerUrl, { type: 'module' });
|
||||
worker = instance;
|
||||
instance.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
const resolve = pending.get(event.data.id);
|
||||
if (!resolve) return;
|
||||
pending.delete(event.data.id);
|
||||
resolve(event.data);
|
||||
};
|
||||
instance.onerror = failAll;
|
||||
instance.onmessageerror = failAll;
|
||||
instance.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
|
||||
return instance;
|
||||
} catch (err) {
|
||||
if (workerObjectUrl) {
|
||||
URL.revokeObjectURL(workerObjectUrl);
|
||||
workerObjectUrl = undefined;
|
||||
}
|
||||
console.error('Failed to create Shiki worker:', err);
|
||||
return undefined;
|
||||
}
|
||||
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
const resolve = pending.get(event.data.id);
|
||||
if (!resolve) return;
|
||||
pending.delete(event.data.id);
|
||||
resolve(event.data);
|
||||
};
|
||||
worker.onerror = failAll;
|
||||
worker.onmessageerror = failAll;
|
||||
worker.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
|
||||
return worker;
|
||||
};
|
||||
|
||||
const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
|
||||
const instance = getWorker();
|
||||
const getWorker = async (): Promise<Worker | undefined> => {
|
||||
if (worker) return worker;
|
||||
workerCreation ??= createWorker().finally(() => {
|
||||
workerCreation = undefined;
|
||||
});
|
||||
return workerCreation;
|
||||
};
|
||||
|
||||
const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
|
||||
const instance = await getWorker();
|
||||
if (!instance) return Promise.resolve(null);
|
||||
const id = ++nextId;
|
||||
return new Promise<MarkdownWorkerResponse | null>((resolve) => {
|
||||
@@ -55,13 +132,47 @@ const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<Markdo
|
||||
});
|
||||
};
|
||||
|
||||
const coalesce = (
|
||||
key: string,
|
||||
run: () => Promise<CachedHighlight | null>,
|
||||
): Promise<CachedHighlight | null> => {
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
const pendingRequest = run().finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, pendingRequest);
|
||||
return pendingRequest;
|
||||
};
|
||||
|
||||
const cacheKeyFor = (kind: string, lang: string, code: string, themeName?: string): string => {
|
||||
const fp = contentFingerprint(code);
|
||||
return themeName === undefined ? `${kind}:${lang}:${fp}` : `${kind}:${themeName}:${lang}:${fp}`;
|
||||
};
|
||||
|
||||
/** Test-only: clear client-side highlight memoization. */
|
||||
export const resetMarkdownWorkerClientCacheForTests = (): void => {
|
||||
resultCache.clear();
|
||||
inflight.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlight a complete code block in the worker. Resolves to Shiki `<pre>` HTML,
|
||||
* or `null` if highlighting is unavailable or failed (caller keeps plain code).
|
||||
*/
|
||||
export const highlightCodeInWorker = async (code: string, lang: string): Promise<string | null> => {
|
||||
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
|
||||
return response?.type === 'highlight' ? response.html : null;
|
||||
const key = cacheKeyFor('highlight', lang, code);
|
||||
const cached = resultCache.get(key);
|
||||
if (cached?.type === 'highlight') return cached.html;
|
||||
|
||||
const result = await coalesce(key, async () => {
|
||||
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
|
||||
if (response?.type !== 'highlight') return null;
|
||||
const entry: CachedHighlight = { type: 'highlight', html: response.html };
|
||||
resultCache.set(key, entry, entryBytes(key, entry));
|
||||
return entry;
|
||||
});
|
||||
return result?.type === 'highlight' ? result.html : null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -70,8 +181,24 @@ export const highlightCodeInWorker = async (code: string, lang: string): Promise
|
||||
* round-trip instead of one per line. Resolves to `null` on failure.
|
||||
*/
|
||||
export const highlightLinesInWorker = async (code: string, lang: string): Promise<string[] | null> => {
|
||||
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
|
||||
return response?.type === 'highlightLines' ? response.lines : null;
|
||||
const key = cacheKeyFor('highlightLines', lang, code);
|
||||
const cached = resultCache.get(key);
|
||||
if (cached?.type === 'highlightLines') return cached.lines;
|
||||
|
||||
const result = await coalesce(key, async () => {
|
||||
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
|
||||
if (response?.type !== 'highlightLines') return null;
|
||||
const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines };
|
||||
resultCache.set(key, entry, entryBytes(key, entry));
|
||||
return entry;
|
||||
});
|
||||
return result?.type === 'highlightLines' ? result.lines : null;
|
||||
};
|
||||
|
||||
/** Return an already-tokenized line result without scheduling a worker request. */
|
||||
export const getCachedHighlightedLines = (code: string, lang: string): string[] | null => {
|
||||
const cached = resultCache.get(cacheKeyFor('highlightLines', lang, code));
|
||||
return cached?.type === 'highlightLines' ? cached.lines : null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -86,18 +213,25 @@ export const highlightTokensInWorker = async (
|
||||
themeName: string,
|
||||
theme: unknown,
|
||||
): Promise<MarkdownTokenRun[][] | null> => {
|
||||
const needsTheme = !sentThemes.has(themeName);
|
||||
const response = await request((id) => ({
|
||||
type: 'highlightTokens',
|
||||
id,
|
||||
code,
|
||||
lang,
|
||||
themeName,
|
||||
...(needsTheme ? { theme } : {}),
|
||||
}));
|
||||
if (response?.type === 'highlightTokens') {
|
||||
const key = cacheKeyFor('highlightTokens', lang, code, themeName);
|
||||
const cached = resultCache.get(key);
|
||||
if (cached?.type === 'highlightTokens') return cached.lines;
|
||||
|
||||
const result = await coalesce(key, async () => {
|
||||
const needsTheme = !sentThemes.has(themeName);
|
||||
const response = await request((id) => ({
|
||||
type: 'highlightTokens',
|
||||
id,
|
||||
code,
|
||||
lang,
|
||||
themeName,
|
||||
...(needsTheme ? { theme } : {}),
|
||||
}));
|
||||
if (response?.type !== 'highlightTokens') return null;
|
||||
sentThemes.add(themeName);
|
||||
return response.lines;
|
||||
}
|
||||
return null;
|
||||
const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
|
||||
resultCache.set(key, entry, entryBytes(key, entry));
|
||||
return entry;
|
||||
});
|
||||
return result?.type === 'highlightTokens' ? result.lines : null;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,72 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
type SanitizeAttribute = {
|
||||
attrName: string;
|
||||
attrValue: string;
|
||||
forceKeepAttr?: boolean;
|
||||
};
|
||||
|
||||
class TestAnchorElement {
|
||||
target = '';
|
||||
|
||||
setAttribute(name: string, value: string): void {
|
||||
if (name === 'target') this.target = value;
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeHooks: {
|
||||
uponSanitizeAttribute?: (node: unknown, data: SanitizeAttribute) => void;
|
||||
afterSanitizeAttributes?: (node: unknown) => void;
|
||||
} = {};
|
||||
|
||||
// Mirrors DOMPurify's default URI policy: approved schemes plus relative URLs.
|
||||
const DOMPURIFY_ALLOWED_URI_RE =
|
||||
// Keep this byte-aligned with DOMPurify's default IS_ALLOWED_URI expression.
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
|
||||
const URI_ATTRIBUTE_WHITESPACE_RE =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g;
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: {},
|
||||
HTMLAnchorElement: TestAnchorElement,
|
||||
});
|
||||
|
||||
mock.module('dompurify', () => ({
|
||||
default: {
|
||||
isSupported: true,
|
||||
addHook: (name: keyof typeof sanitizeHooks, hook: never) => {
|
||||
sanitizeHooks[name] = hook;
|
||||
},
|
||||
sanitize: (html: string) => html.replace(/ href="([^"]*)"/g, (attribute, href: string) => {
|
||||
const anchor = new TestAnchorElement();
|
||||
const data: SanitizeAttribute = { attrName: 'href', attrValue: href };
|
||||
sanitizeHooks.uponSanitizeAttribute?.(anchor, data);
|
||||
sanitizeHooks.afterSanitizeAttributes?.(anchor);
|
||||
|
||||
const normalizedHref = href.replace(URI_ATTRIBUTE_WHITESPACE_RE, '');
|
||||
return data.forceKeepAttr || DOMPURIFY_ALLOWED_URI_RE.test(normalizedHref)
|
||||
? attribute
|
||||
: '';
|
||||
}),
|
||||
},
|
||||
}));
|
||||
mock.module('./markdown-worker', () => ({
|
||||
highlightCodeInWorker: async () => null,
|
||||
}));
|
||||
|
||||
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
|
||||
const {
|
||||
__markdownImageCandidateCacheForTests,
|
||||
extractMarkdownImageCandidates,
|
||||
getCachedMarkdownBlocks,
|
||||
renderMarkdownBlocks,
|
||||
renderMarkdownSync,
|
||||
resetMarkdownHtmlCacheForTests,
|
||||
} = await import('./markdownCore');
|
||||
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
|
||||
|
||||
describe('markdown sanitization', () => {
|
||||
test('turns raw assistant HTML into inert visible text', () => {
|
||||
@@ -15,4 +81,240 @@ describe('markdown sanitization', () => {
|
||||
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('script');
|
||||
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style');
|
||||
});
|
||||
|
||||
test('allows only local file URLs through the sanitizer policy', () => {
|
||||
expect(isLocalFileUrl('file:///private/tmp/report%20viewer.html')).toBe(true);
|
||||
expect(isLocalFileUrl('file://localhost/private/tmp/REPORT.md')).toBe(true);
|
||||
expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false);
|
||||
expect(isLocalFileUrl('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps app and local file links while stripping blocked schemes', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[app](obsidian://open?vault=Notebook)',
|
||||
'[file](file:///workspace/notes.md)',
|
||||
'[script](javascript:alert(1))',
|
||||
'[diagnostic](ms-msdt:/id%20PCWDiagnostic)',
|
||||
].join('\n\n'), 'inline');
|
||||
|
||||
expect(html).toContain('href="obsidian://open?vault=Notebook"');
|
||||
expect(html).toContain('href="file:///workspace/notes.md"');
|
||||
expect(html).not.toContain('href="javascript:alert(1)"');
|
||||
expect(html).not.toContain('href="ms-msdt:/id%20PCWDiagnostic"');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Markdown block cache reads', () => {
|
||||
test('returns all settled blocks synchronously after a full cache hit', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
const text = '**cached** settled markdown';
|
||||
|
||||
expect(getCachedMarkdownBlocks(text)).toBeNull();
|
||||
const rendered = await renderMarkdownBlocks(text, false);
|
||||
|
||||
expect(getCachedMarkdownBlocks(text)).toEqual(rendered);
|
||||
});
|
||||
|
||||
test('returns null for a cold or partial settled miss', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
const first = 'first settled block';
|
||||
const changed = 'first settled block\n\nsecond settled block';
|
||||
|
||||
await renderMarkdownBlocks(first, false);
|
||||
|
||||
expect(getCachedMarkdownBlocks(changed)).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps image mode identity out of the settled full hit', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
const text = '';
|
||||
|
||||
await renderMarkdownBlocks(text, false, 'inline');
|
||||
|
||||
expect(getCachedMarkdownBlocks(text, 'label')).toBeNull();
|
||||
expect(getCachedMarkdownBlocks(text, 'inline')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('does not treat streaming live-cache entries as settled full hits', async () => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
const text = 'streaming markdown';
|
||||
|
||||
await renderMarkdownBlocks(text, true);
|
||||
|
||||
expect(getCachedMarkdownBlocks(text)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown images', () => {
|
||||
test('renders assistant images as icon-ready text without loading the source', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[linked image](packages/vscode/extension.jpg)',
|
||||
'',
|
||||
].join('\n\n'), 'label');
|
||||
|
||||
expect(html).toContain('data-openchamber-markdown-image-label="true"');
|
||||
expect(html).toContain('extension.jpg');
|
||||
expect(html).not.toContain('image syntax');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html.match(/<a /g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps non-chat Markdown images inline', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[remote link](https://example.test/image.png)',
|
||||
'',
|
||||
].join('\n\n'));
|
||||
|
||||
expect(html).toContain('<a href="https://example.test/image.png"');
|
||||
expect(html).toContain('<img src="https://example.test/image.png" alt="remote image">');
|
||||
expect(html).not.toContain('data-openchamber-markdown-image-label');
|
||||
});
|
||||
|
||||
test('collects image syntax across mixed Markdown and ignores links and code', () => {
|
||||
const candidates = extractMarkdownImageCandidates([
|
||||
[
|
||||
'Before [local link](screens/first%20view.png) and ``.',
|
||||
'',
|
||||
'- ',
|
||||
'- ',
|
||||
'',
|
||||
'```md',
|
||||
'',
|
||||
'```',
|
||||
].join('\n'),
|
||||
'After .',
|
||||
]);
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{ source: 'screens/first%20view.png', filename: 'first view.png' },
|
||||
{ source: 'https://example.test/second.webp?size=2', filename: 'second.webp' },
|
||||
{ source: 'data:image/png;base64,AAAA', filename: 'third' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not add an ordinary local image link to the gallery', () => {
|
||||
expect(extractMarkdownImageCandidates(['[download](screens/image.png)'])).toEqual([]);
|
||||
});
|
||||
|
||||
test('limits one finalized message gallery to twelve unique candidates', () => {
|
||||
const markdown = Array.from({ length: 14 }, (_, index) => ``).join('\n');
|
||||
|
||||
const candidates = extractMarkdownImageCandidates([markdown]);
|
||||
|
||||
expect(candidates).toHaveLength(12);
|
||||
expect(candidates.at(-1)?.source).toBe('screens/11.png');
|
||||
});
|
||||
|
||||
test('reuses extracted candidates across virtualized remounts without changing gallery behavior', () => {
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
const contents = Array.from({ length: 20 }, (_, index) => ``);
|
||||
|
||||
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
|
||||
expect(__markdownImageCandidateCacheForTests.stats().scans).toBe(12);
|
||||
|
||||
for (let round = 0; round < 1000; round += 1) {
|
||||
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
|
||||
}
|
||||
|
||||
const stats = __markdownImageCandidateCacheForTests.stats();
|
||||
expect(stats.entries).toBe(12);
|
||||
expect(stats.scans).toBe(12);
|
||||
});
|
||||
|
||||
test('scans one thousand independent messages once across virtualized remounts', () => {
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
const messages = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, index) => ``,
|
||||
);
|
||||
|
||||
for (const message of messages) extractMarkdownImageCandidates([message]);
|
||||
for (const message of messages) extractMarkdownImageCandidates([message]);
|
||||
|
||||
const stats = __markdownImageCandidateCacheForTests.stats();
|
||||
expect(stats.entries).toBe(1000);
|
||||
expect(stats.scans).toBe(1000);
|
||||
});
|
||||
|
||||
test('gives embedded images without alt text a stable filename', () => {
|
||||
const source = 'data:image/png;base64,AAAA';
|
||||
|
||||
expect(extractMarkdownImageCandidates([``])).toEqual([
|
||||
{ source, filename: 'image.png' },
|
||||
]);
|
||||
expect(renderMarkdownSync(``, 'label')).toContain('image.png');
|
||||
});
|
||||
|
||||
test('bounds cached candidate entries and bytes, and skips oversized individual content', () => {
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
for (let index = 0; index < 1025; index += 1) {
|
||||
extractMarkdownImageCandidates([``]);
|
||||
}
|
||||
const boundedStats = __markdownImageCandidateCacheForTests.stats();
|
||||
expect(boundedStats.entries).toBe(1024);
|
||||
expect(boundedStats.bytes <= 2 * 1024 * 1024).toBe(true);
|
||||
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
const oversized = `\n${'x'.repeat(64 * 1024)}`;
|
||||
|
||||
extractMarkdownImageCandidates([oversized]);
|
||||
extractMarkdownImageCandidates([oversized]);
|
||||
expect(__markdownImageCandidateCacheForTests.stats()).toEqual({ entries: 0, bytes: 0, scans: 2 });
|
||||
});
|
||||
|
||||
test('validates embedded image bytes against the declared MIME type', async () => {
|
||||
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
expect(await resolveMarkdownImageSource(`data:image/png;base64,${png}`, signal)).toBe(`data:image/png;base64,${png}`);
|
||||
await resolveMarkdownImageSource(`data:image/jpeg;base64,${png}`, signal).then(
|
||||
() => { throw new Error('Expected mismatched image data to fail'); },
|
||||
(error: unknown) => expect((error as Error).message).toBe('Unsupported image data'),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not resolve images after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await resolveMarkdownImageSource('https://example.test/image.png', controller.signal).then(
|
||||
() => { throw new Error('Expected an aborted image load to fail'); },
|
||||
(error: unknown) => expect((error as Error).name).toBe('AbortError'),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the existing image renderer outside finalized assistant text', () => {
|
||||
const html = renderMarkdownSync('');
|
||||
|
||||
expect(html).toContain('<img src="https://example.test/image.png"');
|
||||
expect(html).not.toContain('data-openchamber-markdown-image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CJK-aware link parsing', () => {
|
||||
const hrefOf = (html: string): string | null => /<a\b[^>]*href="([^"]*)"/.exec(html)?.[1] ?? null;
|
||||
|
||||
test('bare URL followed by a CJK annotation trims the annotation from the href', () => {
|
||||
const html = renderMarkdownSync('访问 https://example.com/docs(中文说明)了解更多');
|
||||
expect(hrefOf(html)).toBe('https://example.com/docs');
|
||||
});
|
||||
|
||||
test('bare URL followed by CJK punctuation trims the punctuation', () => {
|
||||
expect(hrefOf(renderMarkdownSync('地址 https://example.com/guide,详见'))).toBe(
|
||||
'https://example.com/guide',
|
||||
);
|
||||
expect(hrefOf(renderMarkdownSync('官网 https://example.com。'))).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('correct links are unaffected', () => {
|
||||
expect(hrefOf(renderMarkdownSync('官方文档见 [这里](https://docs.example.com)(中文说明)'))).toBe(
|
||||
'https://docs.example.com',
|
||||
);
|
||||
expect(hrefOf(renderMarkdownSync('[下载](https://dl.example.com/安装包(正式版))'))).toBe(
|
||||
'https://dl.example.com/安装包(正式版)',
|
||||
);
|
||||
expect(hrefOf(renderMarkdownSync('[a](url(1))'))).toBe('url(1)');
|
||||
expect(hrefOf(renderMarkdownSync('[a](url "title")'))).toBe('url');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,176 @@
|
||||
import { marked, type Tokens } from 'marked';
|
||||
import { Marked, marked, type Tokens } from 'marked';
|
||||
import markedLinkifyIt from 'marked-linkify-it';
|
||||
import remend from 'remend';
|
||||
import katex from 'katex';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
||||
import { isAppLinkUrl } from '@/lib/url';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
|
||||
import { highlightCodeInWorker } from './markdown-worker';
|
||||
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
|
||||
const escapeAttr = (value: string): string =>
|
||||
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const LOCAL_IMAGE_EXTENSION_RE = /\.(?:png|jpe?g|gif|webp)(?:[?#].*)?$/i;
|
||||
const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/;
|
||||
const URL_SCHEME_RE = /^[A-Za-z][A-Za-z\d+.-]*:/;
|
||||
|
||||
export interface MarkdownImageCandidate {
|
||||
source: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export type MarkdownImageMode = 'inline' | 'label';
|
||||
|
||||
export const MAX_MARKDOWN_IMAGE_COUNT = 12;
|
||||
|
||||
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRIES = 1024;
|
||||
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRY_BYTES = 64 * 1024;
|
||||
|
||||
type MarkdownImageCandidateCacheEntry = {
|
||||
candidates: MarkdownImageCandidate[];
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
const markdownImageCandidateCache = new Map<string, MarkdownImageCandidateCacheEntry>();
|
||||
let markdownImageCandidateCacheBytes = 0;
|
||||
let markdownImageCandidateScanCount = 0;
|
||||
|
||||
const isLocalMarkdownImageSource = (source: string): boolean => {
|
||||
if (/^\/\//.test(source) || !LOCAL_IMAGE_EXTENSION_RE.test(source)) return false;
|
||||
return WINDOWS_ABSOLUTE_PATH_RE.test(source)
|
||||
|| /^file:\/\//i.test(source)
|
||||
|| !URL_SCHEME_RE.test(source);
|
||||
};
|
||||
|
||||
const isSupportedMarkdownImageSource = (source: string): boolean => (
|
||||
/^(?:https?:)?\/\//i.test(source)
|
||||
|| /^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
|
||||
|| isLocalMarkdownImageSource(source)
|
||||
);
|
||||
|
||||
const getMarkdownImageFilename = (source: string, fallback: string): string => {
|
||||
if (/^data:image\/(png|jpeg|gif|webp)/i.test(source)) {
|
||||
const extension = /^data:image\/([^;,]+)/i.exec(source)?.[1]?.replace('jpeg', 'jpg') ?? 'png';
|
||||
return fallback.trim() || `image.${extension}`;
|
||||
}
|
||||
|
||||
const path = source.split(/[?#]/, 1)[0]?.replace(/\\/g, '/') ?? '';
|
||||
const encodedName = path.split('/').filter(Boolean).at(-1) ?? '';
|
||||
if (!encodedName) return fallback.trim();
|
||||
try {
|
||||
return decodeURIComponent(encodedName);
|
||||
} catch {
|
||||
return encodedName;
|
||||
}
|
||||
};
|
||||
|
||||
const estimateMarkdownImageCandidateCacheEntryBytes = (
|
||||
markdown: string,
|
||||
candidates: readonly MarkdownImageCandidate[],
|
||||
): number => (
|
||||
(markdown.length + candidates.reduce((total, candidate) => total + candidate.source.length + candidate.filename.length, 0)) * 2
|
||||
);
|
||||
|
||||
const scanMarkdownImageCandidates = (markdown: string): MarkdownImageCandidate[] => {
|
||||
markdownImageCandidateScanCount += 1;
|
||||
const candidates: MarkdownImageCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
const tokens = marked.lexer(markdown);
|
||||
marked.walkTokens(tokens, (token) => {
|
||||
if (token.type !== 'image') return;
|
||||
|
||||
const source = token.href ?? '';
|
||||
if (!source || !isSupportedMarkdownImageSource(source) || seen.has(source)) return;
|
||||
const fallback = typeof token.text === 'string' ? token.text : '';
|
||||
const filename = getMarkdownImageFilename(source, fallback);
|
||||
if (!filename) return;
|
||||
|
||||
seen.add(source);
|
||||
candidates.push({ source, filename });
|
||||
});
|
||||
return candidates;
|
||||
};
|
||||
|
||||
const getMarkdownImageCandidates = (markdown: string): MarkdownImageCandidate[] => {
|
||||
const cached = markdownImageCandidateCache.get(markdown);
|
||||
if (cached) {
|
||||
markdownImageCandidateCache.delete(markdown);
|
||||
markdownImageCandidateCache.set(markdown, cached);
|
||||
return cached.candidates;
|
||||
}
|
||||
|
||||
const candidates = scanMarkdownImageCandidates(markdown);
|
||||
const bytes = estimateMarkdownImageCandidateCacheEntryBytes(markdown, candidates);
|
||||
if (bytes > MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRY_BYTES) return candidates;
|
||||
|
||||
while (
|
||||
markdownImageCandidateCache.size >= MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRIES
|
||||
|| markdownImageCandidateCacheBytes + bytes > MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_BYTES
|
||||
) {
|
||||
const oldest = markdownImageCandidateCache.entries().next().value;
|
||||
if (!oldest) break;
|
||||
markdownImageCandidateCache.delete(oldest[0]);
|
||||
markdownImageCandidateCacheBytes -= oldest[1].bytes;
|
||||
}
|
||||
markdownImageCandidateCache.set(markdown, { candidates, bytes });
|
||||
markdownImageCandidateCacheBytes += bytes;
|
||||
return candidates;
|
||||
};
|
||||
|
||||
/** @internal Test-only cache instrumentation for deterministic regression tests. */
|
||||
export const __markdownImageCandidateCacheForTests = {
|
||||
reset: (): void => {
|
||||
markdownImageCandidateCache.clear();
|
||||
markdownImageCandidateCacheBytes = 0;
|
||||
markdownImageCandidateScanCount = 0;
|
||||
},
|
||||
stats: () => ({
|
||||
entries: markdownImageCandidateCache.size,
|
||||
bytes: markdownImageCandidateCacheBytes,
|
||||
scans: markdownImageCandidateScanCount,
|
||||
}),
|
||||
};
|
||||
|
||||
const renderMarkdownImageLabel = ({
|
||||
href,
|
||||
title,
|
||||
text,
|
||||
}: {
|
||||
href: string;
|
||||
title?: string | null;
|
||||
text: string;
|
||||
}): string => {
|
||||
const label = getMarkdownImageFilename(href ?? '', text);
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
return `<span${titleAttr} class="inline-flex items-center gap-1 align-text-bottom text-muted-foreground" data-openchamber-markdown-image-label="true">${escapeAttr(label)}</span>`;
|
||||
};
|
||||
|
||||
export const extractMarkdownImageCandidates = (
|
||||
markdownTexts: readonly string[],
|
||||
limit = MAX_MARKDOWN_IMAGE_COUNT,
|
||||
): MarkdownImageCandidate[] => {
|
||||
if (limit <= 0) return [];
|
||||
|
||||
const candidates: MarkdownImageCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const markdown of markdownTexts) {
|
||||
if (!markdown || candidates.length >= limit) continue;
|
||||
for (const candidate of getMarkdownImageCandidates(markdown)) {
|
||||
if (candidates.length >= limit) break;
|
||||
if (seen.has(candidate.source)) continue;
|
||||
seen.add(candidate.source);
|
||||
candidates.push({ ...candidate });
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming block segmentation (port of OpenCode's markdown-stream)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -18,9 +179,11 @@ type MarkdownBlock = {
|
||||
raw: string;
|
||||
src: string;
|
||||
mode: 'full' | 'live';
|
||||
// When false, skip syntax highlighting for this block. Set for the actively
|
||||
// streaming open code fence so we don't re-tokenize a growing block ~40x/sec
|
||||
// (O(n^2)); it highlights once the fence closes and becomes a stable block.
|
||||
// When false, skip syntax highlighting for this block. Block-level commit
|
||||
// feeds the open fence whole lines at the throttle cadence (<=10/sec), so a
|
||||
// partial fence highlights too and streamed code arrives colored; only a
|
||||
// very large open fence falls back to plain text until it closes, keeping
|
||||
// the repeated worker re-tokenization bounded.
|
||||
highlight: boolean;
|
||||
};
|
||||
|
||||
@@ -41,6 +204,11 @@ const hasOpenFence = (raw: string): boolean => {
|
||||
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last);
|
||||
};
|
||||
|
||||
// Above this, re-highlighting the still-open fence on every committed line
|
||||
// costs more than the colored preview is worth; the block highlights in one
|
||||
// pass when the fence closes.
|
||||
const OPEN_FENCE_HIGHLIGHT_LINE_LIMIT = 300;
|
||||
|
||||
const heal = (text: string): string => {
|
||||
try {
|
||||
return remend(text, { linkMode: 'text-only' });
|
||||
@@ -90,11 +258,13 @@ const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
|
||||
const raw = token.raw ?? '';
|
||||
const isLast = i === tail;
|
||||
const openFence = token.type === 'code' && hasOpenFence(raw);
|
||||
const openFenceHighlight = openFence
|
||||
&& raw.split('\n').length <= OPEN_FENCE_HIGHLIGHT_LINE_LIMIT;
|
||||
blocks.push({
|
||||
raw,
|
||||
src: openFence ? raw : heal(raw),
|
||||
mode: isLast ? 'live' : 'full',
|
||||
highlight: !openFence,
|
||||
highlight: !openFence || openFenceHighlight,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,10 +332,15 @@ const blockMathExtension = {
|
||||
},
|
||||
};
|
||||
|
||||
const parser = marked.use({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
extensions: [inlineMathExtension, blockMathExtension],
|
||||
// marked's GFM autolink swallows CJK punctuation after a bare URL, so switch
|
||||
// to marked-linkify-it, which treats Unicode punctuation as a URL boundary.
|
||||
// Plain CJK characters right after a URL are still consumed, matching GitHub.
|
||||
const createParser = (imageMode: MarkdownImageMode) => new Marked().use(
|
||||
markedLinkifyIt({ fuzzyLink: false }),
|
||||
{
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
extensions: [inlineMathExtension, blockMathExtension],
|
||||
renderer: {
|
||||
// Assistant output is untrusted. Markdown constructs still render as HTML,
|
||||
// but raw HTML must remain visible text so it cannot introduce active DOM
|
||||
@@ -186,9 +361,13 @@ const parser = marked.use({
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||
},
|
||||
...(imageMode === 'label' ? { image: renderMarkdownImageLabel } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const inlineImageParser = createParser('inline');
|
||||
const imageLabelParser = createParser('label');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -253,32 +432,37 @@ const highlightCodeBlocks = async (html: string): Promise<string> => {
|
||||
|
||||
const lineLimit = isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT;
|
||||
|
||||
let result = html;
|
||||
for (const match of matches) {
|
||||
const [full, rawLang, escapedCode] = match;
|
||||
const requested = (rawLang || 'text').toLowerCase();
|
||||
// Leave mermaid fences untouched so the decorate pass can render them as
|
||||
// diagrams (highlighting would strip the `language-mermaid` class).
|
||||
if (requested === 'mermaid') continue;
|
||||
// Highlight all eligible fences concurrently — sequential await was O(n)
|
||||
// worker round-trips for messages with multiple code blocks.
|
||||
const replacements = await Promise.all(
|
||||
matches.map(async (match) => {
|
||||
const [full, rawLang, escapedCode] = match;
|
||||
const requested = (rawLang || 'text').toLowerCase();
|
||||
// Leave mermaid fences untouched so the decorate pass can render them as
|
||||
// diagrams (highlighting would strip the `language-mermaid` class).
|
||||
if (requested === 'mermaid') return null;
|
||||
|
||||
const code = unescapeHtml(escapedCode ?? '');
|
||||
const code = unescapeHtml(escapedCode ?? '');
|
||||
|
||||
// Oversized block: skip highlight, keep plain code but stamp the language.
|
||||
if (exceedsLineLimit(code, lineLimit)) {
|
||||
result = result.replace(full, () => full.replace('<pre', `<pre data-md-lang="${requested}"`));
|
||||
continue;
|
||||
}
|
||||
// Oversized block: skip highlight, keep plain code but stamp the language.
|
||||
if (exceedsLineLimit(code, lineLimit)) {
|
||||
return { full, next: full.replace('<pre', `<pre data-md-lang="${requested}"`) };
|
||||
}
|
||||
|
||||
// Tokenize off the main thread. On failure the worker resolves to null and
|
||||
// we keep the original escaped <pre><code> (no main-thread highlight).
|
||||
const highlighted = await highlightCodeInWorker(code, requested);
|
||||
if (highlighted) {
|
||||
// Tokenize off the main thread. On failure the worker resolves to null and
|
||||
// we keep the original escaped <pre><code> (no main-thread highlight).
|
||||
const highlighted = await highlightCodeInWorker(code, requested);
|
||||
if (!highlighted) return null;
|
||||
// Stamp the language so the decorate pass can show a header label.
|
||||
const stamped = highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`);
|
||||
result = result.replace(full, () => stamped);
|
||||
}
|
||||
}
|
||||
return { full, next: highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`) };
|
||||
}),
|
||||
);
|
||||
|
||||
let result = html;
|
||||
for (const replacement of replacements) {
|
||||
if (!replacement) continue;
|
||||
result = result.replace(replacement.full, () => replacement.next);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -302,6 +486,13 @@ const ensureSanitizeHook = (): void => {
|
||||
if (sanitizeHookInstalled) return;
|
||||
if (typeof window === 'undefined' || !DOMPurify.isSupported) return;
|
||||
sanitizeHookInstalled = true;
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
||||
if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return;
|
||||
// DOMPurify's default URI policy strips custom application schemes
|
||||
// (obsidian://, vscode://, ...). Keep them for anchors; dangerous schemes
|
||||
// stay excluded via isAppLinkUrl and clicks go through confirmation.
|
||||
if (isLocalFileUrl(data.attrValue) || isAppLinkUrl(data.attrValue)) data.forceKeepAttr = true;
|
||||
});
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return;
|
||||
if (node.target !== '_blank') return;
|
||||
@@ -317,31 +508,86 @@ const sanitize = (html: string): string => {
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
|
||||
// Per-block HTML cache (content-addressed LRU)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Keyed by content hash + mode + highlight flag + image mode — NOT by renderer
|
||||
// instance id. `SimpleMarkdownRenderer` historically used a shared
|
||||
// `simple:${variant}` key, so every same-variant instance fought over one cache
|
||||
// slot and re-highlighted unchanged content on every pass
|
||||
// (openchamber/openchamber#2769). Content addressing makes identical blocks
|
||||
// share one entry and stops that thrash. Bounds are high enough for long
|
||||
// sessions; byte cap keeps memory bounded.
|
||||
//
|
||||
// `full` (settled) and `live` (trailing, still streaming) blocks get separate
|
||||
// caches. A live block's content changes on every stream step, so under one
|
||||
// shared content-addressed cache each step would insert a new entry and a long
|
||||
// streaming message would evict the settled blocks this fix exists to keep
|
||||
// warm. The live cache is small on purpose: it only has to absorb repeat
|
||||
// renders of the *same* step.
|
||||
|
||||
const CACHE_MAX = 240;
|
||||
const htmlCache = new Map<string, { hash: string; html: string }>();
|
||||
const FULL_CACHE_MAX_ENTRIES = 2000;
|
||||
const FULL_CACHE_MAX_BYTES = 24 * 1024 * 1024;
|
||||
const LIVE_CACHE_MAX_ENTRIES = 32;
|
||||
const LIVE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
// FNV-1a 32-bit hash of the block content.
|
||||
const hash = (value: string): string => {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
h ^= value.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
const fullBlockCache = new HighlightResultCache<string>({
|
||||
maxEntries: FULL_CACHE_MAX_ENTRIES,
|
||||
maxBytes: FULL_CACHE_MAX_BYTES,
|
||||
});
|
||||
const liveBlockCache = new HighlightResultCache<string>({
|
||||
maxEntries: LIVE_CACHE_MAX_ENTRIES,
|
||||
maxBytes: LIVE_CACHE_MAX_BYTES,
|
||||
});
|
||||
|
||||
const cacheForMode = (mode: MarkdownBlock['mode']): HighlightResultCache<string> =>
|
||||
(mode === 'live' ? liveBlockCache : fullBlockCache);
|
||||
|
||||
/** Content-addressed cache key for a markdown block. */
|
||||
const markdownBlockCacheKey = (
|
||||
contentHash: string,
|
||||
mode: MarkdownBlock['mode'],
|
||||
highlight: boolean,
|
||||
imageMode: MarkdownImageMode,
|
||||
): string => `${contentHash}:${mode}:${highlight ? 1 : 0}:${imageMode}`;
|
||||
|
||||
/** Test-only: clear the render HTML caches between cases. */
|
||||
export const resetMarkdownHtmlCacheForTests = (): void => {
|
||||
fullBlockCache.clear();
|
||||
liveBlockCache.clear();
|
||||
};
|
||||
|
||||
/** Test-only: entry counts per block cache, for churn/eviction assertions. */
|
||||
export const __markdownBlockCacheSizesForTests = (): { full: number; live: number } => ({
|
||||
full: fullBlockCache.size,
|
||||
live: liveBlockCache.size,
|
||||
});
|
||||
|
||||
/**
|
||||
* Read a settled render synchronously when every block is already in the full
|
||||
* cache. Cache reads retain the existing LRU `get` semantics and do not insert
|
||||
* or expand either cache.
|
||||
*/
|
||||
export const getCachedMarkdownBlocks = (
|
||||
text: string,
|
||||
imageMode: MarkdownImageMode = 'inline',
|
||||
): RenderedBlock[] | null => {
|
||||
if (!text) return [];
|
||||
|
||||
const blocks = streamBlocks(text, false);
|
||||
const rendered: RenderedBlock[] = [];
|
||||
for (const block of blocks) {
|
||||
const contentHash = contentFingerprint(block.raw);
|
||||
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
|
||||
const html = fullBlockCache.get(id);
|
||||
if (html === undefined) return null;
|
||||
rendered.push({ id, html });
|
||||
}
|
||||
return (h >>> 0).toString(36);
|
||||
return rendered;
|
||||
};
|
||||
|
||||
const touch = (key: string, entry: { hash: string; html: string }): void => {
|
||||
htmlCache.delete(key);
|
||||
htmlCache.set(key, entry);
|
||||
if (htmlCache.size <= CACHE_MAX) return;
|
||||
const oldest = htmlCache.keys().next().value;
|
||||
if (oldest) htmlCache.delete(oldest);
|
||||
};
|
||||
|
||||
const parseBlock = async (block: MarkdownBlock): Promise<string> => {
|
||||
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = await Promise.resolve(parser.parse(block.src));
|
||||
const withMath = renderMathExpressions(parsed);
|
||||
const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath;
|
||||
@@ -357,8 +603,12 @@ const parseBlock = async (block: MarkdownBlock): Promise<string> => {
|
||||
* is synchronous (marked is not configured `async`), so this never blocks on a
|
||||
* worker round-trip.
|
||||
*/
|
||||
export const renderMarkdownSync = (text: string): string => {
|
||||
export const renderMarkdownSync = (
|
||||
text: string,
|
||||
imageMode: MarkdownImageMode = 'inline',
|
||||
): string => {
|
||||
if (!text) return '';
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = parser.parse(text) as string;
|
||||
const withMath = renderMathExpressions(parsed);
|
||||
return sanitize(withMath);
|
||||
@@ -377,27 +627,29 @@ export type RenderedBlock = {
|
||||
* splits into blocks, caches per-block, heals incomplete syntax. Returning
|
||||
* blocks (instead of one joined string) lets the renderer re-morph only the
|
||||
* block that changed, keeping per-step streaming cost ~O(last block).
|
||||
*
|
||||
* Lookup is content-addressed: distinct renderers holding identical blocks
|
||||
* share one entry and cannot evict each other by identity collision.
|
||||
*/
|
||||
export const renderMarkdownBlocks = async (
|
||||
text: string,
|
||||
streaming: boolean,
|
||||
cacheKey: string,
|
||||
imageMode: MarkdownImageMode = 'inline',
|
||||
): Promise<RenderedBlock[]> => {
|
||||
if (!text) return [];
|
||||
|
||||
const blocks = streamBlocks(text, streaming);
|
||||
return Promise.all(
|
||||
blocks.map(async (block, index) => {
|
||||
const contentHash = hash(block.raw);
|
||||
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}`;
|
||||
const key = `${cacheKey}:${index}:${block.mode}`;
|
||||
const cached = htmlCache.get(key);
|
||||
if (cached && cached.hash === contentHash) {
|
||||
touch(key, cached);
|
||||
return { id, html: cached.html };
|
||||
blocks.map(async (block) => {
|
||||
const contentHash = contentFingerprint(block.raw);
|
||||
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
|
||||
const cache = cacheForMode(block.mode);
|
||||
const cached = cache.get(id);
|
||||
if (cached !== undefined) {
|
||||
return { id, html: cached };
|
||||
}
|
||||
const html = await parseBlock(block);
|
||||
touch(key, { hash: contentHash, html });
|
||||
const html = await parseBlock(block, imageMode);
|
||||
cache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
|
||||
return { id, html };
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Regression tests for https://github.com/openchamber/openchamber/issues/2769
|
||||
*
|
||||
* Sustained Shiki worker CPU came from re-tokenizing unchanged content:
|
||||
* 1. `htmlCache` keyed by renderer identity (`simple:${variant}`) so
|
||||
* same-variant instances evicted each other every pass.
|
||||
* 2. LRU capped at 240 entries, so long sessions missed 100% on every pass.
|
||||
* 3. Worker/client had no result memoization.
|
||||
*
|
||||
* These tests assert the fixed contracts: content-addressed caching, room for
|
||||
* long sessions, bounded LRU behavior, and fingerprint-key helpers.
|
||||
*/
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
contentFingerprint,
|
||||
estimateTokenRunsBytes,
|
||||
HighlightResultCache,
|
||||
utf16Bytes,
|
||||
} from './highlightResultCache';
|
||||
|
||||
let highlightCalls = 0;
|
||||
let highlightInflight = 0;
|
||||
let highlightMaxInflight = 0;
|
||||
|
||||
const highlightCodeInWorkerMock = mock(async (code: string, lang: string) => {
|
||||
highlightCalls += 1;
|
||||
highlightInflight += 1;
|
||||
highlightMaxInflight = Math.max(highlightMaxInflight, highlightInflight);
|
||||
await Promise.resolve();
|
||||
highlightInflight -= 1;
|
||||
return `<pre data-lang="${lang}"><code>${code}</code></pre>`;
|
||||
});
|
||||
|
||||
mock.module('./markdown-worker', () => ({
|
||||
highlightCodeInWorker: highlightCodeInWorkerMock,
|
||||
highlightLinesInWorker: mock(async () => null),
|
||||
highlightTokensInWorker: mock(async () => null),
|
||||
resetMarkdownWorkerClientCacheForTests: mock(() => undefined),
|
||||
}));
|
||||
|
||||
const {
|
||||
renderMarkdownBlocks,
|
||||
resetMarkdownHtmlCacheForTests,
|
||||
__markdownBlockCacheSizesForTests,
|
||||
} = await import('./markdownCore');
|
||||
|
||||
const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker');
|
||||
|
||||
beforeEach(() => {
|
||||
resetMarkdownHtmlCacheForTests();
|
||||
resetMarkdownWorkerClientCacheForTests();
|
||||
highlightCalls = 0;
|
||||
highlightInflight = 0;
|
||||
highlightMaxInflight = 0;
|
||||
});
|
||||
|
||||
describe('HighlightResultCache', () => {
|
||||
test('returns cached values for identical keys and refreshes LRU order', () => {
|
||||
const cache = new HighlightResultCache<string>({ maxEntries: 2, maxBytes: 10_000 });
|
||||
cache.set('a', 'one', utf16Bytes('a') + utf16Bytes('one'));
|
||||
cache.set('b', 'two', utf16Bytes('b') + utf16Bytes('two'));
|
||||
expect(cache.get('a')).toBe('one');
|
||||
// Touch `a` so `b` is oldest; inserting `c` should evict `b`.
|
||||
cache.set('c', 'three', utf16Bytes('c') + utf16Bytes('three'));
|
||||
expect(cache.get('b')).toEqual(undefined);
|
||||
expect(cache.get('a')).toBe('one');
|
||||
expect(cache.get('c')).toBe('three');
|
||||
});
|
||||
|
||||
test('evicts by byte budget while still caching a single oversized entry', () => {
|
||||
const cache = new HighlightResultCache<string>({ maxEntries: 10, maxBytes: 64 });
|
||||
cache.set('small', 'x', utf16Bytes('small') + utf16Bytes('x'));
|
||||
cache.set('huge', 'y'.repeat(200), utf16Bytes('huge') + utf16Bytes('y'.repeat(200)));
|
||||
expect(cache.get('huge')).toBe('y'.repeat(200));
|
||||
// Oversized insert cleared prior entries to make room.
|
||||
expect(cache.size).toBe(1);
|
||||
});
|
||||
|
||||
test('contentFingerprint is stable and length-qualified', () => {
|
||||
expect(contentFingerprint('const x = 1')).toBe(contentFingerprint('const x = 1'));
|
||||
expect(contentFingerprint('const x = 1')).not.toBe(contentFingerprint('const x = 2'));
|
||||
expect(contentFingerprint('ab')).not.toBe(contentFingerprint('abc'));
|
||||
});
|
||||
|
||||
test('contentFingerprint stays collision-free across a realistic session', () => {
|
||||
// A collision here does not mis-color a block — it returns a *different*
|
||||
// block's HTML, showing the user source they never wrote. Keep enough key
|
||||
// space that a session-sized working set never collides.
|
||||
const seen = new Map<string, string>();
|
||||
for (let i = 0; i < 20_000; i += 1) {
|
||||
// Same-length, near-identical sources are the realistic worst case:
|
||||
// repeated tool output differing by a few characters.
|
||||
const source = `const value_${String(i).padStart(6, '0')} = ${String(i).padStart(6, '0')};`;
|
||||
const fingerprint = contentFingerprint(source);
|
||||
expect(seen.get(fingerprint) ?? source).toBe(source);
|
||||
seen.set(fingerprint, source);
|
||||
}
|
||||
expect(seen.size).toBe(20_000);
|
||||
});
|
||||
|
||||
test('estimateTokenRunsBytes avoids JSON and stays positive', () => {
|
||||
const lines: Array<Array<[number, string, number]>> = [
|
||||
[[3, '#fff', 0], [1, '', 1]],
|
||||
[[8, 'var(--md-syntax-keyword)', 0]],
|
||||
];
|
||||
expect(estimateTokenRunsBytes(lines)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdownCore content-addressed htmlCache (#2769)', () => {
|
||||
test('repeat renders of unchanged content never re-enter the worker', async () => {
|
||||
const toolOutputA = '```ts\nconst a = 1;\n```';
|
||||
const toolOutputB = '```ts\nconst b = 2;\n```';
|
||||
|
||||
// First pass: cold miss for each distinct block.
|
||||
await renderMarkdownBlocks(toolOutputA, false);
|
||||
await renderMarkdownBlocks(toolOutputB, false);
|
||||
const coldCalls = highlightCalls;
|
||||
expect(coldCalls).toBeGreaterThan(0);
|
||||
|
||||
// 100 more passes. Renderers used to pass a shared `simple:${variant}`
|
||||
// identity key here and evict each other every pass; lookup is now
|
||||
// content-addressed, so no additional worker calls may happen.
|
||||
for (let pass = 0; pass < 100; pass += 1) {
|
||||
await renderMarkdownBlocks(toolOutputA, false);
|
||||
await renderMarkdownBlocks(toolOutputB, false);
|
||||
}
|
||||
|
||||
expect(highlightCalls).toBe(coldCalls);
|
||||
});
|
||||
|
||||
test('long sessions (working set > former 240 cap) stay warm across re-render passes', async () => {
|
||||
const parts = Array.from({ length: 600 }, (_, i) => ({
|
||||
content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``,
|
||||
}));
|
||||
|
||||
for (const part of parts) {
|
||||
await renderMarkdownBlocks(part.content, false);
|
||||
}
|
||||
const afterCold = highlightCalls;
|
||||
expect(afterCold).toBe(parts.length);
|
||||
|
||||
for (let pass = 0; pass < 5; pass += 1) {
|
||||
for (const part of parts) {
|
||||
await renderMarkdownBlocks(part.content, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Unchanged content must not re-enter the worker.
|
||||
expect(highlightCalls).toBe(afterCold);
|
||||
});
|
||||
|
||||
test('content changes invalidate only the changed block', async () => {
|
||||
const stable = '```ts\nconst stable = true;\n```';
|
||||
const changing = '```ts\nconst n = 1;\n```';
|
||||
|
||||
await renderMarkdownBlocks(stable, false);
|
||||
await renderMarkdownBlocks(changing, false);
|
||||
const afterFirst = highlightCalls;
|
||||
|
||||
await renderMarkdownBlocks(stable, false);
|
||||
await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false);
|
||||
expect(highlightCalls).toBe(afterFirst + 1);
|
||||
|
||||
await renderMarkdownBlocks(stable, false);
|
||||
expect(highlightCalls).toBe(afterFirst + 1);
|
||||
});
|
||||
|
||||
test('image mode is part of the cache identity, not shared across modes', async () => {
|
||||
const source = '';
|
||||
|
||||
const [inline] = await renderMarkdownBlocks(source, false, 'inline');
|
||||
expect(__markdownBlockCacheSizesForTests().full).toBe(1);
|
||||
|
||||
// Same source, different rendering: content addressing must not let the
|
||||
// first-rendered mode answer for both.
|
||||
const [label] = await renderMarkdownBlocks(source, false, 'label');
|
||||
expect(inline?.id).not.toBe(label?.id);
|
||||
expect(__markdownBlockCacheSizesForTests().full).toBe(2);
|
||||
|
||||
// Re-rendering a mode already seen stays a cache hit.
|
||||
const [inlineAgain] = await renderMarkdownBlocks(source, false, 'inline');
|
||||
expect(inlineAgain?.id).toBe(inline?.id);
|
||||
expect(__markdownBlockCacheSizesForTests().full).toBe(2);
|
||||
});
|
||||
|
||||
test('streaming a message does not evict settled blocks (live cache is separate)', async () => {
|
||||
const settled = Array.from(
|
||||
{ length: 40 },
|
||||
(_, i) => `\`\`\`ts\nconst settled_${i} = ${i};\n\`\`\``,
|
||||
);
|
||||
for (const block of settled) {
|
||||
await renderMarkdownBlocks(block, false);
|
||||
}
|
||||
const settledEntries = __markdownBlockCacheSizesForTests().full;
|
||||
expect(settledEntries).toBe(settled.length);
|
||||
const afterSettled = highlightCalls;
|
||||
|
||||
// Stream a message: every step is new content for the trailing live block,
|
||||
// so a single shared content-addressed cache would insert one entry per
|
||||
// step and evict the settled working set this fix exists to keep warm.
|
||||
let streamed = '';
|
||||
for (let step = 0; step < 150; step += 1) {
|
||||
streamed += `word_${step} `;
|
||||
await renderMarkdownBlocks(streamed, true);
|
||||
}
|
||||
|
||||
const sizes = __markdownBlockCacheSizesForTests();
|
||||
expect(sizes.live).toBeLessThanOrEqual(32);
|
||||
expect(sizes.full).toBe(settledEntries);
|
||||
|
||||
for (const block of settled) {
|
||||
await renderMarkdownBlocks(block, false);
|
||||
}
|
||||
expect(highlightCalls).toBe(afterSettled);
|
||||
});
|
||||
|
||||
test('a repeated streaming step is served from the live cache', async () => {
|
||||
const step = 'partial answer text';
|
||||
const [first] = await renderMarkdownBlocks(step, true);
|
||||
const [second] = await renderMarkdownBlocks(step, true);
|
||||
|
||||
expect(second?.id).toBe(first?.id);
|
||||
expect(__markdownBlockCacheSizesForTests()).toEqual({ full: 0, live: 1 });
|
||||
});
|
||||
|
||||
test('multiple code fences in one document highlight concurrently', async () => {
|
||||
const multi = [
|
||||
'```ts\nconst a = 1;\n```',
|
||||
'',
|
||||
'```ts\nconst b = 2;\n```',
|
||||
'',
|
||||
'```ts\nconst c = 3;\n```',
|
||||
].join('\n');
|
||||
|
||||
await renderMarkdownBlocks(multi, false);
|
||||
expect(highlightCalls).toBe(3);
|
||||
// Sequential awaits would keep max inflight at 1.
|
||||
expect(highlightMaxInflight).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
let requestCount = 0;
|
||||
let requestPaths: string[] = [];
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const runtimeFetch = mock(async (path: string, init?: RequestInit & { query?: Record<string, unknown> }) => {
|
||||
requestPaths.push(path);
|
||||
if (path === '/api/fs/stat') {
|
||||
return new Response(JSON.stringify({ isFile: true, size: PNG.byteLength }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (path === '/api/fs/raw') {
|
||||
return new Response(PNG, { status: 200, headers: { 'content-type': 'image/png' } });
|
||||
}
|
||||
requestCount += 1;
|
||||
const body = JSON.parse(String(init?.body)) as { sources: string[] };
|
||||
return new Response(JSON.stringify({
|
||||
results: body.sources.map((source) => ({ source, status: 'ready', path: `/repo/${source}` })),
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const resolver = {
|
||||
api: () => '',
|
||||
authenticatedAsset: (path: string, query: Record<string, string | undefined>) => {
|
||||
const params = new URLSearchParams(Object.entries(query).filter((entry): entry is [string, string] => Boolean(entry[1])));
|
||||
return `${path}?${params}`;
|
||||
},
|
||||
};
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch }));
|
||||
mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => resolver }));
|
||||
|
||||
class TestFileReader {
|
||||
result: string | ArrayBuffer | null = null;
|
||||
error: DOMException | null = null;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
readAsDataURL(blob: Blob) {
|
||||
void blob.arrayBuffer().then((buffer) => {
|
||||
this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}`;
|
||||
this.onload?.();
|
||||
}).catch((error) => {
|
||||
this.error = error as DOMException;
|
||||
this.onerror?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.FileReader = TestFileReader as unknown as typeof FileReader;
|
||||
|
||||
const {
|
||||
getPreparedMarkdownImageUrl,
|
||||
prepareLocalMarkdownImages,
|
||||
resolveWorkspaceMarkdownImageSource,
|
||||
} = await import('./markdownImageAssets');
|
||||
|
||||
describe('Markdown image asset preparation', () => {
|
||||
test('prepares many images in one message-level request', async () => {
|
||||
requestCount = 0;
|
||||
const sources = Array.from({ length: 12 }, (_, index) => `${index}.png`);
|
||||
|
||||
const result = await prepareLocalMarkdownImages({
|
||||
sources,
|
||||
directory: '/repo',
|
||||
sessionId: 'ses_batch',
|
||||
messageId: 'msg_batch',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.size).toBe(12);
|
||||
expect(requestCount).toBe(1);
|
||||
});
|
||||
|
||||
test('reuses preparation for one thousand messages after virtualized remounts', async () => {
|
||||
requestCount = 0;
|
||||
const requests = Array.from({ length: 1000 }, (_, index) => ({
|
||||
sources: [`${index}.png`],
|
||||
directory: '/repo',
|
||||
sessionId: 'ses_long',
|
||||
messageId: `msg_${index}`,
|
||||
signal: new AbortController().signal,
|
||||
}));
|
||||
|
||||
for (const request of requests) await prepareLocalMarkdownImages(request);
|
||||
for (const request of requests) await prepareLocalMarkdownImages(request);
|
||||
|
||||
expect(requestCount).toBe(1000);
|
||||
});
|
||||
|
||||
test('reuses the existing authenticated raw-file asset URL', () => {
|
||||
const url = getPreparedMarkdownImageUrl({
|
||||
status: 'ready',
|
||||
path: '/tmp/opencode/image.png',
|
||||
outsideFileGrant: 'grant-1',
|
||||
}, '/repo');
|
||||
|
||||
expect(url).toContain('/api/fs/raw?');
|
||||
expect(url).toContain('path=%2Ftmp%2Fopencode%2Fimage.png');
|
||||
expect(url).toContain('outsideFileGrant=grant-1');
|
||||
});
|
||||
|
||||
test('loads a workspace image through the local filesystem bridge', async () => {
|
||||
requestPaths = [];
|
||||
|
||||
const url = await resolveWorkspaceMarkdownImageSource(
|
||||
'screens/image.png',
|
||||
'/repo',
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(url.startsWith('data:image/png;base64,')).toBe(true);
|
||||
expect(requestPaths).toEqual(['/api/fs/stat', '/api/fs/raw']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeUrlResolver, type RuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
|
||||
const MAX_MARKDOWN_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_PREPARE_CACHE_ENTRIES = 1024;
|
||||
const NON_READY_CACHE_MS = 30_000;
|
||||
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
export type PreparedMarkdownImage =
|
||||
| { status: 'ready'; path: string; outsideFileGrant?: string; expiresAt?: number }
|
||||
| { status: 'missing' | 'error' };
|
||||
|
||||
type PrepareCacheEntry = {
|
||||
result: Map<string, PreparedMarkdownImage>;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const prepareCaches = new WeakMap<RuntimeUrlResolver, Map<string, PrepareCacheEntry>>();
|
||||
|
||||
const throwIfAborted = (signal: AbortSignal): void => {
|
||||
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
|
||||
};
|
||||
|
||||
const parseLocalImagePath = (source: string): string => {
|
||||
let value = source;
|
||||
if (/^file:\/\//i.test(value)) {
|
||||
try {
|
||||
const fileUrl = new URL(value);
|
||||
if (fileUrl.protocol !== 'file:') return '';
|
||||
value = fileUrl.host && fileUrl.host !== 'localhost'
|
||||
? `//${fileUrl.host}${fileUrl.pathname}`
|
||||
: fileUrl.pathname;
|
||||
if (/^\/[A-Za-z]:\//.test(value)) value = value.slice(1);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const path = value.split(/[?#]/, 1)[0] ?? '';
|
||||
try {
|
||||
return decodeURIComponent(path);
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Unable to encode image'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Unable to encode image'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
const hasImageSignature = async (blob: Blob, mimeType: string): Promise<boolean> => {
|
||||
const bytes = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
|
||||
const ascii = (start: number, end: number) => String.fromCharCode(...bytes.slice(start, end));
|
||||
switch (mimeType) {
|
||||
case 'image/png':
|
||||
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG'
|
||||
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
|
||||
case 'image/jpeg':
|
||||
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
case 'image/gif': {
|
||||
const gif = ascii(0, 6);
|
||||
return gif === 'GIF87a' || gif === 'GIF89a';
|
||||
}
|
||||
case 'image/webp':
|
||||
return ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> => {
|
||||
if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) throw new Error('Unsupported image type');
|
||||
if (blob.size > MAX_MARKDOWN_IMAGE_BYTES) throw new Error('Image is too large');
|
||||
if (!await hasImageSignature(blob, mimeType)) throw new Error('Unsupported image data');
|
||||
};
|
||||
|
||||
const validateDataImage = async (source: string): Promise<void> => {
|
||||
const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([\s\S]*)$/i.exec(source);
|
||||
if (!match?.[1] || match[2] === undefined) throw new Error('Invalid image data URL');
|
||||
if (match[2].length > Math.ceil(MAX_MARKDOWN_IMAGE_BYTES * 4 / 3) + 4) throw new Error('Image is too large');
|
||||
let binary: string;
|
||||
try {
|
||||
binary = atob(match[2]);
|
||||
} catch {
|
||||
throw new Error('Invalid image data URL');
|
||||
}
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
await validateImageBlob(new Blob([bytes]), match[1].toLowerCase());
|
||||
};
|
||||
|
||||
export const isLocalMarkdownImageSource = (source: string): boolean => (
|
||||
!/^(?:https?:)?\/\//i.test(source)
|
||||
&& !/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
|
||||
);
|
||||
|
||||
export const prepareLocalMarkdownImages = async ({
|
||||
sources,
|
||||
directory,
|
||||
sessionId,
|
||||
messageId,
|
||||
signal,
|
||||
}: {
|
||||
sources: readonly string[];
|
||||
directory: string;
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<Map<string, PreparedMarkdownImage>> => {
|
||||
const resolver = getRuntimeUrlResolver();
|
||||
let cache = prepareCaches.get(resolver);
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
prepareCaches.set(resolver, cache);
|
||||
}
|
||||
const key = `${sessionId}\0${messageId}\0${directory}\0${sources.join('\0')}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
cache.delete(key);
|
||||
cache.set(key, cached);
|
||||
return cached.result;
|
||||
}
|
||||
if (cached) cache.delete(key);
|
||||
|
||||
const response = await runtimeFetch(
|
||||
`/api/openchamber/sessions/${encodeURIComponent(sessionId)}/markdown-image-grants`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ directory, messageId, sources }),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`Unable to prepare images (${response.status})`);
|
||||
const payload = await response.json() as {
|
||||
results?: Array<{
|
||||
source?: string;
|
||||
status?: string;
|
||||
path?: string;
|
||||
outsideFileGrant?: string;
|
||||
expiresAt?: number;
|
||||
}>;
|
||||
};
|
||||
const prepared = new Map<string, PreparedMarkdownImage>();
|
||||
for (const result of payload.results ?? []) {
|
||||
if (!result.source) continue;
|
||||
if (result.status === 'ready' && result.path) {
|
||||
prepared.set(result.source, {
|
||||
status: 'ready',
|
||||
path: result.path,
|
||||
outsideFileGrant: result.outsideFileGrant,
|
||||
expiresAt: result.expiresAt,
|
||||
});
|
||||
} else if (result.status === 'missing') {
|
||||
prepared.set(result.source, { status: 'missing' });
|
||||
} else {
|
||||
prepared.set(result.source, { status: 'error' });
|
||||
}
|
||||
}
|
||||
for (const source of sources) {
|
||||
if (!prepared.has(source)) prepared.set(source, { status: 'error' });
|
||||
}
|
||||
while (cache.size >= MAX_PREPARE_CACHE_ENTRIES) cache.delete(cache.keys().next().value!);
|
||||
const allReady = [...prepared.values()].every((value) => value.status === 'ready');
|
||||
const grantExpiry = Math.min(...[...prepared.values()]
|
||||
.filter((value): value is Extract<PreparedMarkdownImage, { status: 'ready' }> => value.status === 'ready')
|
||||
.map((value) => value.expiresAt ?? Number.POSITIVE_INFINITY));
|
||||
cache.set(key, {
|
||||
result: prepared,
|
||||
expiresAt: allReady ? grantExpiry : Date.now() + NON_READY_CACHE_MS,
|
||||
});
|
||||
return prepared;
|
||||
};
|
||||
|
||||
export const resolveMarkdownImageSource = async (
|
||||
source: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> => {
|
||||
throwIfAborted(signal);
|
||||
if (/^(?:https?:)?\/\//i.test(source)) return source;
|
||||
if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)) {
|
||||
await validateDataImage(source);
|
||||
throwIfAborted(signal);
|
||||
return source;
|
||||
}
|
||||
throw new Error('Local image has not been prepared');
|
||||
};
|
||||
|
||||
/**
|
||||
* VS Code has no OpenChamber server route for message-scoped temporary-file
|
||||
* grants. Preserve its existing workspace-only gallery path through the local
|
||||
* filesystem bridge, including the same size and signature validation.
|
||||
*/
|
||||
export const resolveWorkspaceMarkdownImageSource = async (
|
||||
source: string,
|
||||
directory: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> => {
|
||||
throwIfAborted(signal);
|
||||
const localPath = parseLocalImagePath(source);
|
||||
const absolutePath = toAbsoluteFilePath(directory, localPath);
|
||||
if (!directory || !localPath || !isFilePathWithinDirectory(absolutePath, directory)) {
|
||||
throw new Error('Image path is outside the active workspace');
|
||||
}
|
||||
|
||||
const statResponse = await runtimeFetch('/api/fs/stat', {
|
||||
query: { path: absolutePath, directory, optional: 'true' },
|
||||
signal,
|
||||
});
|
||||
if (!statResponse.ok) throw new Error(`Unable to inspect image (${statResponse.status})`);
|
||||
const stat = await statResponse.json() as { isFile?: boolean; size?: number };
|
||||
if (!stat.isFile) throw new Error('Image path is not a file');
|
||||
if (typeof stat.size === 'number' && stat.size > MAX_MARKDOWN_IMAGE_BYTES) {
|
||||
throw new Error('Image is too large');
|
||||
}
|
||||
|
||||
const response = await runtimeFetch('/api/fs/raw', {
|
||||
query: { path: absolutePath, directory },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Unable to load image (${response.status})`);
|
||||
|
||||
const mimeType = (response.headers.get('content-type') ?? '').split(';', 1)[0]?.toLowerCase() ?? '';
|
||||
const contentLength = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_MARKDOWN_IMAGE_BYTES) {
|
||||
throw new Error('Image is too large');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
await validateImageBlob(blob, mimeType);
|
||||
throwIfAborted(signal);
|
||||
return blobToDataUrl(blob);
|
||||
};
|
||||
|
||||
export const getPreparedMarkdownImageUrl = (
|
||||
image: Extract<PreparedMarkdownImage, { status: 'ready' }>,
|
||||
directory: string,
|
||||
): string => getRuntimeUrlResolver().authenticatedAsset(
|
||||
'/api/fs/raw',
|
||||
{
|
||||
path: image.path,
|
||||
directory,
|
||||
allowOutsideWorkspace: image.outsideFileGrant ? 'true' : undefined,
|
||||
outsideFileGrant: image.outsideFileGrant,
|
||||
},
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user