Merge upstream/main into feat/shiki-re-highlighting-performance-dd3a

Conflict: packages/ui/src/components/chat/markdown/markdownCore.ts

main added per-image-mode markdown parsers (`imageMode` threaded through
`parseBlock` and into the block cache key); this branch replaced the
identity-keyed block cache with a content-addressed LRU. Resolution keeps the
content-addressed cache and folds `imageMode` into the content key, so the
`inline` and `label` renderings of the same source cannot answer for each other.
This commit is contained in:
Serhii Dziupin
2026-08-17 16:44:05 +03:00
466 changed files with 25608 additions and 10309 deletions
@@ -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,907 @@
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') {
// 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, 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 };
};
@@ -533,11 +533,27 @@ const DraftWelcome: React.FC = () => {
type ChatContainerProps = {
active?: boolean;
/**
* When set, controls message-history reads and session-message loads
* independently of `active`. Defaults to `active`. Embedded session-chat
* panels pass `true` so a delayed/lost visibility handshake cannot hide
* an already-materialized transcript (leaving only the working-status
* row — issue #2903).
*/
messagesEnabled?: boolean;
autoOpenDraft?: boolean;
readOnly?: boolean;
initialAllowPromptingSubagentSessions?: boolean;
};
export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, autoOpenDraft = true, readOnly = false }) => {
export const ChatContainer: React.FC<ChatContainerProps> = ({
active = true,
messagesEnabled: messagesEnabledProp,
autoOpenDraft = true,
readOnly = false,
initialAllowPromptingSubagentSessions,
}) => {
const messagesEnabled = messagesEnabledProp ?? active;
const { t } = useI18n();
// Session UI state
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
@@ -568,6 +584,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
const promptNavigatorEnabled = useUIStore((state) => state.promptNavigatorEnabled);
const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions);
const [embeddedAllowPrompting, setEmbeddedAllowPrompting] = React.useState(initialAllowPromptingSubagentSessions);
const isTimelineDialogOpen = useUIStore((s) => s.isTimelineDialogOpen);
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
@@ -589,9 +606,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
);
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '', effectiveSessionDirectory);
const hasRenderableSessionSnapshot = useSessionRenderable(currentSessionId ?? '', effectiveSessionDirectory);
// Messages from sync system
// Messages from sync system. Keep this gated by `messagesEnabled`, not
// `active`, so embedded panels can show history while the composer stays
// inactive until the parent confirms visibility.
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, {
enabled: active,
enabled: messagesEnabled,
suspendPartUpdates: Boolean(streamingMessageId),
suspendPartUpdatesForMessageId: streamingMessageId,
});
@@ -712,6 +731,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
const isVSCode = isVSCodeRuntime();
const chatSurfaceMode = useChatSurfaceMode();
const draftOpen = Boolean(newSessionDraft?.open);
// A draft can target another project or a pending worktree before it has a
// session. Keep the panel on that same directory so its project, MCP, and
// usage readouts describe where the draft will run rather than the project
// the user came from.
const workStatusDirectory = draftOpen
? newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory
: effectiveSessionDirectory;
const initError = useGlobalSyncStore((s) => s.error);
// Despite the historical name, this now covers mobile too: the mobile
// composer enters the same fullscreen-input mode via its drag handle.
@@ -722,12 +748,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
// row that holds both columns, so its width never depends on the panel's
// own visibility.
const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({
directory: effectiveSessionDirectory,
directory: workStatusDirectory,
isMobile,
isVSCode,
});
// Session view only. The draft branch returns its own layout before this
// one, so the panel has no place there yet.
// Surfaces that never host the panel skip it entirely; the rest keep it
// mounted so its visibility can animate rather than snap.
const workStatusPanelMountable = !isMobile
@@ -795,7 +819,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
{t('chat.container.returnToParent.label')}
</Button>
) : null;
const promptReadOnly = resolveChatPromptReadOnly(currentSession, allowPromptingSubagentSessions, readOnly);
const promptReadOnly = resolveChatPromptReadOnly(
currentSession,
embeddedAllowPrompting ?? allowPromptingSubagentSessions,
readOnly,
);
React.useEffect(() => {
// VS Code/Cursor/Positron webviews delete window.parent (and window.top).
@@ -808,6 +836,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
const parentWindow = window.parent;
const applySetting = (value: boolean) => {
setEmbeddedAllowPrompting(value);
useUIStore.getState().setAllowPromptingSubagentSessions(value);
};
const scopedWindow = window as typeof window & {
@@ -1030,9 +1059,9 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
Boolean(currentSessionId)
&& !hasRenderableSessionSnapshot;
const retrySessionLoad = React.useCallback(() => {
if (!active || !currentSessionId) return;
if (!messagesEnabled || !currentSessionId) return;
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
}, [active, currentSessionId, effectiveSessionDirectory, sync]);
}, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]);
React.useEffect(() => {
if (!active || !currentSessionId) return;
@@ -1057,10 +1086,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]);
React.useEffect(() => {
if (!active || !currentSessionId) return;
if (!messagesEnabled || !currentSessionId) return;
if (hasRenderableSessionSnapshot) return;
void ensureSessionRenderable(currentSessionId);
}, [active, currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot]);
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]);
if (!currentSessionId && !draftOpen) {
// With auto-open, the draft welcome opens on the next tick (effect below),
@@ -1082,20 +1111,37 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
// No transform on this root: it would become the containing block for
// the fullscreen composer's position:fixed visual-viewport pinning in
// mobile browsers (see ChatInput's composerFormRef effect).
<div data-composer-bound className="relative flex h-full flex-col bg-background">
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
<div
className={cn(
'relative z-10 flex min-h-0',
isDesktopExpandedInput
? 'flex-1 bg-background'
: useCompactDraftLayout
? 'bg-background px-0'
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col bg-background">
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
<div
className={cn(
'relative z-10 flex min-h-0',
isDesktopExpandedInput
? 'flex-1 bg-background'
: useCompactDraftLayout
? 'bg-background px-0'
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
{workStatusOverlayMountable ? (
<WorkStatusPanel
overlay
visible={showWorkStatusOverlay}
sessionId={null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div>
{workStatusPanelMountable ? (
<WorkStatusPanel
visible={showWorkStatusPanel}
sessionId={null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div>
);
}
@@ -1122,7 +1168,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
</div>
</div>
<div className="relative z-10 bg-background">
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
@@ -1176,7 +1222,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
: 'bg-background'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
@@ -1211,7 +1257,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
: 'bg-background'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
@@ -1269,7 +1315,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
onClick={navigation.resumeToLatest}
/>
)}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
{/* Inside the chat column, not beside it: as a row sibling it took
@@ -1280,7 +1326,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
overlay
visible={showWorkStatusOverlay}
sessionId={currentSessionId ?? null}
directory={effectiveSessionDirectory ?? null}
directory={workStatusDirectory ?? null}
/>
) : null}
@@ -1302,7 +1348,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
<WorkStatusPanel
visible={showWorkStatusPanel}
sessionId={currentSessionId ?? null}
directory={effectiveSessionDirectory ?? null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div>
+48 -8
View File
@@ -221,6 +221,7 @@ const MemoStatusRow = React.memo(StatusRow);
interface ChatInputProps {
onOpenSettings?: () => void;
scrollToBottom?: () => void;
active?: boolean;
}
const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
@@ -234,7 +235,7 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
};
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom, active = true }) => {
const { t } = useI18n();
// Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef<string | null>(null);
@@ -283,6 +284,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const suppressNextFileMentionPasteRef = React.useRef(false);
const suppressNextFileMentionPasteTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const shellTriggerNormalizationRef = React.useRef(false);
const pendingDroppedAbsolutePathsRef = React.useRef<string[]>([]);
const canAcceptDropRef = React.useRef(false);
const mentionRef = React.useRef<FileMentionHandle>(null);
@@ -962,6 +964,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const queuedMessageId = options?.queuedMessageId;
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
const capturedTarget = messageQueueTarget;
// Snapshot the draft and current-session identity before the first
// async gap so a later sidebar selection cannot reroute the send.
const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null;
const inputSnapshot = options?.presetText != null
? {
message: options.presetText,
@@ -1034,9 +1039,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
}
const sendMessageOptions = capturedTarget
? { target: capturedTarget, ...(delivery ? { delivery } : {}) }
: delivery ? { delivery } : undefined;
const sendMessageOptions: {
target?: NonNullable<typeof capturedTarget>;
draftSnapshot?: NonNullable<typeof capturedDraftSnapshot>;
delivery?: 'steer';
} | undefined = (capturedTarget || capturedDraftSnapshot || delivery)
? {
...(capturedTarget ? { target: capturedTarget } : {}),
...(capturedDraftSnapshot ? { draftSnapshot: capturedDraftSnapshot } : {}),
...(delivery ? { delivery } : {}),
}
: undefined;
// Inline review comments and synthetic context are consumed before
// assembly so a failed send can restore exactly what it took.
@@ -1402,6 +1415,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown.
if (isIMECompositionEvent(e)) return;
// Enter shell mode before CodeMirror inserts the trigger. Keeping the
// document unchanged also keeps the caret at the start for the first
// command character.
if (inputMode === 'normal' && e.key === '!') {
const selection = composerRef.current?.getSelection();
if (selection?.start === 0 && selection.end === 0) {
e.preventDefault();
setInputMode('shell');
closeAutocomplete();
return;
}
}
if (inputMode === 'shell' && e.key === 'Escape') {
e.preventDefault();
setInputMode('normal');
@@ -1705,6 +1731,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}, []);
const handleComposerChange = ({ value, selection, fromPaste, insertedText }: ComposerChange) => {
if (shellTriggerNormalizationRef.current) {
shellTriggerNormalizationRef.current = false;
setMessage(value);
return;
}
// VS Code drops the dragged path as text as well as firing the drop
// handler; swallow that duplicate insertion.
if (isVSCodeRuntime() && suppressNextFileDropTextInsertRef.current) {
@@ -1723,13 +1755,21 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const inputSource: FileMentionAutocompleteInputSource = isPasteInput ? 'paste' : 'manual';
// A leading `!` switches the composer into shell mode and is consumed.
// Mobile keyboards and paste may update the document without a usable
// keydown, so consume the trigger in the same editor transaction rather
// than moving the caret in a later frame against stale text.
if (inputMode === 'normal' && value.startsWith('!')) {
const shellCommand = value.slice(1);
const nextCursor = Math.max(0, selection.start - 1);
setInputMode('shell');
setMessage(shellCommand);
closeAutocomplete();
requestAnimationFrame(() => composerRef.current?.setSelection(nextCursor));
const editor = composerRef.current;
if (editor) {
shellTriggerNormalizationRef.current = true;
editor.replaceRange(0, 1, '', nextCursor);
} else {
setMessage(shellCommand);
}
return;
}
@@ -2000,10 +2040,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
React.useEffect(() => {
if (currentSessionId && composerRef.current && !isMobile) {
if (active && currentSessionId && composerRef.current && !isMobile) {
composerRef.current.focus();
}
}, [currentSessionId, isMobile]);
}, [active, currentSessionId, isMobile]);
React.useEffect(() => {
if (!isMobile) {
@@ -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 => {
@@ -48,3 +52,9 @@ export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typ
<SimpleMarkdownRendererLazy {...props} />
</React.Suspense>
);
export const MarkdownImageGallery: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownImageGalleryLazy>> = (props) => (
<React.Suspense fallback={null}>
<MarkdownImageGalleryLazy {...props} />
</React.Suspense>
);
@@ -1,6 +1,6 @@
import { describe, expect, 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);
@@ -96,3 +96,17 @@ 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();
});
});
@@ -19,7 +19,7 @@ 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 { renderMarkdownBlocks, renderMarkdownSync, type MarkdownImageMode } from './markdown/markdownCore';
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
import {
@@ -37,6 +37,7 @@ import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMerma
import {
BLOCK_PATH_TOKEN_RE,
isAbsoluteReferencePath,
localPathFromFileUrl,
normalizeReferencePath,
parseFileReference,
type ParsedFileReference,
@@ -245,6 +246,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;
}
@@ -831,6 +836,7 @@ const useMorphdomMarkdown = ({
text,
streaming,
cacheKey,
imageMode = 'inline',
syntaxVars,
ctx,
}: {
@@ -838,6 +844,7 @@ const useMorphdomMarkdown = ({
text: string;
streaming: boolean;
cacheKey: string;
imageMode?: MarkdownImageMode;
syntaxVars: Record<string, string>;
ctx: DecorateContext;
}) => {
@@ -876,7 +883,7 @@ const useMorphdomMarkdown = ({
// `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);
block.innerHTML = renderMarkdownSync(text, imageMode);
// Decorate synchronously too: wrap code blocks in their framed card,
// mark inline code, build table controls, etc. The async pass re-decorates
// its own DOM before morphing, so without this the first paint shows bare
@@ -888,7 +895,7 @@ const useMorphdomMarkdown = ({
refreshMermaidViewers();
}
}
}, [containerRef, text, ctx, refreshMermaidViewers]);
}, [containerRef, text, imageMode, ctx, refreshMermaidViewers]);
React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup();
@@ -901,7 +908,7 @@ const useMorphdomMarkdown = ({
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
let active = true;
void renderMarkdownBlocks(text, streaming, cacheKey).then((blocks) => {
void renderMarkdownBlocks(text, streaming, cacheKey, imageMode).then((blocks) => {
if (!active) return;
const existing = Array.from(target.children) as HTMLElement[];
@@ -952,7 +959,7 @@ const useMorphdomMarkdown = ({
return () => {
active = false;
};
}, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]);
}, [containerRef, text, streaming, cacheKey, imageMode, ctx, refreshMermaidViewers]);
React.useEffect(() => {
const container = containerRef.current;
@@ -1035,7 +1042,15 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx });
useMorphdomMarkdown({
containerRef,
text: content,
streaming: live,
cacheKey,
imageMode: variant === 'assistant' ? 'label' : 'inline',
syntaxVars,
ctx,
});
const markdownContent = (
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
@@ -0,0 +1,249 @@
/**
* 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('empty+busy branch skips empty state so StatusRowContainer can stand alone', () => {
expect(chatContainerSource).toContain('if (sessionMessages.length === 0 && !sessionIsWorking)');
expect(chatContainerSource).toContain('<ChatEmptyState');
expect(chatContainerSource).toContain('<StatusRowContainer />');
const emptyBusyGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
const emptyStateReturn = chatContainerSource.indexOf(emptyBusyGuard);
expect(emptyStateReturn).toBeGreaterThan(-1);
const emptyStateBlock = chatContainerSource.slice(
emptyStateReturn,
emptyStateReturn + 1600,
);
expect(emptyStateBlock).toContain('<ChatEmptyState');
expect(emptyStateBlock).not.toContain('<StatusRowContainer />');
});
test('visibility handshake remains as defense-in-depth for background work', () => {
expect(appSource).toContain('requestEmbeddedSessionVisibility();');
expect(appSource).toContain('EMBEDDED_VISIBILITY_UPDATE');
});
});
@@ -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;
}
@@ -344,6 +344,10 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view) return;
const current = view.state.doc.toString();
if (current === value) return;
// 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;
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
// An external rewrite (draft restore, history navigation,
@@ -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);
});
});
@@ -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);
}
@@ -152,7 +152,7 @@ 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
@@ -114,5 +114,3 @@ function matchMention(
});
return query === null ? null : { kind: 'mention', query };
}
export type { FileMentionAutocompleteInputSource };
@@ -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
@@ -54,7 +54,7 @@ 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 ? (
@@ -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 {
@@ -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) {
@@ -57,6 +57,7 @@ const ICONS = {
zoomOut: spriteIcon('subtract'),
fit: spriteIcon('refresh'),
textWrap: spriteIcon('text-wrap'),
image: spriteIcon('file-image'),
} as const;
const ICON_BTN_CLASS =
@@ -66,6 +67,18 @@ const setIconHtml = (el: Element, html: string): void => {
el.innerHTML = html;
};
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');
setIconHtml(icon, ICONS.image);
label.prepend(icon);
}
};
const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string): HTMLButtonElement => {
const button = document.createElement('button');
button.type = 'button';
@@ -487,6 +500,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);
@@ -1,6 +1,24 @@
import { describe, expect, test } from 'bun:test';
import { describe, expect, mock, test } from 'bun:test';
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
mock.module('dompurify', () => ({
default: {
isSupported: true,
addHook: () => undefined,
sanitize: (html: string) => html,
},
}));
mock.module('./markdown-worker', () => ({
highlightCodeInWorker: async () => null,
}));
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
const {
__markdownImageCandidateCacheForTests,
extractMarkdownImageCandidates,
renderMarkdownSync,
} = await import('./markdownCore');
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
describe('markdown sanitization', () => {
test('turns raw assistant HTML into inert visible text', () => {
@@ -15,4 +33,157 @@ 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);
});
});
describe('Markdown images', () => {
test('renders assistant images as icon-ready text without loading the source', () => {
const html = renderMarkdownSync([
'[linked image](packages/vscode/extension.jpg)',
'![image syntax](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)',
'![remote image](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 `![code](ignored.png)`.',
'',
'- ![duplicate](screens/first%20view.png)',
'- ![remote](https://example.test/second.webp?size=2)',
'',
'```md',
'![fenced](ignored-too.jpg)',
'```',
].join('\n'),
'After ![third](data:image/png;base64,AAAA).',
]);
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) => `![image ${index}](screens/${index}.png)`).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) => `![image ${index}](screens/${index}.png)`);
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) => `![image ${index}](screens/${index}.png)`,
);
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([`![](${source})`])).toEqual([
{ source, filename: 'image.png' },
]);
expect(renderMarkdownSync(`![](${source})`, '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([`![image ${index}](screens/${index}.png)`]);
}
const boundedStats = __markdownImageCandidateCacheForTests.stats();
expect(boundedStats.entries).toBe(1024);
expect(boundedStats.bytes <= 2 * 1024 * 1024).toBe(true);
__markdownImageCandidateCacheForTests.reset();
const oversized = `![image](screens/large.png)\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('![tool image](https://example.test/image.png)');
expect(html).toContain('<img src="https://example.test/image.png"');
expect(html).not.toContain('data-openchamber-markdown-image');
});
});
@@ -1,4 +1,4 @@
import { marked, type Tokens } from 'marked';
import { Marked, marked, type Tokens } from 'marked';
import remend from 'remend';
import katex from 'katex';
import DOMPurify from 'dompurify';
@@ -6,11 +6,169 @@ import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/mess
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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)
// ---------------------------------------------------------------------------
@@ -163,7 +321,7 @@ const blockMathExtension = {
},
};
const parser = marked.use({
const createParser = (imageMode: MarkdownImageMode) => new Marked().use({
gfm: true,
breaks: false,
extensions: [inlineMathExtension, blockMathExtension],
@@ -187,9 +345,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
// ---------------------------------------------------------------------------
@@ -308,6 +470,10 @@ 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;
if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true;
});
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (!(node instanceof HTMLAnchorElement)) return;
if (node.target !== '_blank') return;
@@ -346,14 +512,16 @@ export const markdownBlockCacheKey = (
contentHash: string,
mode: MarkdownBlock['mode'],
highlight: boolean,
): string => `${contentHash}:${mode}:${highlight ? 1 : 0}`;
imageMode: MarkdownImageMode,
): string => `${contentHash}:${mode}:${highlight ? 1 : 0}:${imageMode}`;
/** Test-only: clear the render HTML cache between cases. */
export const resetMarkdownHtmlCacheForTests = (): void => {
htmlCache.clear();
};
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;
@@ -369,8 +537,9 @@ 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);
@@ -398,6 +567,7 @@ export const renderMarkdownBlocks = async (
text: string,
streaming: boolean,
cacheKey: string,
imageMode: MarkdownImageMode = 'inline',
): Promise<RenderedBlock[]> => {
// Retained for call-site compatibility / debugging; lookup is content-addressed.
void cacheKey;
@@ -407,12 +577,12 @@ export const renderMarkdownBlocks = async (
return Promise.all(
blocks.map(async (block) => {
const contentHash = contentFingerprint(block.raw);
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight);
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
const cached = htmlCache.get(id);
if (cached !== undefined) {
return { id, html: cached };
}
const html = await parseBlock(block);
const html = await parseBlock(block, imageMode);
htmlCache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
return { id, html };
}),
@@ -151,10 +151,12 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => {
expect(highlightCalls).toBe(afterFirst + 1);
});
test('block cache keys are content-addressed (mode + highlight + hash)', () => {
expect(markdownBlockCacheKey('abc', 'full', true)).toBe('abc:full:1');
expect(markdownBlockCacheKey('abc', 'live', false)).toBe('abc:live:0');
expect(markdownBlockCacheKey('abc', 'full', true)).not.toBe(markdownBlockCacheKey('abc', 'full', false));
test('block cache keys are content-addressed (mode + highlight + imageMode + hash)', () => {
expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).toBe('abc:full:1:inline');
expect(markdownBlockCacheKey('abc', 'live', false, 'inline')).toBe('abc:live:0:inline');
expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).not.toBe(markdownBlockCacheKey('abc', 'full', false, 'inline'));
// Image mode changes the rendered HTML, so it must not share a cache entry.
expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).not.toBe(markdownBlockCacheKey('abc', 'full', true, 'label'));
});
test('multiple code fences in one document highlight concurrently', async () => {
@@ -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,
},
);
@@ -4,3 +4,12 @@ export const escapeRawMarkdownHtml = (value: string): string =>
/** Active elements forbidden again at the final DOMPurify boundary. */
export const MARKDOWN_FORBIDDEN_TAGS = ['script', 'style'] as const;
export const isLocalFileUrl = (value: string): boolean => {
try {
const parsed = new URL(value);
return parsed.protocol === 'file:' && (!parsed.hostname || parsed.hostname === 'localhost');
} catch {
return false;
}
};
@@ -21,7 +21,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messages/messageText';
@@ -56,6 +56,7 @@ import {
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
import { useProviderLogo } from '@/hooks/useProviderLogo';
import { getAgentColor } from '@/lib/agentColors';
import { isCapacitorMobileApp } from '@/apps/mobileNativeChrome';
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
@@ -1211,6 +1212,11 @@ const AssistantMessageBody = React.memo(({
const assistantTextParts = React.useMemo(() => {
return visibleParts.filter((part) => part.type === 'text');
}, [visibleParts]);
const finalizedAssistantMarkdownContents = React.useMemo(() => (
isMessageCompleted
? assistantTextParts.map(extractTextContent).filter((text) => text.trim().length > 0)
: []
), [assistantTextParts, isMessageCompleted]);
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
@@ -1612,6 +1618,13 @@ const AssistantMessageBody = React.memo(({
}
throw new Error(payload.error || 'Failed to save image in VS Code');
}
} else if (isCapacitorMobileApp()) {
const blob = await fetch(dataUrl).then((response) => response.blob());
const file = new File([blob], fileName, { type: blob.type || 'image/png' });
if (!navigator.canShare?.({ files: [file] })) {
throw new Error('Image sharing is unavailable in this mobile runtime');
}
await navigator.share({ files: [file] });
} else {
const link = document.createElement('a');
link.download = fileName;
@@ -2228,6 +2241,12 @@ const AssistantMessageBody = React.memo(({
)}
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
<MarkdownImageGallery
sessionId={sessionId}
messageId={messageId}
contents={finalizedAssistantMarkdownContents}
onShowPopup={onShowPopup}
/>
{shouldRenderStandaloneActionsAfterContent && (
<div className={INLINE_MESSAGE_ACTIONS_CLASS_NAME} data-message-actions="true">
<div className="flex items-center gap-1.5" data-message-action-group="true">
@@ -55,6 +55,32 @@ Use this doc when you ask an agent to change tool/header/description behavior.
HTML is sanitized as defense in depth, with script and style elements
forbidden, so message content cannot inject active DOM or application-wide
CSS into any runtime surface.
- Final assistant Markdown rendering is independent from image gallery
extraction: gallery presence never changes the chat body. Assistant image
syntax consistently renders as a shared image icon followed by its filename,
without loading the image in the body; tool and simple Markdown retain normal
inline image rendering. The gallery separately collects HTTP(S), embedded, and workspace-local
PNG/JPEG/GIF/WebP image candidates into one 100px thumbnail gallery in the
message-completion area after all message text and above the turn's changed
files. Each muted filename caption includes the shared image-file icon.
HTTP(S) images keep their browser URL. Embedded and workspace-local images
are limited to 10 MiB and validated as PNG/JPEG/GIF/WebP. Chat Markdown uses
the assistant image-label policy without gallery-specific link rewriting,
completion-state switching, or hidden placeholders. A
completed assistant message hydrates at most 12 unique image candidates,
including persisted text parts that omit their optional part-level end time.
In server-backed runtimes, a gallery approaching the viewport prepares all
local candidates in one message-level request, then reuses the authenticated
`/api/fs/raw` asset route. Each URL loads only when its thumbnail approaches
the viewport. VS Code instead loads workspace-contained images through its
local filesystem bridge and never calls the server grant route; OpenCode
temporary-directory images remain unsupported there. Mounted historical
messages therefore do not eagerly read every image.
Gallery clicks do not introduce or alter preview chrome: desktop and mobile
both reuse the pre-existing attachment image preview overlay.
Workspace-external images receive the existing path-bound `outsideFileGrant`
only when the server verifies the exact source in the owning assistant
message and the real file is inside OpenCode's dedicated temporary directory.
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`.
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
@@ -1258,6 +1258,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
);
const hasVisualDiffEntry = diffEntries.some((entry) => entry.renderMode === 'diff');
const hideToolInputPreview = part.tool === 'openchamber'
|| part.tool === 'openchamber_web'
|| part.tool === 'apply_patch'
|| part.tool === 'edit'
|| part.tool === 'multiedit';
@@ -56,6 +56,9 @@ export const getToolIcon = (toolName: string) => {
if (tool === 'openchamber') {
return <Icon name="openchamber" className={iconClass} />;
}
if (tool === 'openchamber_web') {
return <Icon name="global" className={iconClass} />;
}
if (tool === 'question') {
return <Icon name="survey" className={iconClass} />;
}
@@ -58,7 +58,7 @@ const toSelectionNode = (node: Node): SelectionNode | null => {
};
};
export const trimSelectionNodes = (nodes: SelectionNode[]): SelectionNode[] => {
const trimSelectionNodes = (nodes: SelectionNode[]): SelectionNode[] => {
return nodes
.filter((node) => node.type === 'text' || !node.isCodeLineNumber)
.map((node) => node.type === 'text'
@@ -86,4 +86,20 @@ describe('buildRevertedMessageDockState', () => {
expect(second).not.toBe(first);
expect(second.records).toHaveLength(1);
});
test('collects a post-rollover reverted tail by marker position', () => {
const before = message('msg_ffffffffffffBefore', 'user');
const marker = message('msg_000000000000Marker', 'user');
const after = message('msg_000000000001After', 'user');
const snapshot = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: marker.id } } as State['session'][number]],
message: { ses_1: [before, marker, after] },
}),
'ses_1',
);
expect(snapshot.records.map((record) => record.message.id)).toEqual([marker.id, after.id]);
});
});
@@ -1,5 +1,6 @@
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from '@/sync/types';
import { findMessageIndex } from '@/sync/message-ordering';
type RevertedMessageRecord = {
message: Message & { role: 'user' };
@@ -50,9 +51,14 @@ export const buildRevertedMessageDockState = (
}
const messages = state.message[sessionId] ?? [];
const revertIndex = findMessageIndex(messages, revertMessageID);
if (revertIndex < 0) {
return EMPTY_REVERTED_MESSAGE_DOCK_STATE;
}
const records: RevertedMessageRecord[] = [];
for (const message of messages) {
if (!isUserMessage(message) || message.id < revertMessageID) {
for (let index = revertIndex; index < messages.length; index += 1) {
const message = messages[index];
if (!isUserMessage(message)) {
continue;
}
records.push({
@@ -50,10 +50,9 @@ exactly as it already does when the context panel opens.
`WORK_STATUS_PANEL_WIDTH` of panel.
`ChatContainer` additionally suppresses it in mini-chat and in expanded-input
mode, and the panel does not appear on a new-session draft: that branch returns
its own layout before the one that hosts the panel. The repository readouts
would apply there — branch and working-tree state inform what to ask for — so
this is a gap worth closing rather than a decision.
mode. It remains available on a new-session draft: when the draft targets a
project or pending worktree, the panel uses that directory for project, MCP,
and usage readouts before a session exists.
`rowRef` is a **callback ref, not an object ref**. An object ref gives no signal
when the node attaches, so the measuring effect read `.current`, found nothing
@@ -82,7 +81,8 @@ and therefore displaces nothing.
## Data sources
Everything is read from already-warm caches. The panel adds no aggregated
endpoint and no polling of its own.
endpoint; quota data refreshes through the shared fixed three-minute quota timer,
which requests only providers enabled for this panel.
| Block | Source | Notes |
|---|---|---|
@@ -170,7 +170,7 @@ from aggregating message summaries, not from `Session.summary`.
Ordering is by durability, not category:
1. **Session** (goal, context, cost), **Repository** (attention, branch,
1. **Session** (goal, context, cost), **Project** (attention, branch,
changes, PR, checks) and **Usage** — true for as long as the session is
open. Usage sits here rather than lower down because a spent quota stops the
work outright;
@@ -203,6 +203,11 @@ section decides for itself that it has nothing to say, so they report through
`presenceContext.ts` and the panel collapses when none rendered. Deriving that
at the panel level would mean duplicating every data source the sections read.
There is one deliberate exception: when the user hides every section, the card
stays visible with a localized empty state and section controls. Collapsing that
state would also hide the only recovery path. A panel with enabled sections but
no data still follows the presence reports and collapses as before.
The scroll offset resets on session change: restoring one session's offset into
another's shorter panel lands somewhere arbitrary.
@@ -210,6 +215,10 @@ The Subagents section opens itself when subagents appear where there were none,
on that edge only: re-expanding on every count change would fight a user who
just collapsed it.
Its expanded list is capped at eight rows and scrolls independently, so a
session with many subagents does not crowd every section below it out of the
panel.
## Tasks
Icons and strike-through match the composer's todo dropdown, so one list does
@@ -315,8 +324,8 @@ Two readouts had no loader of their own and appeared only after the user opened
the matching header dropdown:
- **MCP**`McpDropdown` was the only mount-time caller of `refresh()`.
- **Usage**`useQuotaAutoRefresh` merely schedules an interval; the *first*
fetch was performed by the dropdown's open handler.
- **Usage**`useQuotaAutoRefresh` schedules the shared fixed three-minute
refresh; the *first* fetch was performed by the dropdown's open handler.
- **Skills**`loadSkills()` ran only when the composer's slash autocomplete
opened, so the context-sources count was whatever happened to be cached. The
section loads them itself, keyed on the directory, since skills are
@@ -325,7 +334,8 @@ the matching header dropdown:
The panel now performs these itself, silently and through the
background-network gate, so it cannot compete with chat bootstrap traffic for
sockets. A panel that reports a subsystem's state cannot depend on an unrelated
sockets. Usage additionally provides an explicit refresh action in its section
header. A panel that reports a subsystem's state cannot depend on an unrelated
component having been mounted or opened.
The repository section follows the same ownership rule. It subscribes directly
@@ -5,7 +5,6 @@ import { useMcpStore } from '@/stores/useMcpStore';
import { McpIcon } from '@/components/icons/McpIcon';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { toast } from 'sonner';
import { isVSCodeRuntime } from '@/lib/desktop';
import { startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusRowAction } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
@@ -54,7 +53,6 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
const { opened } = await startMcpAuthorization({
name,
directory,
skipRedirectUriBootstrap: isVSCodeRuntime(),
});
if (!opened) {
toast.error(t('chat.workStatus.mcp.authorizeOpenFailed'));
@@ -2,6 +2,7 @@ import React from 'react';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Button } from '@/components/ui/button';
import { useUIStore } from '@/stores/useUIStore';
import { WORK_STATUS_PANEL_WIDTH } from './useWorkStatusVisibility';
import { WorkStatusGoalRow } from './WorkStatusGoalRow';
@@ -13,7 +14,11 @@ import { WorkStatusMcpSection } from './WorkStatusMcpSection';
import { WorkStatusPinnedSection } from './WorkStatusPinnedSection';
import { WorkStatusContextSection } from './WorkStatusContextSection';
import { WorkStatusSectionsDialog } from './WorkStatusSectionsDialog';
import { isWorkStatusSectionVisible } from './sections';
import {
areAllWorkStatusSectionsHidden,
getWorkStatusPanelPresentation,
isWorkStatusSectionVisible,
} from './sections';
import { WorkStatusPresenceProvider } from './presence';
import { Icon } from '@/components/icon/Icon';
@@ -82,9 +87,19 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
// out with something in it rather than emptying first, and its subscriptions
// stop once it is truly gone.
const [contentMounted, setContentMounted] = React.useState(visible);
// Hidden, mid-collapse, or reporting nothing: in each case the card is not
// something the user can act on, so it should not be reachable.
const interactive = visible && renderedSections > 0;
// Hidden or mid-collapse: the card is not something the user can act on.
// When `visible` but all sections are hidden, the panel stays interactive so
// the settings button remains reachable — otherwise there is no way to
// re-enable sections. The previous `renderedSections > 0` guard is preserved
// for the transient "no data yet" state so the panel doesn't flash a bare
// bordered card on first mount.
const allSectionsHidden = areAllWorkStatusSectionsHidden(hiddenSections);
const { interactive, showEmptyState } = getWorkStatusPanelPresentation({
visible,
contentMounted,
renderedSections,
allSectionsHidden,
});
React.useEffect(() => {
if (visible) {
setContentMounted(true);
@@ -180,9 +195,9 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
// separates the two without going fully opaque.
'oc-glass-panel',
],
// An empty card is a border around a settings icon, which reads as a
// fault rather than as "nothing to report".
renderedSections === 0 && 'border-transparent bg-transparent shadow-none',
// When every section is hidden the card keeps its border and background
// so the settings button stays discoverable — going transparent made the
// only recovery path unreachable.
'motion-reduce:transition-none',
'rounded-xl border border-[var(--interactive-border)]',
!overlay && 'bg-[var(--surface-muted)]/40',
@@ -246,6 +261,20 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
</WorkStatusPresenceProvider>
) : null}
{showEmptyState ? (
<div className="flex flex-col items-center justify-center px-4 py-8 text-center">
<span className="text-sm text-muted-foreground">{t('chat.workStatus.sections.allHidden')}</span>
<Button
variant="link"
size="xs"
onClick={() => setSectionsDialogOpen(true)}
className="mt-2 normal-case text-muted-foreground hover:text-foreground"
>
{t('chat.workStatus.sections.open')}
</Button>
</div>
) : null}
<WorkStatusSectionsDialog open={sectionsDialogOpen} onOpenChange={setSectionsDialogOpen} />
</aside>
);
@@ -8,7 +8,8 @@ import { useSession, useSessionMessages } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { normalizeProjectPath } from '@/lib/projectResolution';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { resolveUsageTone } from '@/lib/quota';
import { sessionEvents } from '@/lib/sessionEvents';
import { normalizePath } from '@/lib/pathNormalization';
@@ -85,27 +86,22 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
const branch = gitStatus?.current?.trim() || null;
// The panel's directory can be a worktree, so the project is the registered
// one whose path contains it — longest match wins, since projects can nest.
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
// Worktrees normally sit beside rather than beneath their project directory,
// so a prefix match alone cannot find their owning project. Reuse the shared
// session-directory resolver, which consults the discovered worktree map.
const projectLabel = useProjectsStore(
React.useCallback((state) => {
const normalizedDirectory = normalizeProjectPath(directory ?? null);
if (!normalizedDirectory) return null;
let best: { path: string; label: string } | null = null;
for (const project of state.projects) {
const projectPath = normalizeProjectPath(project.path);
if (!projectPath) continue;
const contains = normalizedDirectory === projectPath
|| normalizedDirectory.startsWith(`${projectPath}/`);
if (!contains) continue;
if (best && best.path.length >= projectPath.length) continue;
const label = project.label?.trim()
|| projectPath.split('/').filter(Boolean).pop()
|| projectPath;
best = { path: projectPath, label };
}
return best?.label ?? null;
}, [directory]),
const project = resolveProjectForSessionDirectory(
state.projects,
availableWorktreesByProject,
directory,
);
if (!project) return null;
return project.label?.trim()
|| project.path.split('/').filter(Boolean).pop()
|| project.path;
}, [availableWorktreesByProject, directory]),
);
// Read-only: PR watching is owned by the background tracker. Starting a watch
@@ -242,7 +238,7 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
{hasRepository ? (
<WorkStatusSection
title={t('chat.workStatus.section.repository')}
title={t('chat.workStatus.section.project')}
summary={projectLabel}
>
{attentionLabel ? <WorkStatusCallout>{attentionLabel}</WorkStatusCallout> : null}
@@ -63,9 +63,11 @@ export const WorkStatusCollapsibleSection: React.FC<{
iconColor?: string;
/** Shown on the header while collapsed and expanded alike. */
summary?: React.ReactNode;
/** An independent header action, such as refreshing this section's data. */
action?: React.ReactNode;
defaultExpanded?: boolean;
children: React.ReactNode;
}> = ({ id, title, icon, iconNode, iconColor, summary, defaultExpanded = false, children }) => {
}> = ({ id, title, icon, iconNode, iconColor, summary, action, defaultExpanded = false, children }) => {
const stored = useUIStore(
React.useCallback((state) => state.workStatusExpandedSections[id], [id]),
);
@@ -73,35 +75,38 @@ export const WorkStatusCollapsibleSection: React.FC<{
const expanded = stored ?? defaultExpanded;
return (
<section className={SECTION_CLASS}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpandedInStore(id, !expanded)}
className={cn(
'group/section mb-0.5 flex h-6 items-center gap-1.5 rounded-md px-1 text-left',
// No hover fill anywhere in the panel: at this row density the blocks
// of colour read as selection, not as affordance. Interactivity shows
// through the text instead.
'transition-colors hover:text-foreground',
)}
>
{iconNode ?? (icon ? (
<div className="mb-0.5 flex h-6 items-center gap-1">
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpandedInStore(id, !expanded)}
className={cn(
'group/section flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 text-left',
// No hover fill anywhere in the panel: at this row density the blocks
// of colour read as selection, not as affordance. Interactivity shows
// through the text instead.
'transition-colors hover:text-foreground',
)}
>
{iconNode ?? (icon ? (
<Icon
name={icon}
className={cn('size-4 shrink-0', !iconColor && 'text-muted-foreground')}
style={iconColor ? { color: iconColor } : undefined}
/>
) : null)}
<span className={cn(HEADING_CLASS, 'min-w-0 truncate')}>{title}</span>
<Icon
name={icon}
className={cn('size-4 shrink-0', !iconColor && 'text-muted-foreground')}
style={iconColor ? { color: iconColor } : undefined}
name={expanded ? 'arrow-down-s' : 'arrow-right-s'}
className="size-3.5 shrink-0 text-muted-foreground"
/>
) : null)}
<span className={cn(HEADING_CLASS, 'min-w-0 truncate')}>{title}</span>
<Icon
name={expanded ? 'arrow-down-s' : 'arrow-right-s'}
className="size-3.5 shrink-0 text-muted-foreground"
/>
<span className="flex-1" />
{summary !== undefined && summary !== null ? (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{summary}</span>
) : null}
</button>
<span className="flex-1" />
{summary !== undefined && summary !== null ? (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{summary}</span>
) : null}
</button>
{action}
</div>
{expanded ? children : null}
</section>
);
@@ -2,6 +2,7 @@ import React from 'react';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { SettingsCheckboxRow } from '@/components/sections/shared/SettingsSection';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@@ -12,6 +13,7 @@ import {
import {
WORK_STATUS_SECTION_IDS,
WORK_STATUS_SECTION_LABEL_KEYS,
areAllWorkStatusSectionsHidden,
isWorkStatusSectionVisible,
} from './sections';
@@ -29,6 +31,12 @@ export const WorkStatusSectionsDialog: React.FC<{
const { t } = useI18n();
const hidden = useUIStore((state) => state.workStatusHiddenSections);
const setSectionVisible = useUIStore((state) => state.setWorkStatusSectionVisible);
const setHiddenSections = useUIStore((state) => state.setWorkStatusHiddenSections);
const allVisible = hidden.length === 0;
const noneVisible = areAllWorkStatusSectionsHidden(hidden);
const handleShowAll = () => setHiddenSections([]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -50,6 +58,22 @@ export const WorkStatusSectionsDialog: React.FC<{
/>
))}
</div>
{!allVisible ? (
<div className="flex items-center justify-between border-t pt-3">
{noneVisible ? (
<span className="text-xs text-destructive">{t('chat.workStatus.sections.noneWarning')}</span>
) : <span />}
<Button
variant="link"
size="xs"
onClick={handleShowAll}
className="normal-case text-muted-foreground hover:text-foreground"
>
{t('chat.workStatus.sections.showAll')}
</Button>
</div>
) : null}
</DialogContent>
</Dialog>
);
@@ -82,29 +82,31 @@ export const WorkStatusSubagentsSection: React.FC<Props> = ({ sessionId, directo
defaultExpanded
summary={busyChildren > 0 ? `${busyChildren}/${children.length}` : children.length}
>
{children.map((child) => {
const blocked = (permissions[child.id]?.length ?? 0) > 0;
const asked = (questions[child.id]?.length ?? 0) > 0;
const busy = statuses[child.id]?.type === 'busy';
const label = child.title?.trim() || t('chat.workStatus.subagent.untitled');
return (
<WorkStatusRow
key={child.id}
onClick={directory ? () => openChildSession(child.id, label) : undefined}
ariaLabel={t('chat.workStatus.action.openSubagent', { name: label })}
label={label}
value={blocked ? (
<WorkStatusValue tone="warning">{t('chat.workStatus.subagent.needsPermission')}</WorkStatusValue>
) : asked ? (
<WorkStatusValue tone="warning">{t('chat.workStatus.subagent.askedQuestion')}</WorkStatusValue>
) : busy ? (
<WorkStatusValue tone="info">{t('chat.workStatus.subagent.working')}</WorkStatusValue>
) : (
<WorkStatusValue tone="muted">{t('chat.workStatus.subagent.done')}</WorkStatusValue>
)}
/>
);
})}
<div className="max-h-56 overflow-y-auto">
{children.map((child) => {
const blocked = (permissions[child.id]?.length ?? 0) > 0;
const asked = (questions[child.id]?.length ?? 0) > 0;
const busy = statuses[child.id]?.type === 'busy';
const label = child.title?.trim() || t('chat.workStatus.subagent.untitled');
return (
<WorkStatusRow
key={child.id}
onClick={directory ? () => openChildSession(child.id, label) : undefined}
ariaLabel={t('chat.workStatus.action.openSubagent', { name: label })}
label={label}
value={blocked ? (
<WorkStatusValue tone="warning">{t('chat.workStatus.subagent.needsPermission')}</WorkStatusValue>
) : asked ? (
<WorkStatusValue tone="warning">{t('chat.workStatus.subagent.askedQuestion')}</WorkStatusValue>
) : busy ? (
<WorkStatusValue tone="info">{t('chat.workStatus.subagent.working')}</WorkStatusValue>
) : (
<WorkStatusValue tone="muted">{t('chat.workStatus.subagent.done')}</WorkStatusValue>
)}
/>
);
})}
</div>
</WorkStatusCollapsibleSection>
);
};
@@ -1,5 +1,7 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { preloadProviderLogos } from '@/hooks/useProviderLogo';
@@ -43,7 +45,7 @@ export const WorkStatusUsageSection: React.FC = () => {
const isLoading = useQuotaStore((state) => state.isLoading);
const quotaResults = useQuotaStore((state) => state.results);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const fetchQuotas = useQuotaStore((state) => state.fetchQuotas);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
@@ -61,8 +63,8 @@ export const WorkStatusUsageSection: React.FC = () => {
(providerId) => !quotaResults.some((result) => result.providerId === providerId),
);
if (!missingProvider) return;
void runBackgroundNetworkTask(() => fetchAllQuotas());
}, [dropdownProviderIds, fetchAllQuotas, isLoading, quotaResults]);
void runBackgroundNetworkTask(() => fetchQuotas(dropdownProviderIds));
}, [dropdownProviderIds, fetchQuotas, isLoading, quotaResults]);
React.useEffect(() => {
if (groups.length === 0) return;
@@ -96,7 +98,6 @@ export const WorkStatusUsageSection: React.FC = () => {
icon="timer"
summary={(
<span className="inline-flex items-center gap-1.5">
{isLoading ? <Icon name="refresh" className="size-3 animate-spin" /> : null}
{headline && headlineMetric && headlineMetric !== '-' ? (
<>
<span className="truncate">{headline.row.label}</span>
@@ -105,6 +106,19 @@ export const WorkStatusUsageSection: React.FC = () => {
) : modeLabel}
</span>
)}
action={(
<Button
size="icon"
variant="ghost"
className="size-6 shrink-0 text-muted-foreground"
onClick={() => void fetchQuotas(dropdownProviderIds)}
aria-label={t('settings.usage.sidebar.actions.refreshAria')}
title={t('settings.usage.sidebar.actions.refreshTitle')}
disabled={isLoading}
>
<Icon name="refresh" className={cn('size-3.5', isLoading && 'animate-spin')} />
</Button>
)}
>
{groups.map((group) => (
<React.Fragment key={group.providerId}>
@@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test';
import {
WORK_STATUS_SECTION_IDS,
WORK_STATUS_SECTION_LABEL_KEYS,
areAllWorkStatusSectionsHidden,
getWorkStatusPanelPresentation,
isWorkStatusSectionVisible,
sanitizeWorkStatusHiddenSections,
} from './sections';
@@ -30,6 +32,75 @@ describe('isWorkStatusSectionVisible', () => {
});
});
describe('areAllWorkStatusSectionsHidden', () => {
test('returns false when no sections are hidden', () => {
expect(areAllWorkStatusSectionsHidden([])).toBe(false);
});
test('returns false for null and undefined', () => {
expect(areAllWorkStatusSectionsHidden(null)).toBe(false);
expect(areAllWorkStatusSectionsHidden(undefined)).toBe(false);
});
test('returns false when only some sections are hidden', () => {
expect(areAllWorkStatusSectionsHidden(['usage', 'tasks'])).toBe(false);
});
test('returns true when every known section is hidden', () => {
expect(areAllWorkStatusSectionsHidden([...WORK_STATUS_SECTION_IDS])).toBe(true);
});
test('ignores stale ids that are no longer in the section list', () => {
// A future section-ID removal should not trick the length check into
// reporting all-hidden when real sections are still visible.
const withStale = [...WORK_STATUS_SECTION_IDS.slice(0, -1), 'removed_section'];
expect(areAllWorkStatusSectionsHidden(withStale)).toBe(false);
});
test('returns true even with extra stale ids alongside all real ones', () => {
const withExtra = [...WORK_STATUS_SECTION_IDS, 'removed_section'];
expect(areAllWorkStatusSectionsHidden(withExtra)).toBe(true);
});
});
describe('getWorkStatusPanelPresentation', () => {
test('keeps a visible all-hidden panel interactive and renders its recovery state', () => {
expect(getWorkStatusPanelPresentation({
visible: true,
contentMounted: true,
renderedSections: 0,
allSectionsHidden: true,
})).toEqual({ interactive: true, showEmptyState: true });
});
test('covers the optimistic fresh-mount count when all sections are hidden', () => {
expect(getWorkStatusPanelPresentation({
visible: true,
contentMounted: true,
renderedSections: 1,
allSectionsHidden: true,
})).toEqual({ interactive: true, showEmptyState: true });
});
test('preserves collapse when no section has data but sections remain enabled', () => {
expect(getWorkStatusPanelPresentation({
visible: true,
contentMounted: true,
renderedSections: 0,
allSectionsHidden: false,
})).toEqual({ interactive: false, showEmptyState: false });
});
test('does not expose controls or the empty state during a hidden collapse', () => {
expect(getWorkStatusPanelPresentation({
visible: false,
contentMounted: false,
renderedSections: 0,
allSectionsHidden: true,
})).toEqual({ interactive: false, showEmptyState: false });
});
});
describe('sanitizeWorkStatusHiddenSections', () => {
test('keeps known ids and drops everything else', () => {
expect(sanitizeWorkStatusHiddenSections(['usage', 'nope', 42, null, 'tasks']))
@@ -25,7 +25,7 @@ type WorkStatusSectionId = (typeof WORK_STATUS_SECTION_IDS)[number];
export const WORK_STATUS_SECTION_LABEL_KEYS: Record<WorkStatusSectionId, I18nKey> = {
session: 'chat.workStatus.section.session',
repository: 'chat.workStatus.section.repository',
repository: 'chat.workStatus.section.project',
usage: 'chat.workStatus.section.usage',
subagents: 'chat.workStatus.section.subagents',
tasks: 'chat.workStatus.section.tasks',
@@ -49,6 +49,32 @@ export const isWorkStatusSectionVisible = (
id: WorkStatusSectionId,
): boolean => !hidden?.includes(id);
/**
* True when every known section id appears in the hidden set.
*
* Uses `.every()` instead of a length comparison so that stale ids left over
* from a removed section cannot inflate the count past the current list length.
*/
export const areAllWorkStatusSectionsHidden = (
hidden: readonly string[] | null | undefined,
): boolean =>
hidden != null && WORK_STATUS_SECTION_IDS.every((id) => hidden.includes(id));
export const getWorkStatusPanelPresentation = ({
visible,
contentMounted,
renderedSections,
allSectionsHidden,
}: {
visible: boolean;
contentMounted: boolean;
renderedSections: number;
allSectionsHidden: boolean;
}): { interactive: boolean; showEmptyState: boolean } => ({
interactive: visible && (renderedSections > 0 || allSectionsHidden),
showEmptyState: contentMounted && allSectionsHidden,
});
export const sanitizeWorkStatusHiddenSections = (value: unknown): WorkStatusSectionId[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<WorkStatusSectionId>();
@@ -36,6 +36,10 @@ describe('resolveQuotaProviderId', () => {
expect(resolveQuotaProviderId('anthropic')).toBe('claude');
});
test('maps the opencode-claude integration provider onto Claude quota', () => {
expect(resolveQuotaProviderId('claude-code')).toBe('claude');
});
test('is case and whitespace tolerant, and rejects empties', () => {
expect(resolveQuotaProviderId(' OpenAI ')).toBe('codex');
expect(resolveQuotaProviderId('')).toBeNull();
@@ -13,11 +13,15 @@ import type { UsageProviderGroup, UsageLimitRow } from '@/components/usage/usage
/**
* Quota provider ids mostly match OpenCode provider ids; these are the ones
* that do not. Unmatched providers simply produce no headline.
*
* `claude-code` is the provider the opencode-claude integration registers, and
* it bills against the same Claude subscription the `claude` quota reports.
*/
const QUOTA_PROVIDER_ALIASES = new Map<string, string>([
['openai', 'codex'],
['chatgpt', 'codex'],
['anthropic', 'claude'],
['claude-code', 'claude'],
['gemini', 'google'],
]);
+24 -1
View File
@@ -30,6 +30,29 @@ function ensureSpriteOnce() {
spriteInjected = true
}
/**
* Append a single missing symbol. Needed when the sprite was injected before a
* newly generated icon landed (HMR / late sprite regenerate) a one-shot inject
* would otherwise leave `<use href="#oc-…"/>` pointing at nothing.
*/
function ensureSpriteSymbol(name: IconName) {
if (typeof document === "undefined") return
ensureSpriteOnce()
if (document.getElementById(`oc-${name}`)) return
const content = iconSpriteData[name]
if (typeof content !== "string") return
const sprite = document.getElementById(SPRITE_ID)
if (!sprite) return
const symbol = document.createElementNS("http://www.w3.org/2000/svg", "symbol")
symbol.id = `oc-${name}`
symbol.setAttribute("viewBox", "0 0 24 24")
symbol.innerHTML = content
sprite.appendChild(symbol)
}
export interface IconProps extends React.ComponentPropsWithoutRef<"svg"> {
name: IconName
}
@@ -38,7 +61,7 @@ export const Icon = React.memo(({ name, className, ...rest }: IconProps) => {
// Inline sprite injection during render must run before <use> tries
// to resolve the #oc-* reference during the same commit.
if (typeof document !== "undefined") {
ensureSpriteOnce()
ensureSpriteSymbol(name)
}
return (
+5 -1
View File
@@ -51,6 +51,7 @@ export const iconSpriteData = {
"checkbox-blank-circle-fill": `<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="currentColor"/>`,
"checkbox-circle": `<path d="M4 12C4 7.58172 7.58172 4 12 4C16.4183 4 20 7.58172 20 12C20 16.4183 16.4183 20 12 20C7.58172 20 4 16.4183 4 12ZM12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM17.4571 9.45711L16.0429 8.04289L11 13.0858L8.20711 10.2929L6.79289 11.7071L11 15.9142L17.4571 9.45711Z" fill="currentColor"/>`,
"checkbox-multiple": `<path d="M6.99979 7V3C6.99979 2.44772 7.4475 2 7.99979 2H20.9998C21.5521 2 21.9998 2.44772 21.9998 3V16C21.9998 16.5523 21.5521 17 20.9998 17H17V20.9925C17 21.5489 16.551 22 15.9925 22H3.00728C2.45086 22 2 21.5511 2 20.9925L2.00276 8.00748C2.00288 7.45107 2.4518 7 3.01025 7H6.99979ZM8.99979 7H15.9927C16.549 7 17 7.44892 17 8.00748V15H19.9998V4H8.99979V7ZM15 9H4.00255L4.00021 20H15V9ZM8.50242 18L4.96689 14.4645L6.3811 13.0503L8.50242 15.1716L12.7451 10.9289L14.1593 12.3431L8.50242 18Z" fill="currentColor"/>`,
"claude-code": `<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z" fill="currentColor"/>`,
"clipboard": `<path d="M7 4V2H17V4H20.0066C20.5552 4 21 4.44495 21 4.9934V21.0066C21 21.5552 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5551 3 21.0066V4.9934C3 4.44476 3.44495 4 3.9934 4H7ZM7 6H5V20H19V6H17V8H7V6ZM9 4V6H15V4H9Z" fill="currentColor"/>`,
"close": `<path d="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637 16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855 12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z" fill="currentColor"/>`,
"close-circle": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 10.5858L14.8284 7.75736L16.2426 9.17157L13.4142 12L16.2426 14.8284L14.8284 16.2426L12 13.4142L9.17157 16.2426L7.75736 14.8284L10.5858 12L7.75736 9.17157L9.17157 7.75736L12 10.5858Z" fill="currentColor"/>`,
@@ -62,11 +63,12 @@ export const iconSpriteData = {
"code-sslash": `<path d="M24 12L18.3431 17.6569L16.9289 16.2426L21.1716 12L16.9289 7.75736L18.3431 6.34315L24 12ZM2.82843 12L7.07107 16.2426L5.65685 17.6569L0 12L5.65685 6.34315L7.07107 7.75736L2.82843 12ZM9.78845 21H7.66009L14.2116 3H16.3399L9.78845 21Z" fill="currentColor"/>`,
"collapse-vertical": `<path d="M11.9995 13.4995 16.9492 18.4493 15.535 19.8635 12.9995 17.3279 12.9995 22.9995H10.9995L10.9995 17.3279 8.46643 19.861 7.05222 18.4468 11.9995 13.4995ZM10.9995.999512 10.9995 6.67035 8.46448 4.13535 7.05026 5.54956 12 10.4995 16.9497 5.54977 15.5355 4.13555 12.9995 6.67157V.999512L10.9995.999512Z" fill="currentColor"/>`,
"command": `<path d="M10 8H14V6.5C14 4.567 15.567 3 17.5 3C19.433 3 21 4.567 21 6.5C21 8.433 19.433 10 17.5 10H16V14H17.5C19.433 14 21 15.567 21 17.5C21 19.433 19.433 21 17.5 21C15.567 21 14 19.433 14 17.5V16H10V17.5C10 19.433 8.433 21 6.5 21C4.567 21 3 19.433 3 17.5C3 15.567 4.567 14 6.5 14H8V10H6.5C4.567 10 3 8.433 3 6.5C3 4.567 4.567 3 6.5 3C8.433 3 10 4.567 10 6.5V8ZM8 8V6.5C8 5.67157 7.32843 5 6.5 5C5.67157 5 5 5.67157 5 6.5C5 7.32843 5.67157 8 6.5 8H8ZM8 16H6.5C5.67157 16 5 16.6716 5 17.5C5 18.3284 5.67157 19 6.5 19C7.32843 19 8 18.3284 8 17.5V16ZM16 8H17.5C18.3284 8 19 7.32843 19 6.5C19 5.67157 18.3284 5 17.5 5C16.6716 5 16 5.67157 16 6.5V8ZM16 16V17.5C16 18.3284 16.6716 19 17.5 19C18.3284 19 19 18.3284 19 17.5C19 16.6716 18.3284 16 17.5 16H16ZM10 10V14H14V10H10Z" fill="currentColor"/>`,
"command-code": `<path fill="currentColor" d="M5.8 5.8h4.8v4.8h-4.8Z M13.4 5.8h4.8v4.8h-4.8Z M10.6 10.6h2.8v2.8h-2.8Z M5.8 13.4h4.8v4.8h-4.8Z M13.4 13.4h4.8v4.8h-4.8Z"/>`,
"compass-3": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM16.5 7.5L14 14L7.5 16.5L10 10L16.5 7.5ZM12 13C12.5523 13 13 12.5523 13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13Z" fill="currentColor"/>`,
"computer": `<path d="M4 16H20V5H4V16ZM13 18V20H17V22H7V20H11V18H2.9918C2.44405 18 2 17.5511 2 16.9925V4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V16.9925C22 17.5489 21.5447 18 21.0082 18H13Z" fill="currentColor"/>`,
"contract-up-down": `<path d="M5.79285 5.20718 12 11.4143 18.2071 5.20718 16.7928 3.79297 12 8.58586 7.20706 3.79297 5.79285 5.20718ZM18.2072 18.7928 12.0001 12.5857 5.793 18.7928 7.20721 20.207 12.0001 15.4141 16.793 20.207 18.2072 18.7928Z" fill="currentColor"/>`,
"corner-down-left": `<path d="M19.0001 13.9999L19.0002 5L17.0002 4.99997L17.0001 11.9999L6.8283 12L10.778 8.05024L9.36382 6.63603L2.99986 13L9.36382 19.364L10.778 17.9497L6.82826 14L19.0001 13.9999Z" fill="currentColor"/>`,
"cursor": `<path d="M15.3873 13.4975L17.9403 20.5117L13.2418 22.2218L10.6889 15.2076L6.79004 17.6529L8.4086 1.63318L19.9457 12.8646L15.3873 13.4975ZM15.3768 19.3163L12.6618 11.8568L15.6212 11.4459L9.98201 5.9561L9.19088 13.7863L11.7221 12.1988L14.4371 19.6583L15.3768 19.3163Z" fill="currentColor"/>`,
"cursor": `<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" fill="currentColor"/>`,
"database-2": `<path d="M5 12.5C5 12.8134 5.46101 13.3584 6.53047 13.8931C7.91405 14.5849 9.87677 15 12 15C14.1232 15 16.0859 14.5849 17.4695 13.8931C18.539 13.3584 19 12.8134 19 12.5V10.3287C17.35 11.3482 14.8273 12 12 12C9.17273 12 6.64996 11.3482 5 10.3287V12.5ZM19 15.3287C17.35 16.3482 14.8273 17 12 17C9.17273 17 6.64996 16.3482 5 15.3287V17.5C5 17.8134 5.46101 18.3584 6.53047 18.8931C7.91405 19.5849 9.87677 20 12 20C14.1232 20 16.0859 19.5849 17.4695 18.8931C18.539 18.3584 19 17.8134 19 17.5V15.3287ZM3 17.5V7.5C3 5.01472 7.02944 3 12 3C16.9706 3 21 5.01472 21 7.5V17.5C21 19.9853 16.9706 22 12 22C7.02944 22 3 19.9853 3 17.5ZM12 10C14.1232 10 16.0859 9.58492 17.4695 8.89313C18.539 8.3584 19 7.81342 19 7.5C19 7.18658 18.539 6.6416 17.4695 6.10687C16.0859 5.41508 14.1232 5 12 5C9.87677 5 7.91405 5.41508 6.53047 6.10687C5.46101 6.6416 5 7.18658 5 7.5C5 7.81342 5.46101 8.3584 6.53047 8.89313C7.91405 9.58492 9.87677 10 12 10Z" fill="currentColor"/>`,
"delete-bin": `<path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 11H11V17H9V11ZM13 11H15V17H13V11ZM9 4V6H15V4H9Z" fill="currentColor"/>`,
"discord-fill": `<path d="M19.3034 5.33716C17.9344 4.71103 16.4805 4.2547 14.9629 4C14.7719 4.32899 14.5596 4.77471 14.411 5.12492C12.7969 4.89144 11.1944 4.89144 9.60255 5.12492C9.45397 4.77471 9.2311 4.32899 9.05068 4C7.52251 4.2547 6.06861 4.71103 4.70915 5.33716C1.96053 9.39111 1.21766 13.3495 1.5891 17.2549C3.41443 18.5815 5.17612 19.388 6.90701 19.9187C7.33151 19.3456 7.71356 18.73 8.04255 18.0827C7.41641 17.8492 6.82211 17.5627 6.24904 17.2231C6.39762 17.117 6.5462 17.0003 6.68416 16.8835C10.1438 18.4648 13.8911 18.4648 17.3082 16.8835C17.4568 17.0003 17.5948 17.117 17.7434 17.2231C17.1703 17.5627 16.576 17.8492 15.9499 18.0827C16.2789 18.73 16.6609 19.3456 17.0854 19.9187C18.8152 19.388 20.5875 18.5815 22.4033 17.2549C22.8596 12.7341 21.6806 8.80747 19.3034 5.33716ZM8.5201 14.8459C7.48007 14.8459 6.63107 13.9014 6.63107 12.7447C6.63107 11.5879 7.45884 10.6434 8.5201 10.6434C9.57071 10.6434 10.4303 11.5879 10.4091 12.7447C10.4091 13.9014 9.57071 14.8459 8.5201 14.8459ZM15.4936 14.8459C14.4535 14.8459 13.6034 13.9014 13.6034 12.7447C13.6034 11.5879 14.4323 10.6434 15.4936 10.6434C16.5442 10.6434 17.4038 11.5879 17.3825 12.7447C17.3825 13.9014 16.5548 14.8459 15.4936 14.8459Z" fill="currentColor"/>`,
@@ -159,6 +161,7 @@ export const iconSpriteData = {
"lock-unlock": `<path d="M7 10H20C20.5523 10 21 10.4477 21 11V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V11C3 10.4477 3.44772 10 4 10H5V9C5 5.13401 8.13401 2 12 2C14.7405 2 17.1131 3.5748 18.2624 5.86882L16.4731 6.76344C15.6522 5.12486 13.9575 4 12 4C9.23858 4 7 6.23858 7 9V10ZM5 12V20H19V12H5ZM10 15H14V17H10V15Z" fill="currentColor"/>`,
"loop-right-ai": `<path d="M22 12C22 17.5228 17.5228 22 12 22C8.72774 22 5.82382 20.4286 4 18.001V20.5H2V14.5H8V16.5H5.38477C6.82543 18.6137 9.25151 20 12 20C16.4183 20 20 16.4183 20 12H22ZM11.5293 8.31934C11.7059 7.8935 12.2943 7.89349 12.4707 8.31934L12.7236 8.93066C13.1556 9.97346 13.9615 10.8062 14.9746 11.2568L15.6924 11.5762C16.1026 11.759 16.1026 12.3562 15.6924 12.5391L14.9326 12.877C13.9449 13.3162 13.1534 14.1194 12.7139 15.1279L12.4668 15.6934C12.2864 16.1075 11.7137 16.1075 11.5332 15.6934L11.2871 15.1279C10.8476 14.1193 10.0552 13.3163 9.06738 12.877L8.30762 12.5391C7.89744 12.3562 7.89741 11.759 8.30762 11.5762L9.02539 11.2568C10.0385 10.8062 10.8445 9.97348 11.2764 8.93066L11.5293 8.31934ZM12 2C15.2723 2 18.1762 3.57144 20 5.99902V3.5H22V9.5H16V7.5H18.6152C17.1746 5.38634 14.7485 4 12 4C7.58172 4 4 7.58172 4 12H2C2 6.47715 6.47715 2 12 2Z" fill="currentColor"/>`,
"macbook": `<path d="M4 5V16H20V5H4ZM2 4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V18H2V4.00748ZM1 19H23V21H1V19Z" fill="currentColor"/>`,
"markup": `<path d="M10 10.4967L11.0385 6.86204C11.1902 6.331 11.7437 6.02351 12.2747 6.17523C12.6069 6.27015 12.8666 6.52983 12.9615 6.86204L14 10.4967V11.9967H14.7192C15.1781 11.9967 15.5781 12.309 15.6894 12.7542L17.051 18.2008C18.8507 16.7339 20 14.4995 20 11.9967C20 7.57843 16.4183 3.9967 12 3.9967C7.58172 3.9967 4 7.57843 4 11.9967C4 14.4995 5.14932 16.7339 6.94897 18.2008L8.31063 12.7542C8.42193 12.309 8.82191 11.9967 9.28078 11.9967H10V10.4967ZM12 19.9967C12.2415 19.9967 12.4813 19.986 12.7189 19.9649C13.6187 19.8847 14.4756 19.6556 15.2649 19.3024L13.9384 13.9967H10.0616L8.73514 19.3024C9.52438 19.6556 10.3813 19.8847 11.2811 19.9648C11.5187 19.986 11.7585 19.9967 12 19.9967ZM12 21.9967C6.47715 21.9967 2 17.5196 2 11.9967C2 6.47386 6.47715 1.9967 12 1.9967C17.5228 1.9967 22 6.47386 22 11.9967C22 17.5196 17.5228 21.9967 12 21.9967Z" fill="currentColor"/>`,
"menu-2": `<path d="M3 4H21V6H3V4ZM3 11H15V13H3V11ZM3 18H21V20H3V18Z" fill="currentColor"/>`,
"menu-fold-2": `<path d="M4.40347 3.90332L2.98926 5.31753L6.17124 8.49951L2.98926 11.6815L4.40347 13.0957L8.99967 8.49951L4.40347 3.90332ZM20.9997 19.9995V17.9995H2.99967V19.9995H20.9997ZM20.9997 12.9995V10.9995H11.9997V12.9995H20.9997ZM20.9997 5.99951V3.99951H11.9997V5.99951H20.9997Z" fill="currentColor"/>`,
"menu-search": `<path d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z" fill="currentColor"/>`,
@@ -225,6 +228,7 @@ export const iconSpriteData = {
"target": `<path d="M12 1.99999C12.5523 1.99999 13 2.4477 13 2.99999C12.9999 3.55224 12.5522 3.99999 12 3.99999C7.58172 3.99999 4 7.58171 4 12C4.00004 16.4182 7.58174 20 12 20C16.4182 20 19.9999 16.4182 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C21.9999 17.5228 17.5228 22 12 22C6.47717 22 2.00004 17.5228 2 12C2 6.47714 6.47715 1.99999 12 1.99999ZM12 5.99999C12.5523 5.99999 13 6.4477 13 6.99999C12.9999 7.55224 12.5522 7.99999 12 7.99999C9.79085 7.99999 7.99999 9.79085 7.99999 12C8.00004 14.2091 9.79088 16 12 16C14.2091 16 15.9999 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C17.9999 15.3137 15.3137 18 12 18C8.68631 18 6.00004 15.3137 6 12C6 8.68628 8.68629 5.99999 12 5.99999ZM17.6562 2.10057C18.0468 1.71005 18.6807 1.71005 19.0713 2.10057C19.4614 2.49105 19.4615 3.12419 19.0713 3.51463L18.3633 4.22069L18.3642 4.22167C17.9737 4.61219 17.9737 5.2452 18.3642 5.63573C18.7548 6.02612 19.3878 6.02621 19.7783 5.63573L20.4853 4.9287C20.8759 4.53839 21.5089 4.53826 21.8994 4.9287C22.2899 5.31915 22.2897 5.95222 21.8994 6.34276L19.7783 8.46483C19.5909 8.65223 19.3363 8.75671 19.0713 8.75682H16.6572L12.707 12.707C12.3165 13.0974 11.6834 13.0974 11.293 12.707C10.9025 12.3165 10.9026 11.6835 11.293 11.293L15.2422 7.34374V4.9287C15.2422 4.66356 15.3477 4.40916 15.5351 4.22167L17.6562 2.10057Z" fill="currentColor"/>`,
"target-fill": `<path d="M12 2C12.5523 2 13 2.44772 13 3C13 3.55228 12.5523 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 6C12.5523 6 13 6.44772 13 7C13 7.55228 12.5523 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6ZM18.5713 2.10059C18.8474 2.1006 19.0712 2.32449 19.0713 2.60059V4.42969C19.0716 4.70553 19.2954 4.92866 19.5713 4.92871H21.3994C21.6754 4.92871 21.8992 5.15275 21.8994 5.42871V6.34375L20.0107 8.23242C19.6358 8.60719 19.1268 8.81824 18.5967 8.81836H16.5967L12.707 12.707C12.3165 13.0974 11.6835 13.0975 11.293 12.707C10.9027 12.3165 10.9026 11.6834 11.293 11.293L15.1826 7.4043V5.4043C15.1826 4.87411 15.3928 4.36526 15.7676 3.99023L17.6572 2.10059H18.5713Z" fill="currentColor"/>`,
"task": `<path d="M19 4H5V20H19V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H19.9997C20.5519 2 20.9996 2.44772 20.9997 3L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11.2929 13.1213L15.5355 8.87868L16.9497 10.2929L11.2929 15.9497L7.40381 12.0607L8.81802 10.6464L11.2929 13.1213Z" fill="currentColor"/>`,
"telegram-fill": `<path d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM12.3584 9.38246C11.3857 9.78702 9.4418 10.6244 6.5266 11.8945C6.05321 12.0827 5.80524 12.2669 5.78266 12.4469C5.74451 12.7513 6.12561 12.8711 6.64458 13.0343C6.71517 13.0565 6.78832 13.0795 6.8633 13.1039C7.37388 13.2698 8.06071 13.464 8.41776 13.4717C8.74164 13.4787 9.10313 13.3452 9.50222 13.0711C12.226 11.2325 13.632 10.3032 13.7203 10.2832C13.7826 10.269 13.8689 10.2513 13.9273 10.3032C13.9858 10.3552 13.98 10.4536 13.9739 10.48C13.9361 10.641 12.4401 12.0318 11.666 12.7515C11.4351 12.9661 11.2101 13.1853 10.9833 13.4039C10.509 13.8611 10.1533 14.204 11.003 14.764C11.8644 15.3317 12.7323 15.8982 13.5724 16.4971C13.9867 16.7925 14.359 17.0579 14.8188 17.0156C15.0861 16.991 15.3621 16.7397 15.5022 15.9903C15.8335 14.2193 16.4847 10.3821 16.6352 8.80083C16.6484 8.6623 16.6318 8.485 16.6185 8.40717C16.6052 8.32934 16.5773 8.21844 16.4762 8.13635C16.3563 8.03913 16.1714 8.01863 16.0887 8.02009C15.7125 8.02672 15.1355 8.22737 12.3584 9.38246Z" fill="currentColor"/>`,
"terminal": `<path d="M10.9999 12L3.92886 19.0711L2.51465 17.6569L8.1715 12L2.51465 6.34317L3.92886 4.92896L10.9999 12ZM10.9999 19H20.9999V21H10.9999V19Z" fill="currentColor"/>`,
"terminal-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM12 15H18V17H12V15ZM8.66685 12L5.83842 9.17157L7.25264 7.75736L11.4953 12L7.25264 16.2426L5.83842 14.8284L8.66685 12Z" fill="currentColor"/>`,
"terminal-window": `<path d="M20 9V5H4V9H20ZM20 11H4V19H20V11ZM3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM5 12H8V17H5V12ZM5 6H7V8H5V6ZM9 6H11V8H9V6Z" fill="currentColor"/>`,
File diff suppressed because it is too large Load Diff
+1 -23
View File
@@ -41,9 +41,8 @@ import { cn, hasModifier } from '@/lib/utils';
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
import { McpIcon } from '@/components/icons/McpIcon';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota';
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
import { updateDesktopSettings } from '@/lib/persistence';
import { formatTimeForPreference } from '@/lib/timeFormat';
import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
@@ -549,7 +548,6 @@ export const Header: React.FC<HeaderProps> = ({
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
const quotaLastUpdated = useQuotaStore((state) => state.lastUpdated);
const quotaDisplayMode = useQuotaStore((state) => state.displayMode);
const showPredValues = useQuotaStore((state) => state.showPredValues);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
const setQuotaDisplayMode = useQuotaStore((state) => state.setDisplayMode);
@@ -2448,12 +2446,6 @@ export const Header: React.FC<HeaderProps> = ({
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label);
const expectedMarker = paceInfo?.dailyAllocationPercent != null
? (quotaDisplayMode === 'remaining'
? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio)
: calculateExpectedUsagePercent(paceInfo.elapsedRatio))
: null;
const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent);
const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference);
return (
@@ -2475,11 +2467,7 @@ export const Header: React.FC<HeaderProps> = ({
percent={displayPercent}
tonePercent={window.usedPercent}
className="h-1.5"
expectedMarkerPercent={expectedMarker}
/>
{paceInfo && showPredValues ? (
<PaceIndicator paceInfo={paceInfo} compact />
) : null}
</div>
);
})}
@@ -2513,12 +2501,6 @@ export const Header: React.FC<HeaderProps> = ({
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds);
const expectedMarker = paceInfo?.dailyAllocationPercent != null
? (quotaDisplayMode === 'remaining'
? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio)
: calculateExpectedUsagePercent(paceInfo.elapsedRatio))
: null;
const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent);
return (
<div key={`${group.providerId}-${modelName}`} className="flex flex-col gap-1.5">
@@ -2532,11 +2514,7 @@ export const Header: React.FC<HeaderProps> = ({
percent={displayPercent}
tonePercent={window.usedPercent}
className="h-1.5"
expectedMarkerPercent={expectedMarker}
/>
{paceInfo && showPredValues ? (
<PaceIndicator paceInfo={paceInfo} compact />
) : null}
</div>
);
})}
@@ -15,6 +15,8 @@ import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { extractAnnouncedUrls, extractProjectActionUrl } from '@/lib/terminalPreview';
import { setAnnouncedDevServers } from '@/lib/browser/announcedServers';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { openExternalUrl } from '@/lib/url';
@@ -39,6 +41,10 @@ type UrlWatchEntry = {
openedUrl: boolean;
tail: string;
openInPreview: boolean;
/** Addresses announced so far by an auto-discovery run, in announcement order. */
announced: string[];
/** Set once the panel is showing these candidates and wants later ones too. */
offering: boolean;
};
interface ProjectActionsButtonProps {
@@ -49,11 +55,14 @@ interface ProjectActionsButtonProps {
allowMobile?: boolean;
}
const ANSI_ESCAPE_PREFIX = String.fromCharCode(27);
const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_ESCAPE_PREFIX}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
const URL_GLOBAL_PATTERN = /https?:\/\/[^\s<>'"`]+/gi;
const AUTO_DISCOVER_ACTION_ID = '__openchamber_auto_discover_preview__';
const AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS = 15_000;
/**
* How long to keep listening after the first server announces itself. A project
* that starts several at once staggers them by a second or two, and opening the
* first to speak would just be a race.
*/
const AUTO_DISCOVER_SETTLE_MS = 3_000;
const stripControlChars = (value: string): string => {
let next = '';
@@ -89,57 +98,6 @@ const normalizeManualOpenUrl = (value: string | undefined): string | null => {
}
};
const extractBestUrl = (value: string): string | null => {
const cleaned = value.replace(ANSI_ESCAPE_PATTERN, '');
const matches = cleaned.match(URL_GLOBAL_PATTERN);
if (!matches || matches.length === 0) {
return null;
}
const normalized = matches
.map((entry) => entry.replace(/[),.;]+$/, ''))
.filter(Boolean);
if (normalized.length === 0) {
return null;
}
const portCandidates: Array<{ raw: string; parsed: URL }> = [];
for (const candidate of normalized) {
try {
const parsed = new URL(candidate);
if (parsed.port && parsed.port.length > 0) {
portCandidates.push({ raw: candidate, parsed });
}
} catch {
// noop
}
}
if (portCandidates.length > 0) {
const scoreCandidate = (entry: { raw: string; parsed: URL }): number => {
const { parsed } = entry;
const host = parsed.hostname.toLowerCase();
const isLocalHost = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1';
const normalizedPath = parsed.pathname || '/';
const pathSegments = normalizedPath.split('/').filter(Boolean).length;
const hasRootPath = normalizedPath === '/' || normalizedPath === '';
const hasQueryOrHash = Boolean(parsed.search || parsed.hash);
let score = 0;
if (isLocalHost) score += 50;
if (hasRootPath) score += 30;
score -= Math.min(pathSegments * 5, 20);
if (hasQueryOrHash) score -= 10;
return score;
};
portCandidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a));
return portCandidates[0]?.parsed.origin ?? portCandidates[0]?.raw ?? null;
}
return normalized[0] ?? null;
};
export const ProjectActionsButton = ({
projectRef,
@@ -307,6 +265,39 @@ export const ProjectActionsButton = ({
}, [actions, canUseAutoDiscover, selectedActionId]);
React.useEffect(() => {
/**
* Decides what an auto-discovery run found, once its servers have had a
* moment to announce themselves. One address is opened; several are offered
* in the browser panel, because choosing between them would be a guess
* dressed up as a feature.
*/
const settleAutoDiscovery = (runKey: string) => {
delete previewWaitTimeoutByRunKeyRef.current[runKey];
const watch = urlWatchByRunKeyRef.current[runKey];
if (!watch || watch.openedUrl) return;
const store = useTerminalStore.getState();
const run = store.projectActionRuns[runKey];
if (!run) return;
const candidates = watch.announced;
if (candidates.length === 0) return;
watch.openedUrl = true;
store.updateProjectActionRunStatus(runKey, 'running');
if (candidates.length === 1) {
setAnnouncedDevServers(run.directory, []);
setTabPreviewUrl(run.directory, run.tabId, candidates[0], { locked: false, autoOpened: true });
openContextPreview(run.directory, candidates[0]);
return;
}
watch.offering = true;
setAnnouncedDevServers(run.directory, candidates);
useUIStore.getState().openContextSurface(run.directory, 'browser');
toast.info(t('projectActions.toast.multipleServers'));
};
const monitorRuns = () => {
const terminalStore = useTerminalStore.getState();
const terminalSessions = terminalStore.sessions;
@@ -319,7 +310,7 @@ export const ProjectActionsButton = ({
continue;
}
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false };
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false, announced: [], offering: false };
urlWatchByRunKeyRef.current[runKey] = watch;
const action = displayActions.find((item) => item.id === entry.actionId);
const bufferChunks = terminalStore.getBuffer(entry.directory, entry.tabId).chunks;
@@ -330,7 +321,34 @@ export const ProjectActionsButton = ({
const combined = nextChunks.map((chunk) => chunk.data).join('');
const textForScan = `${watch.tail}${combined}`;
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true ? extractBestUrl(textForScan) : null;
// Auto-discovery inferred the command; it must not also infer the
// address. It collects what the servers announce and decides once they
// have had a moment to all speak up.
// Keep listening after the panel starts offering candidates: servers in
// one project can be seconds apart, and a list that froze at whoever was
// ready first would quietly omit the rest.
if (watch.openInPreview && (!watch.openedUrl || watch.offering)) {
const announced = extractAnnouncedUrls(textForScan);
const before = watch.announced.length;
for (const url of announced) {
if (!watch.announced.includes(url)) watch.announced.push(url);
}
const added = watch.announced.length - before;
if (watch.offering && added > 0) {
setAnnouncedDevServers(entry.directory, watch.announced);
} else if (!watch.openedUrl && before === 0 && watch.announced.length > 0) {
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
previewWaitTimeoutByRunKeyRef.current[runKey] = window.setTimeout(
() => settleAutoDiscovery(runKey),
AUTO_DISCOVER_SETTLE_MS,
);
}
}
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true && !watch.openInPreview
? extractProjectActionUrl(textForScan)
: null;
const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId;
watch.lastSeenChunkId = lastChunkId;
@@ -551,6 +569,8 @@ export const ProjectActionsButton = ({
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
announced: [],
offering: false,
};
const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n'));
@@ -30,9 +30,8 @@ import { useI18n } from '@/lib/i18n';
import { toast } from '@/components/ui';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
import { Icon } from "@/components/icon/Icon";
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { formatTimeForPreference } from '@/lib/timeFormat';
@@ -673,7 +672,6 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
const quotaLastUpdated = useQuotaStore((state) => state.lastUpdated);
const quotaDisplayMode = useQuotaStore((state) => state.displayMode);
const showPredValues = useQuotaStore((state) => state.showPredValues);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
@@ -975,12 +973,6 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label);
const expectedMarker = paceInfo?.dailyAllocationPercent != null
? (quotaDisplayMode === 'remaining'
? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio)
: calculateExpectedUsagePercent(paceInfo.elapsedRatio))
: null;
const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent);
return (
<DropdownMenuItem
@@ -999,13 +991,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
percent={displayPercent}
tonePercent={window.usedPercent}
className="h-1"
expectedMarkerPercent={expectedMarker}
/>
{paceInfo && showPredValues && (
<div className="mt-0.5">
<PaceIndicator paceInfo={paceInfo} compact />
</div>
)}
<span className="flex items-center justify-between typography-micro text-muted-foreground text-[10px]">
<span>{formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference)}</span>
</span>
@@ -0,0 +1,212 @@
/**
* Regression coverage for https://github.com/openchamber/openchamber/issues/2815
*
* A full ContextPanel mount is not available in bun test because its import
* graph includes a Vite worker URL. This test follows the source-level guard
* pattern in contextPanelEscapeClosesTerminal.test.ts and uses the real store.
*/
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getDefaultTheme } from '@/lib/theme/themes';
import { useUIStore } from '@/stores/useUIStore';
import {
buildEmbeddedSessionChatURL,
getActiveEmbeddedSessionChatTab,
resetEmbeddedSessionChatCache,
} from '../contextPanelEmbeddedChat';
const __dirname = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(join(__dirname, '..', '..', '..', 'App.tsx'), 'utf-8');
const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8');
type FixtureTab = {
id: string;
mode: 'chat' | 'git' | 'diff' | 'plan';
targetPath: string | null;
dedupeKey: string;
label: string | null;
sessionTitleFallback: string | null;
readOnly: boolean;
stagedDiff: boolean;
diffScope: 'working';
touchedAt: number;
};
const DIRECTORY = '/path/to/repository';
const originalWindow = globalThis.window;
const installWindowLocation = () => {
const url = new URL('http://127.0.0.1:3000/');
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
location: {
href: url.toString(),
origin: url.origin,
pathname: url.pathname,
search: url.search,
},
},
});
};
const buildTab = (mode: FixtureTab['mode'], id: string): FixtureTab => ({
id: mode === 'chat' ? `chat:session:${id}` : id,
mode,
targetPath: null,
dedupeKey: mode === 'chat' ? `session:${id}` : id,
label: mode === 'chat' ? `Session ${id}` : null,
sessionTitleFallback: null,
readOnly: mode === 'chat',
stagedDiff: false,
diffScope: 'working',
touchedAt: Date.now(),
});
const sessionChatTabs = Array.from({ length: 8 }, (_, index) => buildTab('chat', `ses_${index + 1}`));
const issueScenarioTabs = [
...sessionChatTabs,
buildTab('git', 'git'),
buildTab('diff', 'diff'),
buildTab('plan', 'plan'),
];
const installIssueScenario = () => {
useUIStore.setState({
contextPanelByDirectory: {
[DIRECTORY]: {
isOpen: true,
expanded: false,
tabs: issueScenarioTabs,
activeTabId: sessionChatTabs[0].id,
widthByMode: {},
touchedAt: Date.now(),
},
} as never,
});
};
beforeEach(() => {
installWindowLocation();
resetEmbeddedSessionChatCache();
useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] });
installIssueScenario();
});
afterAll(() => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: originalWindow,
});
});
describe('issue #2815 active-only chat iframe source guard', () => {
test('does not map persisted chat tabs to iframe elements', () => {
expect(contextPanelSource).not.toContain('{chatTabs.map((tab) => {');
});
test('renders the iframe only when an active chat has a session and URL', () => {
const start = contextPanelSource.indexOf('{activeChatTab && activeChatSessionID && activeChatSrc ? (');
expect(start).toBeGreaterThan(-1);
const end = contextPanelSource.indexOf(') : null}', start);
expect(end).toBeGreaterThan(start);
const block = contextPanelSource.slice(start, end);
expect(block).toContain('<iframe');
expect(block).toContain('key={activeChatTab.id}');
expect(block).toContain('src={activeChatSrc}');
expect(block).toContain('postEmbeddedVisibilityToChats();');
expect(block).not.toContain("'block' : 'hidden'");
});
test('does not select a chat iframe when the context panel is closed', () => {
expect(contextPanelSource).toContain(
"const activeChatTabID = isOpen && activeTab?.mode === 'chat' ? activeTab.id : null;",
);
expect(contextPanelSource).toContain(
"const activeChatSessionID = isOpen && activeTab?.mode === 'chat'",
);
});
test('answers the mounted iframe visibility handshake from the active tab', () => {
expect(contextPanelSource).toContain('data?.type === EMBEDDED_VISIBILITY_REQUEST');
expect(contextPanelSource).toContain('frame.contentWindow === event.source');
expect(contextPanelSource).toContain('payload: { visible: activeChatTabID === tabID }');
});
test('requests authoritative visibility after installing the iframe listener', () => {
const effectStart = appSource.indexOf('const applyVisibility = (payload?: EmbeddedVisibilityPayload) => {');
const listenerIndex = appSource.indexOf("window.addEventListener('message', handleMessage);", effectStart);
const requestIndex = appSource.indexOf('requestEmbeddedSessionVisibility();', effectStart);
expect(effectStart).toBeGreaterThan(-1);
expect(listenerIndex).toBeGreaterThan(effectStart);
expect(requestIndex).toBeGreaterThan(listenerIndex);
});
test('gates embedded chat background work on visibility but keeps message history enabled', () => {
expect(appSource).toContain(
'const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;',
);
expect(appSource).toContain('active={embeddedBackgroundWorkEnabled}');
expect(appSource).toContain('messagesEnabled={true}');
expect(appSource).toContain(
'useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled });',
);
});
});
describe('issue #2815 persisted scenario', () => {
test('keeps all tab records but selects one chat for mounting', () => {
const panel = useUIStore.getState().contextPanelByDirectory[DIRECTORY];
const chatTabs = panel.tabs.filter((tab) => tab.mode === 'chat');
const activeTab = getActiveEmbeddedSessionChatTab(chatTabs, panel.activeTabId);
expect(panel.tabs).toHaveLength(11);
expect(chatTabs).toHaveLength(8);
expect(activeTab?.id).toBe(sessionChatTabs[0].id);
});
test('produces one live embedded URL for eight persisted chats', () => {
const panel = useUIStore.getState().contextPanelByDirectory[DIRECTORY];
const chatTabs = panel.tabs.filter((tab) => tab.mode === 'chat');
const activeTab = getActiveEmbeddedSessionChatTab(chatTabs, panel.activeTabId);
const frames = activeTab ? [buildEmbeddedSessionChatURL('ses_1', DIRECTORY, activeTab.readOnly, {
mode: 'system',
lightThemeId: 'light',
darkThemeId: 'dark',
currentTheme: getDefaultTheme(true),
})] : [];
expect(frames).toHaveLength(1);
const url = new URL(frames[0]);
expect(url.searchParams.get('ocPanel')).toBe('session-chat');
expect(url.searchParams.get('sessionId')).toBe('ses_1');
expect(url.searchParams.get('readOnly')).toBe('1');
});
test('selects another single chat after a tab switch', () => {
useUIStore.getState().setActiveContextPanelTab(DIRECTORY, sessionChatTabs[6].id);
const panel = useUIStore.getState().contextPanelByDirectory[DIRECTORY];
const chatTabs = panel.tabs.filter((tab) => tab.mode === 'chat');
const activeTab = getActiveEmbeddedSessionChatTab(chatTabs, panel.activeTabId);
expect(activeTab?.id).toBe(sessionChatTabs[6].id);
expect(chatTabs.filter((tab) => tab.id === activeTab?.id)).toHaveLength(1);
});
test('selects no chat after the panel closes', () => {
useUIStore.getState().closeContextPanel(DIRECTORY);
const panel = useUIStore.getState().contextPanelByDirectory[DIRECTORY];
const chatTabs = panel.tabs.filter((tab) => tab.mode === 'chat');
const activeTabID = panel.isOpen ? panel.activeTabId : null;
expect(panel.isOpen).toBe(false);
expect(getActiveEmbeddedSessionChatTab(chatTabs, activeTabID)).toBeNull();
});
});
@@ -1,48 +0,0 @@
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 mainLayoutSource = readFileSync(
join(__dirname, '..', 'MainLayout.tsx'),
'utf-8',
);
const sessionSidebarSource = readFileSync(
join(__dirname, '..', '..', 'session', 'SessionSidebar.tsx'),
'utf-8',
);
describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)', () => {
test('mobile SessionSidebar is not conditionally mounted on mobileLeftDrawerVisible', () => {
const mobileSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar mobileVariant');
expect(mobileSidebarIndex).toBeGreaterThan(-1);
const windowStart = Math.max(0, mobileSidebarIndex - 400);
const precedingWindow = mainLayoutSource.slice(windowStart, mobileSidebarIndex);
expect(/\{\s*mobileLeftDrawerVisible\s*&&\s*\(/.test(precedingWindow)).toBe(false);
expect(precedingWindow.includes('pointer-events-none')).toBe(true);
expect(mainLayoutSource.slice(mobileSidebarIndex, mobileSidebarIndex + 120)).toContain('isVisible={mobileLeftDrawerVisible}');
});
test('desktop SessionSidebar is rendered inside Sidebar without drawer-visibility gating', () => {
const desktopSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar isVisible={isSidebarOpen} />');
expect(desktopSidebarIndex).toBeGreaterThan(-1);
const windowStart = Math.max(0, desktopSidebarIndex - 300);
const precedingWindow = mainLayoutSource.slice(windowStart, desktopSidebarIndex);
expect(precedingWindow).toContain('<Sidebar');
expect(/mobileLeftDrawerVisible\s*&&/.test(precedingWindow)).toBe(false);
});
test('hidden sidebars disable render-only subscriptions and effects', () => {
expect(sessionSidebarSource).toContain('useGitAllBranches(isVisible)');
expect(sessionSidebarSource).toContain('useGitRepoStatusMap(isVisible ? normalizedProjectPaths : EMPTY_STRING_ARRAY)');
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isSessionSearchOpen');
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isDesktopShellRuntime');
expect(sessionSidebarSource).toContain('if (!isVisible) return EMPTY_STRING_ARRAY;');
});
});
@@ -5,10 +5,13 @@ import {
buildEmbeddedSessionChatURL,
EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST,
EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE,
EMBEDDED_VISIBILITY_REQUEST,
getOrCreateEmbeddedSessionChatURL,
getActiveEmbeddedSessionChatTab,
getEmbeddedSessionChatOriginSessionId,
isEmbeddedSessionChat,
requestEmbeddedSessionRuntimeBootstrap,
requestEmbeddedSessionVisibility,
resetEmbeddedSessionChatCache,
type EmbeddedSessionChatURLCacheEntry,
} from './contextPanelEmbeddedChat';
@@ -127,6 +130,17 @@ describe('embedded session chat URL', () => {
expect(new URL(second).searchParams.get('themeVariant')).toBe('dark');
});
test('bootstraps subagent prompting before the embedded chat first renders', () => {
const src = buildEmbeddedSessionChatURL('ses_1', '/repo', false, {
mode: 'system',
lightThemeId: 'light-a',
darkThemeId: 'dark-a',
currentTheme: makeTheme('dark-a', 'dark'),
}, { allowPromptingSubagentSessions: true });
expect(new URL(src).searchParams.get('allowPromptingSubagentSessions')).toBe('1');
});
test('rebuilds cached src when readOnly changes for an existing tab', () => {
const cache = new Map<string, EmbeddedSessionChatURLCacheEntry>();
const theme = {
@@ -145,6 +159,38 @@ describe('embedded session chat URL', () => {
});
});
describe('active embedded session chat', () => {
const tabs = Array.from({ length: 8 }, (_, index) => ({
id: `chat-${index + 1}`,
sessionID: `ses_${index + 1}`,
}));
test('selects one tab from persisted chat tabs', () => {
expect(getActiveEmbeddedSessionChatTab(tabs, 'chat-5')).toEqual(tabs[4]);
});
test('selects no tab when a chat is not active', () => {
expect(getActiveEmbeddedSessionChatTab(tabs, null)).toBeNull();
expect(getActiveEmbeddedSessionChatTab(tabs, 'missing-chat')).toBeNull();
});
test('requests authoritative visibility from the same-origin parent', () => {
installWindowLocation('http://127.0.0.1:5173/app?ocPanel=session-chat&sessionId=ses_1');
resetEmbeddedSessionChatCache();
const calls: Array<{ message: unknown; origin: string }> = [];
(window as unknown as { parent: { postMessage: (message: unknown, origin: string) => void } }).parent = {
postMessage: (message, origin) => calls.push({ message, origin }),
};
requestEmbeddedSessionVisibility();
expect(calls).toEqual([{
message: { type: EMBEDDED_VISIBILITY_REQUEST },
origin: 'http://127.0.0.1:5173',
}]);
});
});
describe('isEmbeddedSessionChat', () => {
test('is true only for the session-chat panel search param', () => {
installWindowLocation('http://127.0.0.1:5173/app?ocPanel=session-chat&sessionId=ses_1');
@@ -8,6 +8,10 @@ export type EmbeddedSessionChatThemeBootstrap = {
currentTheme: Theme;
};
export type EmbeddedSessionChatSettingsBootstrap = {
allowPromptingSubagentSessions: boolean;
};
export type EmbeddedSessionChatURLCacheEntry = {
signature: string;
src: string;
@@ -24,6 +28,8 @@ export type EmbeddedSessionRuntimeBootstrap = {
export const EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST = 'openchamber:embedded-runtime-bootstrap-request';
export const EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE = 'openchamber:embedded-runtime-bootstrap-response';
export const EMBEDDED_VISIBILITY_REQUEST = 'openchamber:embedded-visibility-request';
export const EMBEDDED_VISIBILITY_UPDATE = 'openchamber:embedded-visibility';
const EMBEDDED_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 5_000;
const EMBEDDED_RUNTIME_BOOTSTRAP_RETRY_MS = 100;
@@ -100,6 +106,13 @@ export const requestEmbeddedSessionRuntimeBootstrap = (): Promise<EmbeddedSessio
});
};
export const requestEmbeddedSessionVisibility = (): void => {
if (!isEmbeddedSessionChat() || typeof window === 'undefined' || !window.parent || window.parent === window) {
return;
}
window.parent.postMessage({ type: EMBEDDED_VISIBILITY_REQUEST }, window.location.origin);
};
const buildEmbeddedSessionChatURLSignature = (
sessionID: string,
directory: string | null,
@@ -111,6 +124,7 @@ export const buildEmbeddedSessionChatURL = (
directory: string | null,
readOnly: boolean,
theme: EmbeddedSessionChatThemeBootstrap,
settings?: EmbeddedSessionChatSettingsBootstrap,
): string => {
if (typeof window === 'undefined') {
return '';
@@ -134,6 +148,9 @@ export const buildEmbeddedSessionChatURL = (
url.searchParams.set('lightThemeId', theme.lightThemeId);
url.searchParams.set('darkThemeId', theme.darkThemeId);
url.searchParams.set('themeVariant', theme.currentTheme.metadata.variant === 'dark' ? 'dark' : 'light');
if (settings) {
url.searchParams.set('allowPromptingSubagentSessions', settings.allowPromptingSubagentSessions ? '1' : '0');
}
url.hash = '';
return url.toString();
@@ -146,6 +163,7 @@ export const getOrCreateEmbeddedSessionChatURL = (
directory: string | null,
readOnly: boolean,
theme: EmbeddedSessionChatThemeBootstrap,
settings?: EmbeddedSessionChatSettingsBootstrap,
): string => {
const signature = buildEmbeddedSessionChatURLSignature(sessionID, directory, readOnly);
const existing = cache.get(tabID);
@@ -153,11 +171,22 @@ export const getOrCreateEmbeddedSessionChatURL = (
return existing.src;
}
const src = buildEmbeddedSessionChatURL(sessionID, directory, readOnly, theme);
const src = buildEmbeddedSessionChatURL(sessionID, directory, readOnly, theme, settings);
cache.set(tabID, { signature, src });
return src;
};
export const getActiveEmbeddedSessionChatTab = <T extends { id: string }>(
tabs: T[],
activeTabID: string | null,
): T | null => {
if (!activeTabID) {
return null;
}
return tabs.find((tab) => tab.id === activeTabID) ?? null;
};
/**
* True when the current document is the embedded session-chat iframe
* (`?ocPanel=session-chat`). Used to distinguish the embedded iframe from
@@ -22,7 +22,6 @@ import { McpIcon } from '@/components/icons/McpIcon';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { toast } from 'sonner';
import { isVSCodeRuntime } from '@/lib/desktop';
import { startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization';
const statusTooltip = (
@@ -211,7 +210,6 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
const { opened } = await startMcpAuthorization({
name: serverName,
directory,
skipRedirectUriBootstrap: isVSCodeRuntime(),
});
if (!opened) {
toast.error(t('mcpDropdown.toast.authorizeOpenFailed'));
@@ -288,9 +288,9 @@ const SortableProviderSection: React.FC<{
};
const STICKY_HEADER_OFFSET = 32;
const STICKY_FADE_MAX_SIZE = 48;
const STICKY_FADE_MIN_SIZE = 32;
const STICKY_FADE_CLEAR_MAX_SIZE = 24;
const STICKY_FADE_MAX_SIZE = 52;
const STICKY_FADE_MIN_SIZE = 36;
const STICKY_FADE_CLEAR_MAX_SIZE = 28;
const scrollIntoView = (container: HTMLElement | null, node: HTMLElement | null) => {
if (!node) return;
@@ -420,8 +420,8 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
const selectionStore = selectionStoreRef.current;
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const scrollRef = React.useRef<HTMLElement | null>(null);
const sectionHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
const stickyFadeSizeRef = React.useRef(0);
const sectionHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
const [stuckSectionHeaders, setStuckSectionHeaders] = React.useState<Set<string>>(new Set());
const keyboardOwnsSelectionRef = React.useRef(false);
const lastMousePositionRef = React.useRef<{ x: number; y: number } | null>(null);
@@ -882,8 +882,8 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
<ScrollableOverlay
ref={scrollRef}
useScrollShadow={stickyHeaders}
hideTopScrollShadow={!stickyHeaders}
scrollShadowSize={96}
hideBottomScrollShadow
scrollShadowSize={12}
outerClassName={maxHeightClassName}
className="oc-sticky-fade-scroller overlay-scrollbar-target--no-gutter"
style={{
@@ -0,0 +1,74 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import type { IconName } from '@/components/icon/icons';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type ComingSoonMessenger = {
id: 'discord' | 'telegram';
icon: IconName;
brandClassName: string;
nameKey: I18nKey;
descriptionKey: I18nKey;
};
const COMING_SOON_MESSENGERS: readonly ComingSoonMessenger[] = [
{
id: 'discord',
icon: 'discord-fill',
brandClassName: 'text-[#5865F2]',
nameKey: 'settings.integrations.messengers.discord.name',
descriptionKey: 'settings.integrations.messengers.discord.description',
},
{
id: 'telegram',
icon: 'telegram-fill',
brandClassName: 'text-[#2AABEE]',
nameKey: 'settings.integrations.messengers.telegram.name',
descriptionKey: 'settings.integrations.messengers.telegram.description',
},
] as const;
/**
* Non-interactive Discord/Telegram placeholders same card chrome as live
* integrations, greyed out, with a Coming soon badge and no expandable body.
*/
export const ComingSoonMessengersSection: React.FC = () => {
const { t } = useI18n();
return (
<SettingsSection
title={t('settings.integrations.messengers.title')}
info={t('settings.integrations.messengers.info')}
divider={false}
settingsItem="integrations.messengers"
contentClassName="space-y-3"
>
{COMING_SOON_MESSENGERS.map((messenger) => (
<div
key={messenger.id}
data-settings-item={`integrations.messengers.${messenger.id}`}
aria-disabled="true"
className={cn(
'flex min-w-0 items-center gap-3 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-4 py-3',
'pointer-events-none opacity-60',
)}
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name={messenger.icon} className={cn('size-5', messenger.brandClassName)} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{t(messenger.nameKey)}</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t(messenger.descriptionKey)}
</p>
</div>
<span className="max-w-36 shrink-0 truncate rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{t('settings.common.state.comingSoon')}
</span>
</div>
))}
</SettingsSection>
);
};
@@ -0,0 +1,31 @@
import React from 'react';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { useI18n } from '@/lib/i18n';
import { ComingSoonMessengersSection } from './ComingSoonMessengersSection';
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
interface IntegrationsPageProps {
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
onOpenPluginManager: () => void;
}
export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
onOpenProviderSetup,
onOpenPluginManager,
}) => {
const { t } = useI18n();
return (
<SettingsPageLayout
title={t('settings.page.integrations.title')}
description={t('settings.page.integrations.description')}
showSaveStatus={false}
>
<ComingSoonMessengersSection />
<ThirdPartyIntegrationsSection
onOpenProviderSetup={onOpenProviderSetup}
onOpenPluginManager={onOpenPluginManager}
/>
</SettingsPageLayout>
);
};
@@ -0,0 +1,439 @@
import React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useI18n } from '@/lib/i18n';
import { openExternalUrl } from '@/lib/url';
import { cn } from '@/lib/utils';
import {
usePluginsStore,
type PluginMutationResult,
} from '@/stores/usePluginsStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import {
getCatalogPluginPrimaryAction,
getCatalogPluginPresentation,
getCatalogPluginState,
getLatestNpmSpec,
THIRD_PARTY_PLUGINS,
type ThirdPartyPluginDefinition,
} from './thirdPartyPlugins';
type PendingAction = 'install' | 'update' | 'setup' | 'remove';
type RemoveTarget = ThirdPartyPluginDefinition | null;
interface ThirdPartyIntegrationsSectionProps {
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
onOpenPluginManager: () => void;
}
const requiresRestart = (result: PluginMutationResult): boolean =>
result.restartDeferred === true
|| result.requiresManualRestart === true
|| result.reloadFailed === true;
export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSectionProps> = ({
onOpenProviderSetup,
onOpenPluginManager,
}) => {
const { t } = useI18n();
const {
entries,
registryInfo,
loadPlugins,
loadRegistryInfo,
createEntry,
updateEntry,
deleteEntry,
} = usePluginsStore(
useShallow((state) => ({
entries: state.entries,
registryInfo: state.registryInfo,
loadPlugins: state.loadPlugins,
loadRegistryInfo: state.loadRegistryInfo,
createEntry: state.createEntry,
updateEntry: state.updateEntry,
deleteEntry: state.deleteEntry,
})),
);
const [registryLoadFailed, setRegistryLoadFailed] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<{
pluginId: string;
action: PendingAction;
} | null>(null);
const [restartRequiredIds, setRestartRequiredIds] = React.useState<ReadonlySet<string>>(
() => new Set(),
);
const [providerUnavailableIds, setProviderUnavailableIds] = React.useState<ReadonlySet<string>>(
() => new Set(),
);
const [removeTarget, setRemoveTarget] = React.useState<RemoveTarget>(null);
const [openPluginIds, setOpenPluginIds] = React.useState<ReadonlySet<string>>(() => new Set());
const refresh = React.useCallback(async () => {
const pluginsLoaded = await loadPlugins({ force: true });
if (!pluginsLoaded) {
setRegistryLoadFailed(true);
return;
}
const latestEntries = usePluginsStore.getState().entries;
const specs = new Set(THIRD_PARTY_PLUGINS.map((plugin) => plugin.packageName));
for (const entry of latestEntries) {
if (THIRD_PARTY_PLUGINS.some((plugin) => entry.spec === plugin.packageName || entry.spec.startsWith(`${plugin.packageName}@`))) {
specs.add(entry.spec);
}
}
const registryLoaded = await loadRegistryInfo({ specs: [...specs], force: true });
setRegistryLoadFailed(!registryLoaded);
}, [loadPlugins, loadRegistryInfo]);
React.useEffect(() => {
void refresh();
}, [refresh]);
const pendingPluginRestartCount = usePendingOpenCodeRestartStore(
(state) => state.changes.filter((change) => change.scope === 'plugins').length,
);
const isApplyingRestart = usePendingOpenCodeRestartStore((state) => state.isApplying);
const previousPluginRestartCountRef = React.useRef(pendingPluginRestartCount);
// When deferred plugin restarts are applied (pending plugins scope clears), drop
// local restart/unavailable flags and reload so statuses update immediately.
React.useEffect(() => {
const previousCount = previousPluginRestartCountRef.current;
previousPluginRestartCountRef.current = pendingPluginRestartCount;
if (isApplyingRestart) {
return;
}
if (previousCount <= 0 || pendingPluginRestartCount > 0) {
return;
}
setRestartRequiredIds(new Set());
setProviderUnavailableIds(new Set());
void refresh();
}, [isApplyingRestart, pendingPluginRestartCount, refresh]);
const setRestartRequired = React.useCallback((pluginId: string, required: boolean) => {
setRestartRequiredIds((current) => {
const next = new Set(current);
if (required) next.add(pluginId);
else next.delete(pluginId);
return next;
});
}, []);
const setProviderUnavailable = React.useCallback((pluginId: string, unavailable: boolean) => {
setProviderUnavailableIds((current) => {
const next = new Set(current);
if (unavailable) next.add(pluginId);
else next.delete(pluginId);
return next;
});
}, []);
const runMutation = React.useCallback(async (
plugin: ThirdPartyPluginDefinition,
action: Exclude<PendingAction, 'setup'>,
run: () => Promise<PluginMutationResult>,
) => {
setPendingAction({ pluginId: plugin.id, action });
try {
const result = await run();
if (!result.ok) {
toast.error(t('settings.integrations.thirdParty.toast.actionFailed'));
return;
}
setProviderUnavailable(plugin.id, false);
const restartNeeded = requiresRestart(result);
setRestartRequired(plugin.id, restartNeeded);
const toastOptions = restartNeeded
? { description: t('settings.integrations.thirdParty.toast.restartRequired') }
: undefined;
if (action === 'install') {
toast.success(t('settings.integrations.thirdParty.toast.installed', { name: t(plugin.nameKey) }), toastOptions);
} else if (action === 'update') {
toast.success(t('settings.integrations.thirdParty.toast.updated', { name: t(plugin.nameKey) }), toastOptions);
} else {
toast.success(t('settings.integrations.thirdParty.toast.removed', { name: t(plugin.nameKey) }), toastOptions);
}
await refresh();
} finally {
setPendingAction(null);
}
}, [refresh, setProviderUnavailable, setRestartRequired, t]);
const handlePrimaryAction = React.useCallback(async (plugin: ThirdPartyPluginDefinition) => {
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
const action = getCatalogPluginPrimaryAction(state, plugin.packageName);
if (action === 'manage') {
onOpenPluginManager();
return;
}
if (action === 'setup') {
setPendingAction({ pluginId: plugin.id, action });
try {
const opened = await onOpenProviderSetup(plugin.providerId);
setProviderUnavailable(plugin.id, !opened);
if (!opened) {
toast.error(t('settings.integrations.thirdParty.toast.providerUnavailable'));
}
} finally {
setPendingAction(null);
}
return;
}
const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry);
if (!latestSpec) {
setRegistryLoadFailed(true);
return;
}
if (action === 'install') {
await runMutation(plugin, 'install', () => createEntry({ spec: latestSpec, scope: 'user' }));
return;
}
if (state.userEntry) {
await runMutation(plugin, 'update', () => updateEntry(state.userEntry!.id, { spec: latestSpec }));
}
}, [createEntry, entries, onOpenPluginManager, onOpenProviderSetup, registryInfo, runMutation, setProviderUnavailable, t, updateEntry]);
const handleRemove = React.useCallback(async () => {
const plugin = removeTarget;
if (!plugin) return;
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
if (!state.userEntry || state.userEntryIsAmbiguous) {
setRemoveTarget(null);
onOpenPluginManager();
return;
}
setRemoveTarget(null);
await runMutation(plugin, 'remove', () => deleteEntry(state.userEntry!.id));
}, [deleteEntry, entries, onOpenPluginManager, registryInfo, removeTarget, runMutation]);
const setPluginOpen = React.useCallback((pluginId: string, open: boolean) => {
setOpenPluginIds((current) => {
const next = new Set(current);
if (open) next.add(pluginId);
else next.delete(pluginId);
return next;
});
}, []);
const renderPlugin = (plugin: ThirdPartyPluginDefinition) => {
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
const primaryAction = getCatalogPluginPrimaryAction(state, plugin.packageName);
const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry);
const isPending = pendingAction?.pluginId === plugin.id;
const isRestartRequired = restartRequiredIds.has(plugin.id);
const isProviderUnavailable = providerUnavailableIds.has(plugin.id);
const registryUnavailable = registryLoadFailed || state.registry?.kind === 'npm-network';
const actionDisabled = isPending
|| isRestartRequired
|| ((primaryAction === 'install' || primaryAction === 'update') && (registryUnavailable || !latestSpec));
const presentation = getCatalogPluginPresentation(state, {
registryUnavailable,
restartRequired: isRestartRequired,
providerUnavailable: isProviderUnavailable,
});
let status: string;
switch (presentation.status) {
case 'installed-version':
status = presentation.latestVersion
? t('settings.integrations.thirdParty.status.installedVersion', {
version: presentation.latestVersion,
})
: t('settings.integrations.thirdParty.status.installed');
break;
case 'update-available':
status = presentation.latestVersion
? t('settings.integrations.thirdParty.status.updateAvailable', {
version: presentation.latestVersion,
})
: t('settings.integrations.thirdParty.status.unpinned');
break;
case 'not-installed':
status = t('settings.integrations.thirdParty.status.notInstalled');
break;
case 'installed':
status = t('settings.integrations.thirdParty.status.installed');
break;
case 'unpinned':
status = t('settings.integrations.thirdParty.status.unpinned');
break;
case 'ambiguous':
status = t('settings.integrations.thirdParty.status.ambiguous');
break;
case 'restart-required':
status = t('settings.integrations.thirdParty.status.restartRequired');
break;
case 'registry-unavailable':
status = t('settings.integrations.thirdParty.status.registryUnavailable');
break;
case 'provider-unavailable':
status = t('settings.integrations.thirdParty.status.providerUnavailable');
break;
}
const statusClassName = presentation.status === 'installed-version'
? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
: presentation.status === 'update-available'
|| presentation.status === 'ambiguous'
|| presentation.status === 'restart-required'
|| presentation.status === 'registry-unavailable'
|| presentation.status === 'provider-unavailable'
? 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]'
: 'bg-[var(--surface-muted)] text-muted-foreground';
const primaryLabel = {
install: t('settings.integrations.thirdParty.actions.install'),
update: t('settings.integrations.thirdParty.actions.update'),
setup: t('settings.integrations.thirdParty.actions.setup'),
manage: t('settings.integrations.thirdParty.actions.managePlugins'),
}[primaryAction];
const open = openPluginIds.has(plugin.id);
return (
<Collapsible
key={plugin.id}
open={open}
onOpenChange={(nextOpen) => setPluginOpen(plugin.id, nextOpen)}
>
<div
data-settings-item={`integrations.third-party.${plugin.id}`}
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
>
<CollapsibleTrigger
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name={plugin.icon} className={cn('size-5', plugin.brandClassName)} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{t(plugin.nameKey)}</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t(plugin.descriptionKey)}
</p>
</div>
<span
aria-live="polite"
className={cn(
'max-w-36 shrink-0 truncate rounded-full px-2 py-0.5 text-[10px] font-medium',
statusClassName,
)}
>
{status}
</span>
<Icon
name="arrow-down-s"
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out motion-reduce:transition-none',
open && 'rotate-180',
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
<div className="space-y-3">
{state.projectEntries.length > 0 ? (
<p className="text-xs text-muted-foreground">
{t('settings.integrations.thirdParty.status.projectInstalled')}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant={primaryAction === 'manage' ? 'outline' : 'default'}
onClick={() => void handlePrimaryAction(plugin)}
disabled={actionDisabled}
>
{isPending ? (
<Icon name="loader-4" className="size-3.5 animate-spin" />
) : primaryAction === 'setup' ? (
<Icon name="plug-2" className="size-3.5" />
) : null}
{primaryLabel}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void openExternalUrl(plugin.homepage)}
>
<Icon name="external-link" className="size-3.5" />
{t('settings.integrations.thirdParty.actions.docs')}
</Button>
{state.userEntry && !state.userEntryIsAmbiguous ? (
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => setRemoveTarget(plugin)}
disabled={isPending}
>
<Icon name="delete-bin" className="size-3.5" />
{t('settings.integrations.thirdParty.actions.remove')}
</Button>
) : null}
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
};
return (
<>
<SettingsSection
title={t('settings.integrations.thirdParty.title')}
info={t('settings.integrations.thirdParty.info')}
settingsItem="integrations.third-party"
contentClassName="space-y-3"
>
{THIRD_PARTY_PLUGINS.map(renderPlugin)}
</SettingsSection>
<Dialog open={removeTarget !== null} onOpenChange={(open) => !open && setRemoveTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('settings.integrations.thirdParty.dialog.remove.title')}</DialogTitle>
<DialogDescription>
{t('settings.integrations.thirdParty.dialog.remove.description', {
name: removeTarget ? t(removeTarget.nameKey) : '',
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" size="sm" variant="ghost" onClick={() => setRemoveTarget(null)}>
{t('settings.common.actions.cancel')}
</Button>
<Button type="button" size="sm" variant="destructive" onClick={() => void handleRemove()}>
{t('settings.integrations.thirdParty.actions.remove')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,218 @@
import { describe, expect, test } from 'bun:test';
import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore';
import * as thirdPartyCatalog from './thirdPartyPlugins';
import {
getCatalogPluginState,
getCatalogPluginPrimaryAction,
getLatestNpmSpec,
specMatchesPackage,
} from './thirdPartyPlugins';
type CatalogPresentationStatus =
| 'not-installed'
| 'installed'
| 'installed-version'
| 'update-available'
| 'unpinned'
| 'ambiguous'
| 'restart-required'
| 'registry-unavailable'
| 'provider-unavailable';
type GetCatalogPluginPresentation = (
state: ReturnType<typeof getCatalogPluginState>,
options?: {
registryUnavailable?: boolean;
restartRequired?: boolean;
providerUnavailable?: boolean;
},
) => {
status: CatalogPresentationStatus;
latestVersion: string | null;
};
const getCatalogPluginPresentation = (
thirdPartyCatalog as unknown as {
getCatalogPluginPresentation?: GetCatalogPluginPresentation;
}
).getCatalogPluginPresentation;
const claudePackage = '@openchamber/opencode-claude';
const entry = (spec: string, scope: PluginEntry['scope'] = 'user'): PluginEntry => ({
id: `config:${scope}:${spec}`,
spec,
scope,
kind: 'config',
parsedKind: 'npm',
});
const registry = (spec: string, currentVersion: string | null, latestVersion = '0.7.0'): RegistryResult => ({
kind: 'npm-ok',
spec,
name: claudePackage,
currentVersion,
latestVersion,
versions: ['0.6.0', latestVersion],
hasUpdate: currentVersion !== null && currentVersion !== latestVersion,
});
describe('third-party plugin catalog helpers', () => {
test('derives compact-card status with explicit transient-state priority', () => {
expect(typeof getCatalogPluginPresentation).toBe('function');
if (!getCatalogPluginPresentation) return;
const notInstalled = getCatalogPluginState([], claudePackage, {});
expect(getCatalogPluginPresentation(notInstalled)).toEqual({
status: 'not-installed',
latestVersion: null,
});
const current = getCatalogPluginState(
[entry(`${claudePackage}@0.7.0`)],
claudePackage,
{ [`${claudePackage}@0.7.0`]: registry(`${claudePackage}@0.7.0`, '0.7.0') },
);
expect(getCatalogPluginPresentation(current)).toEqual({
status: 'installed-version',
latestVersion: '0.7.0',
});
const outdated = getCatalogPluginState(
[entry(`${claudePackage}@0.6.0`)],
claudePackage,
{ [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') },
);
expect(getCatalogPluginPresentation(outdated)).toEqual({
status: 'update-available',
latestVersion: '0.7.0',
});
expect(getCatalogPluginPresentation(outdated, { registryUnavailable: true })).toEqual({
status: 'registry-unavailable',
latestVersion: '0.7.0',
});
expect(getCatalogPluginPresentation(outdated, { providerUnavailable: true })).toEqual({
status: 'provider-unavailable',
latestVersion: '0.7.0',
});
expect(getCatalogPluginPresentation(outdated, {
providerUnavailable: true,
restartRequired: true,
})).toEqual({
status: 'restart-required',
latestVersion: '0.7.0',
});
const ambiguous = getCatalogPluginState(
[entry(claudePackage), entry(`${claudePackage}@0.6.0`)],
claudePackage,
{},
);
expect(getCatalogPluginPresentation(ambiguous)).toEqual({
status: 'ambiguous',
latestVersion: null,
});
});
test('matches only a package or its versioned spec', () => {
expect(specMatchesPackage(claudePackage, claudePackage)).toBe(true);
expect(specMatchesPackage(`${claudePackage}@0.6.0`, claudePackage)).toBe(true);
expect(specMatchesPackage('@openchamber/opencode-claude-extra@0.6.0', claudePackage)).toBe(false);
});
test('points catalog plugins at the OpenChamber GitHub and npm packages', () => {
expect(thirdPartyCatalog.THIRD_PARTY_PLUGINS.map((plugin) => ({
id: plugin.id,
packageName: plugin.packageName,
homepage: plugin.homepage,
}))).toEqual([
{
id: 'opencode-claude',
packageName: '@openchamber/opencode-claude',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-commandcode',
packageName: '@openchamber/opencode-commandcode',
homepage: 'https://github.com/openchamber/opencode-commandcode',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
homepage: 'https://github.com/openchamber/opencode-cursor',
},
]);
});
test('uses the configured user entry and its registry result', () => {
const installed = entry(`${claudePackage}@0.6.0`);
const state = getCatalogPluginState(
[installed],
claudePackage,
{ [installed.spec]: registry(installed.spec, '0.6.0') },
);
expect(state.userEntry).toEqual(installed);
expect(state.userEntryIsAmbiguous).toBe(false);
expect(state.projectEntries).toEqual([]);
expect(state.registry).toEqual(registry(installed.spec, '0.6.0'));
});
test('does not choose an entry when multiple user specs would make a mutation ambiguous', () => {
const state = getCatalogPluginState(
[entry(claudePackage), entry(`${claudePackage}@0.6.0`), entry(claudePackage, 'project')],
claudePackage,
{},
);
expect(state.userEntry).toBeNull();
expect(state.userEntryIsAmbiguous).toBe(true);
expect(state.projectEntries).toHaveLength(1);
});
test('returns an exact latest spec only from a valid npm registry result', () => {
expect(getLatestNpmSpec(claudePackage, registry(claudePackage, null))).toBe(`${claudePackage}@0.7.0`);
expect(getLatestNpmSpec(claudePackage, {
kind: 'npm-network',
spec: claudePackage,
error: 'offline',
})).toBeNull();
});
test('chooses an update for a bare or outdated user-wide entry', () => {
const bare = getCatalogPluginState(
[entry(claudePackage)],
claudePackage,
{ [claudePackage]: registry(claudePackage, null) },
);
const outdated = getCatalogPluginState(
[entry(`${claudePackage}@0.6.0`)],
claudePackage,
{ [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') },
);
expect(getCatalogPluginPrimaryAction(bare, claudePackage)).toBe('update');
expect(getCatalogPluginPrimaryAction(outdated, claudePackage)).toBe('update');
});
test('keeps setup as the primary action once the exact latest spec is installed', () => {
const installed = entry(`${claudePackage}@0.7.0`);
const state = getCatalogPluginState(
[installed],
claudePackage,
{ [installed.spec]: registry(installed.spec, '0.7.0') },
);
expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('setup');
});
test('sends ambiguous entries to manual plugin management', () => {
const state = getCatalogPluginState(
[entry(claudePackage), entry(`${claudePackage}@0.6.0`)],
claudePackage,
{},
);
expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('manage');
});
});
@@ -0,0 +1,166 @@
import type { IconName } from '@/components/icon/icons';
import type { I18nKey } from '@/lib/i18n';
import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore';
export interface ThirdPartyPluginDefinition {
id: string;
packageName: string;
providerId: string;
icon: IconName;
/** Brand mark tint (e.g. Claude orange); neutral marks use text-foreground. */
brandClassName: string;
nameKey: I18nKey;
descriptionKey: I18nKey;
homepage: string;
}
export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [
{
id: 'opencode-claude',
packageName: '@openchamber/opencode-claude',
providerId: 'claude-code',
icon: 'claude-code',
brandClassName: 'text-[#D97757]',
nameKey: 'settings.integrations.thirdParty.opencodeClaude.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-commandcode',
packageName: '@openchamber/opencode-commandcode',
providerId: 'command-code',
icon: 'command-code',
brandClassName: 'text-foreground',
nameKey: 'settings.integrations.thirdParty.opencodeCommandcode.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description',
homepage: 'https://github.com/openchamber/opencode-commandcode',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
providerId: 'cursor',
icon: 'cursor',
brandClassName: 'text-foreground',
nameKey: 'settings.integrations.thirdParty.opencodeCursorOauth.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeCursorOauth.description',
homepage: 'https://github.com/openchamber/opencode-cursor',
},
] as const;
export interface CatalogPluginState {
userEntry: PluginEntry | null;
userEntryIsAmbiguous: boolean;
projectEntries: PluginEntry[];
registry: RegistryResult | null;
}
export type CatalogPluginPrimaryAction = 'install' | 'update' | 'setup' | 'manage';
type CatalogPluginPresentationStatus =
| 'not-installed'
| 'installed'
| 'installed-version'
| 'update-available'
| 'unpinned'
| 'ambiguous'
| 'restart-required'
| 'registry-unavailable'
| 'provider-unavailable';
interface CatalogPluginPresentationOptions {
registryUnavailable?: boolean;
restartRequired?: boolean;
providerUnavailable?: boolean;
}
interface CatalogPluginPresentation {
status: CatalogPluginPresentationStatus;
latestVersion: string | null;
}
export const specMatchesPackage = (spec: string, packageName: string): boolean =>
spec === packageName || spec.startsWith(`${packageName}@`);
export function getCatalogPluginState(
entries: PluginEntry[],
packageName: string,
registryInfo: Record<string, RegistryResult>,
): CatalogPluginState {
const matchingEntries = entries.filter((entry) => specMatchesPackage(entry.spec, packageName));
const userEntries = matchingEntries.filter((entry) => entry.scope === 'user');
const projectEntries = matchingEntries.filter((entry) => entry.scope === 'project');
const userEntry = userEntries.length === 1 ? userEntries[0] : null;
const registry = registryInfo[userEntry?.spec ?? packageName] ?? registryInfo[packageName] ?? null;
return {
userEntry,
userEntryIsAmbiguous: userEntries.length > 1,
projectEntries,
registry,
};
}
export function getLatestNpmSpec(
packageName: string,
registry: RegistryResult | null | undefined,
): string | null {
if (registry?.kind !== 'npm-ok' || registry.name !== packageName || !registry.latestVersion) {
return null;
}
return `${packageName}@${registry.latestVersion}`;
}
export function getCatalogPluginPrimaryAction(
state: CatalogPluginState,
packageName: string,
): CatalogPluginPrimaryAction {
if (state.userEntryIsAmbiguous) {
return 'manage';
}
if (!state.userEntry) {
return 'install';
}
const latestSpec = getLatestNpmSpec(packageName, state.registry);
return latestSpec && latestSpec !== state.userEntry.spec ? 'update' : 'setup';
}
/**
* Converts catalog and temporary mutation state into the one compact-card
* status. Transient states intentionally outrank installed/version metadata.
*/
export function getCatalogPluginPresentation(
state: CatalogPluginState,
options: CatalogPluginPresentationOptions = {},
): CatalogPluginPresentation {
const latestVersion = state.registry?.kind === 'npm-ok'
? state.registry.latestVersion
: null;
if (state.userEntryIsAmbiguous) {
return { status: 'ambiguous', latestVersion };
}
if (options.restartRequired) {
return { status: 'restart-required', latestVersion };
}
if (options.providerUnavailable) {
return { status: 'provider-unavailable', latestVersion };
}
if (options.registryUnavailable) {
return { status: 'registry-unavailable', latestVersion };
}
if (!state.userEntry) {
return { status: 'not-installed', latestVersion };
}
if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === state.registry.latestVersion) {
return { status: 'installed-version', latestVersion };
}
if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === null) {
return { status: 'unpinned', latestVersion };
}
if (state.registry?.kind === 'npm-ok' && latestVersion) {
return { status: 'update-available', latestVersion };
}
return { status: 'installed', latestVersion };
}
@@ -18,6 +18,7 @@ import {
applyImportedMcpToDraft,
} from './mcpImport';
import { useMcpStore } from '@/stores/useMcpStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { cn } from '@/lib/utils';
@@ -439,6 +440,7 @@ const StatusBadge: React.FC<{
failed: { text: 'text-[var(--status-error)]', bg: 'bg-[var(--status-error)]/10' },
needs_auth: { text: 'text-[var(--status-warning)]', bg: 'bg-[var(--status-warning)]/10' },
needs_client_registration: { text: 'text-[var(--status-warning)]', bg: 'bg-[var(--status-warning)]/10' },
awaiting_restart: { text: 'text-[var(--status-warning)]', bg: 'bg-[var(--status-warning)]/10' },
};
const colors = colorClassMap[status] ?? { text: 'text-muted-foreground', bg: '' };
@@ -584,6 +586,7 @@ export const McpPage: React.FC = () => {
const completeAuthMcp = useMcpStore((state) => state.completeAuth);
const clearAuthMcp = useMcpStore((state) => state.clearAuth);
const testConnectionMcp = useMcpStore((state) => state.testConnection);
const pendingRestartChanges = usePendingOpenCodeRestartStore((state) => state.changes);
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName) : null;
const isNewServer = Boolean(mcpDraft && mcpDraft.name === selectedMcpName && !selectedServer);
@@ -1032,15 +1035,40 @@ export const McpPage: React.FC = () => {
// One implementation for every surface that can authorise; the page
// used to own this flow while the dropdown and the work-status panel
// called plain `connect`, which cannot start OAuth at all.
const { authorizationUrl: nextAuthUrl, opened } = await startMcpAuthorization({
const { authorizationUrl: nextAuthUrl, opened, nativeFlow, completion } = await startMcpAuthorization({
name: selectedMcpName,
directory: currentDirectory,
// Only VS Code keeps OpenCode's own redirect. Skipping whenever some
// value was stored left a stale one — a dead loopback port from an
// earlier launch — unrepairable from this page; the bootstrap already
// rewrites nothing when the stored value is right.
skipRedirectUriBootstrap: isVSCodeAuthRuntime,
});
if (nativeFlow) {
// OpenCode opened the browser and completes the flow itself; there is
// no URL or state to track. The completion promise is the authoritative
// end signal — status polling alone cannot tell a finished
// reauthorization from the still-connected state it started in.
if (runtimeActionKeyRef.current !== actionKey) return;
setAuthUrl(null);
setAuthStateKey(null);
setIsAuthPolling(true);
authPollAttemptsRef.current = 0;
toast.message(t('settings.mcp.page.toast.completeAuthorizationInBrowser'));
completion
?.then(() => {
if (runtimeActionKeyRef.current !== actionKey) return;
setIsAuthPolling(false);
authPollAttemptsRef.current = 0;
authPollStartsFromNeedsAuthRef.current = false;
toast.success(t('settings.mcp.page.toast.authorizationCompleted'));
})
.catch((completionError) => {
if (runtimeActionKeyRef.current !== actionKey) return;
setIsAuthPolling(false);
authPollAttemptsRef.current = 0;
authPollStartsFromNeedsAuthRef.current = false;
toast.error(normalizeMcpAuthErrorMessage(completionError, t('settings.mcp.page.toast.authorizationFailed'), tUnsafe));
});
return;
}
const stateKey = parseMcpOAuthCallbackStateKey(new URL(nextAuthUrl).searchParams);
queuedStateKey = stateKey;
@@ -1269,6 +1297,12 @@ export const McpPage: React.FC = () => {
const runtimeStatus = mcpStatus[selectedMcpName];
const runtimeDiagnostic = selectedMcpName ? mcpDiagnostics[selectedMcpName] : undefined;
const effectiveRuntimeStatus = runtimeStatus ?? runtimeDiagnostic;
// Saved into the config but queued behind Apply & Restart: OpenCode does not
// know this server yet, so every runtime action (connect, authorize, clear
// auth) can only fail with "server not found". The page says that instead of
// offering the buttons.
const isAwaitingRestart = !isNewServer && !effectiveRuntimeStatus
&& pendingRestartChanges.some((change) => change.scope === 'mcp' && change.id.startsWith(`mcp:${selectedMcpName}:`));
const isConnected = runtimeStatus?.status === 'connected';
const needsAuthorization = runtimeStatus?.status === 'needs_auth' || runtimeStatus?.status === 'needs_client_registration';
// Must be the very URI `startMcpAuthorization` writes into the config, not a
@@ -1305,6 +1339,8 @@ export const McpPage: React.FC = () => {
return t('settings.mcp.page.status.label.needsAuth');
case 'needs_client_registration':
return t('settings.mcp.page.status.label.needsRegistration');
case 'awaiting_restart':
return t('settings.mcp.page.status.label.awaitingRestart');
default:
return status;
}
@@ -1315,12 +1351,17 @@ export const McpPage: React.FC = () => {
<SettingsPageLayout
title={isNewServer ? t('settings.mcp.page.header.newServer') : selectedMcpName}
titleAccessory={!isNewServer ? (
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} variant="pill" />
<StatusBadge
status={isAwaitingRestart ? 'awaiting_restart' : effectiveRuntimeStatus?.status}
enabled={enabled}
getStatusLabel={getStatusLabel}
variant="pill"
/>
) : undefined}
description={isNewServer
? t('settings.mcp.page.header.configureNewServer')
: t('settings.mcp.page.header.transport', { type: mcpType === 'local' ? t('settings.mcp.page.transport.local') : t('settings.mcp.page.transport.remote') })}
headerEnd={!isNewServer ? (
headerEnd={!isNewServer && !isAwaitingRestart ? (
<div className="flex flex-wrap items-center gap-2">
<Button
variant={isConnected ? 'outline' : 'default'}
@@ -1340,11 +1381,15 @@ export const McpPage: React.FC = () => {
onClick={() => void handleStartAuthorization()}
disabled={isAuthorizing || !enabled}
>
{/* "Reauthorize" only once a working authorization exists (the
server is connected); every other state needs_auth,
failed, still unknown reads "Authorize" so the label does
not imply stored credentials that may not be there. */}
{isAuthorizing
? t('settings.mcp.page.actions.starting')
: needsAuthorization
? t('settings.mcp.page.actions.authorize')
: t('settings.mcp.page.actions.reauthorize')}
: isConnected
? t('settings.mcp.page.actions.reauthorize')
: t('settings.mcp.page.actions.authorize')}
</Button>
<Button
variant="ghost"
@@ -1375,8 +1420,26 @@ export const McpPage: React.FC = () => {
{/* Saved but queued behind Apply & Restart: dynamic status the user
must see, or the missing action buttons read as a broken page. */}
{isAwaitingRestart && (
<SettingsSection divider={false}>
<div className="rounded-lg border p-3 border-[var(--status-warning-border)] bg-[var(--status-warning-background)]">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.mcp.page.status.runtimeStatus')}</span>
<StatusBadge status="awaiting_restart" enabled={enabled} getStatusLabel={getStatusLabel} />
</div>
<p className="typography-meta text-muted-foreground">
{t('settings.mcp.page.status.description.awaitingRestart')}
</p>
</div>
</div>
</SettingsSection>
)}
{/* Runtime Status - Simplified for connected, expanded for errors */}
{!isNewServer && shouldShowFullStatusCard(effectiveRuntimeStatus?.status, authUrl, needsAuthorization, isAuthPolling) && (
{!isNewServer && !isAwaitingRestart && shouldShowFullStatusCard(effectiveRuntimeStatus?.status, authUrl, needsAuthorization, isAuthPolling) && (
<SettingsSection divider={false}>
<div className={cn('rounded-lg border p-3', statusCardClass(effectiveRuntimeStatus?.status))}>
<div className="space-y-4">
@@ -1,7 +1,8 @@
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { applyPendingOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
import { openExternalUrl } from '@/lib/url';
import { focusDesktopWindow, isDesktopShell } from '@/lib/desktop';
import { focusDesktopWindow, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackStateKey } from './mcpOAuth';
@@ -32,6 +33,20 @@ type McpAuthorizationStart = {
authorizationUrl: string;
/** False when the runtime refused to open a browser; the caller then offers a manual paste. */
opened: boolean;
/**
* True when OpenCode runs the whole flow itself over its fixed loopback
* listener: it opened the browser, waits for the callback, and exchanges the
* code. There is no URL to display and no state to correlate callers watch
* runtime status until it turns `connected`.
*/
nativeFlow?: boolean;
/**
* Native flow only: resolves when OpenCode finishes the whole exchange (or
* rejects when it fails) the precise "authorization is over" signal, since
* runtime status alone cannot distinguish a completed reauthorization from
* the still-connected state it started in.
*/
completion?: Promise<void>;
};
class McpAuthorizationError extends Error {}
@@ -105,6 +120,46 @@ const clearPendingContext = async (state: string | null): Promise<void> => {
.catch(() => undefined);
};
/**
* One-time migration for the native desktop flow: earlier versions wrote this
* app's per-launch callback URL into the server's config, and OpenCode derives
* its listener from that field pointed at OUR port, it either fails to bind
* or the callback lands on a flow that never registered it. Clearing the field
* returns OpenCode to its fixed default port. Applied immediately when the
* write gets queued behind Apply & Restart, for the same reason as the
* callback-URL write below: authorization runs against the live runtime.
*/
const clearCustomRedirectUriForNativeFlow = async (name: string): Promise<void> => {
if (!useMcpConfigStore.getState().getMcpByName(name)) {
await useMcpConfigStore.getState().loadMcpConfigs();
}
const configStore = useMcpConfigStore.getState();
const existing = configStore.getMcpByName(name);
const currentOAuth = existing && 'oauth' in existing && existing.oauth ? existing.oauth : null;
if (!existing || !currentOAuth?.redirectUri) return;
const saved = await configStore.updateMcp(name, {
oauthEnabled: true,
oauthClientId: currentOAuth.clientId ?? '',
oauthClientSecret: currentOAuth.clientSecret ?? '',
oauthScope: currentOAuth.scope ?? '',
oauthRedirectUri: '',
});
if (!saved.ok) {
throw new McpAuthorizationError(saved.message || 'Failed to reset the authorization callback URL');
}
if (saved.restartDeferred) {
const applied = await applyPendingOpenCodeRestart();
if (!applied.ok) {
throw new McpAuthorizationError(
applied.requiresManualRestart
? 'The callback settings changed, but OpenCode must be restarted manually before authorization can start.'
: 'Failed to apply the callback settings. Use Apply & Restart, then authorize again.',
);
}
}
};
/** How long the user plausibly spends authorising before giving up on them. */
const AUTHORIZATION_WATCH_MS = 3 * 60_000;
const AUTHORIZATION_POLL_MS = 1_500;
@@ -129,14 +184,33 @@ const waitForAuthorizationThenFocus = async (name: string, directory: string | n
export const startMcpAuthorization = async (input: {
name: string;
directory?: string | null;
/** VS Code cannot receive our callback route, so it keeps OpenCode's own redirect. */
skipRedirectUriBootstrap?: boolean;
}): Promise<McpAuthorizationStart> => {
const { name, directory } = input;
let queuedState: string | null = null;
// Runtimes where the system browser provably lives on the same machine as
// OpenCode — desktop with the LOCAL embedded server, and VS Code (the
// extension always spawns its own local OpenCode): OpenCode's own flow works
// end-to-end over its FIXED loopback port (19876) — no config writes, no
// OpenCode restarts, no dependence on this app's per-launch port. The custom
// callback URL below stays for every case where the browser cannot reach
// OpenCode's loopback: remote instances, hosted web, mobile — and the plain
// web runtime too, because same-origin says nothing about the browser being
// on the server's machine.
if (isVSCodeRuntime() || (isDesktopShell() && getRuntimeKey() === 'local')) {
await clearCustomRedirectUriForNativeFlow(name);
const completion = useMcpStore.getState().authenticate(name, directory ?? null);
completion
.then(() => focusDesktopWindow())
.catch(() => {
// Recorded as a runtime diagnostic by the store; the status card and
// the caller's completion handling surface it.
});
return { authorizationUrl: '', opened: true, nativeFlow: true, completion };
}
try {
if (!input.skipRedirectUriBootstrap) {
{
// The config has to be loaded before its absence can mean anything. On
// the first authorization after launch the store is often still empty,
// and reading it then reported "no redirect URI" for a server that had
@@ -176,6 +250,25 @@ export const startMcpAuthorization = async (input: {
saved.message || 'Failed to save the authorization callback URL',
);
}
// Config mutations accumulate behind Apply & Restart now, but the
// authorization flow runs against the LIVE OpenCode runtime: with the
// write still queued, OpenCode hands out its own loopback redirect and
// the callback never reaches us. The user just clicked Authorize —
// explicit intent — so apply the queued changes right away and start
// the flow against the runtime that actually has our callback URL.
if (saved.restartDeferred) {
const applied = await applyPendingOpenCodeRestart();
if (applied.requiresManualRestart) {
throw new McpAuthorizationError(
'The callback URL was saved, but OpenCode must be restarted manually before authorization can start.',
);
}
if (!applied.ok) {
throw new McpAuthorizationError(
'Failed to apply the saved callback URL. Use Apply & Restart, then authorize again.',
);
}
}
}
}
@@ -52,7 +52,7 @@ export const DefaultsSettings: React.FC = () => {
const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>();
const [smallModelUseDefault, setSmallModelUseDefault] = React.useState(true);
const [smallModelOverride, setSmallModelOverride] = React.useState<string | undefined>();
const [smallModelProviders, setSmallModelProviders] = React.useState<string[] | undefined>();
const [smallModelProviders, setSmallModelProviders] = React.useState<string[]>([]);
const [walkthroughModelOverride, setWalkthroughModelOverride] = React.useState<string | undefined>();
const [isLoading, setIsLoading] = React.useState(true);
@@ -274,13 +274,9 @@ export const DefaultsSettings: React.FC = () => {
() => getDisplayModel(walkthroughModelOverride),
[walkthroughModelOverride]
);
React.useEffect(() => {
// Both pickers filter by the same authenticated-provider list, so either
// one being open is reason enough to fetch it.
// Both pickers filter by the same authenticated-provider list, and the
// walkthrough picker is always visible, so this is always worth fetching.
if (smallModelProviders !== undefined) return;
let cancelled = false;
(async () => {
try {
@@ -291,13 +287,13 @@ export const DefaultsSettings: React.FC = () => {
setSmallModelProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string'));
}
} catch {
// leave undefined — picker falls back to showing all providers
// Fail closed: never offer providers whose credentials were not verified.
}
})();
return () => {
cancelled = true;
};
}, [smallModelProviders]);
}, []);
const availableVariants = React.useMemo(() => {
if (!parsedModel.providerId || !parsedModel.modelId) return [];
@@ -10,6 +10,7 @@ import { GitHubSettings } from './GitHubSettings';
import { VoiceSettings } from './VoiceSettings';
import { TunnelSettings } from './TunnelSettings';
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
import { OpenChamberToolsSettings } from './OpenChamberToolsSettings';
import { DesktopNetworkSettings } from './DesktopNetworkSettings';
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
@@ -52,6 +53,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
<DefaultsSettings />
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
{!isVSCode && <OpenCodeCliSettings />}
{!isVSCode && <OpenChamberToolsSettings />}
<SessionRetentionSettings />
{isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp() && <PasskeySettings />}
{showAbout && <AboutSettings />}
@@ -144,6 +146,7 @@ const GeneralSectionContent: React.FC = () => {
{showDesktopNetworkSettings && <DesktopNetworkSettings />}
{showPasskeySettings && <PasskeySettings />}
{!isVSCode && <OpenCodeCliSettings />}
{!isVSCode && <OpenChamberToolsSettings />}
<OpenChamberVisualSettings visibleSettings={[
'fileEditorKeymap',
'autoSaveEnabled',
@@ -0,0 +1,66 @@
import * as React from 'react';
import {
SettingsSection,
SettingsCheckboxRow,
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { updateDesktopSettings } from '@/lib/persistence';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
/**
* Which OpenChamber capabilities agents are given.
*
* Each entry is one tool the managed OpenCode child is handed, so the choices
* belong together and not under the CLI's own configuration the binary path
* is about which OpenCode runs, these are about what it can do.
*
* A toggle is written immediately but only reaches agents once OpenCode
* restarts, so each one records a pending restart rather than implying the
* change is already live.
*/
export const OpenChamberToolsSettings: React.FC = () => {
const { t } = useI18n();
const agentControlToolEnabled = useUIStore((state) => state.agentControlToolEnabled);
const setAgentControlToolEnabled = useUIStore((state) => state.setAgentControlToolEnabled);
const agentWebToolEnabled = useUIStore((state) => state.agentWebToolEnabled);
const setAgentWebToolEnabled = useUIStore((state) => state.setAgentWebToolEnabled);
const handleAgentControlToolChange = React.useCallback((enabled: boolean) => {
setAgentControlToolEnabled(enabled);
void updateDesktopSettings({ agentControlToolEnabled: enabled });
recordDeferredOpenCodeRestart('cli', { id: 'agent-control-tool' });
}, [setAgentControlToolEnabled]);
const handleAgentWebToolChange = React.useCallback((enabled: boolean) => {
setAgentWebToolEnabled(enabled);
void updateDesktopSettings({ agentWebToolEnabled: enabled });
recordDeferredOpenCodeRestart('cli', { id: 'agent-web-tool' });
}, [setAgentWebToolEnabled]);
return (
<SettingsSection title={t('settings.openchamber.tools.title')}>
<div className={SETTINGS_OPTION_STACK_CLASS}>
<SettingsCheckboxRow
settingsItem="sessions.agent-control-tool"
checked={agentControlToolEnabled}
onChange={handleAgentControlToolChange}
label={t('settings.openchamber.tools.field.agentControlTool')}
ariaLabel={t('settings.openchamber.tools.field.agentControlToolAria')}
info={t('settings.openchamber.tools.field.agentControlToolInfo')}
/>
<SettingsCheckboxRow
settingsItem="sessions.agent-web-tool"
checked={agentWebToolEnabled}
onChange={handleAgentWebToolChange}
label={t('settings.openchamber.tools.field.agentWebTool')}
ariaLabel={t('settings.openchamber.tools.field.agentWebToolAria')}
info={t('settings.openchamber.tools.field.agentWebToolInfo')}
/>
</div>
</SettingsSection>
);
};
@@ -26,8 +26,6 @@ export const OpenCodeCliSettings: React.FC = () => {
const [isSaving, setIsSaving] = React.useState(false);
const showOpenCodeUpdateNotifications = useUIStore((state) => state.showOpenCodeUpdateNotifications);
const setShowOpenCodeUpdateNotifications = useUIStore((state) => state.setShowOpenCodeUpdateNotifications);
const agentControlToolEnabled = useUIStore((state) => state.agentControlToolEnabled);
const setAgentControlToolEnabled = useUIStore((state) => state.setAgentControlToolEnabled);
React.useEffect(() => {
let cancelled = false;
@@ -102,11 +100,6 @@ export const OpenCodeCliSettings: React.FC = () => {
void updateDesktopSettings({ showOpenCodeUpdateNotifications: enabled });
}, [setShowOpenCodeUpdateNotifications]);
const handleAgentControlToolChange = React.useCallback((enabled: boolean) => {
setAgentControlToolEnabled(enabled);
void updateDesktopSettings({ agentControlToolEnabled: enabled });
}, [setAgentControlToolEnabled]);
return (
<SettingsSection title={t('settings.openchamber.opencodeCli.title')}>
<div className="space-y-0.5">
@@ -160,15 +153,6 @@ export const OpenCodeCliSettings: React.FC = () => {
/>
)}
<SettingsCheckboxRow
settingsItem="sessions.agent-control-tool"
checked={agentControlToolEnabled}
onChange={handleAgentControlToolChange}
label={t('settings.openchamber.opencodeCli.field.agentControlTool')}
ariaLabel={t('settings.openchamber.opencodeCli.field.agentControlToolAria')}
info={t('settings.openchamber.opencodeCli.field.agentControlToolInfo')}
/>
<div className="flex justify-start py-1.5">
<Button
type="button"
@@ -7,7 +7,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
export const normalizeProjectIconBackground = (value: string | null | undefined): string | null => {
const normalizeProjectIconBackground = (value: string | null | undefined): string | null => {
if (!value) {
return null;
}
@@ -20,6 +20,7 @@ import {
firstUnansweredPrompt,
parseAuthPrompts,
parseAuthorization,
shouldOpenAuthorizationUrl,
visiblePrompts,
type AuthPrompt,
type OAuthAuthorization,
@@ -173,7 +174,10 @@ export const ProviderOAuthMethods: React.FC<ProviderOAuthMethodsProps> = ({
return;
}
if (authorization.url) {
// Claude Code CLI owns its OAuth flow and opens the browser itself. Its
// plugin URL is informational only; opening it creates a misleading docs
// tab alongside the real sign-in page.
if (authorization.url && shouldOpenAuthorizationUrl(providerId, authorization.url)) {
void openExternalUrl(authorization.url);
}
@@ -4,6 +4,7 @@ import {
getOAuthAuthMethods,
normalizeAuthType,
parseAuthPayload,
requiresOpenCodeRestartAfterOAuth,
shouldShowApiKeyAuth,
} from './providerAuth';
@@ -57,4 +58,9 @@ describe('provider auth method helpers', () => {
{ method: { type: 'oauth', label: 'Cursor' }, methodIndex: 0 },
]);
});
test('Claude CLI OAuth does not require an OpenCode restart', () => {
expect(requiresOpenCodeRestartAfterOAuth('claude-code')).toBe(false);
expect(requiresOpenCodeRestartAfterOAuth('github-copilot')).toBe(true);
});
});
@@ -27,6 +27,7 @@ import { shouldLoadAvailableProviders } from './providerAvailability';
import {
getOAuthAuthMethods,
parseAuthPayload,
requiresOpenCodeRestartAfterOAuth,
shouldShowApiKeyAuth,
type AuthMethod,
type OAuthAuthMethodEntry,
@@ -471,7 +472,9 @@ export const ProvidersPage: React.FC = () => {
const handleOAuthConnected = (providerId: string) => {
setShowAuthPanel(false);
recordDeferredOpenCodeRestart('providers', { id: providerId });
if (requiresOpenCodeRestartAfterOAuth(providerId)) {
recordDeferredOpenCodeRestart('providers', { id: providerId });
}
setSelectedProvider(providerId);
};
@@ -6,9 +6,9 @@
export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible';
export const CUSTOM_PROVIDER_ID = '__custom_provider__';
export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
export const BASE_URL_PATTERN = /^https?:\/\//;
export const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
export type CustomProviderTranslator = (
key: string,
@@ -126,7 +126,7 @@ export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({
headers: [createHeaderRow()],
});
export function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
const trimmed = apiKey.trim();
if (!trimmed) {
return {};
@@ -7,11 +7,19 @@ import {
isPromptVisible,
parseAuthPrompts,
parseAuthorization,
shouldOpenAuthorizationUrl,
visiblePrompts,
type AuthPrompt,
type ProviderOAuthTranslator,
} from './provider-oauth';
describe('shouldOpenAuthorizationUrl', () => {
test('lets Claude Code CLI own browser launch', () => {
expect(shouldOpenAuthorizationUrl('claude-code', 'https://docs.example')).toBe(false);
expect(shouldOpenAuthorizationUrl('github-copilot', 'https://github.com/login')).toBe(true);
});
});
/** Mirrors the github-copilot auth method shipped by OpenCode. */
const copilotPrompts = [
{
@@ -29,6 +29,9 @@ export interface OAuthAuthorization {
userCode?: string;
}
export const shouldOpenAuthorizationUrl = (providerId: string, url?: string): boolean =>
Boolean(url) && providerId !== 'claude-code';
export interface AuthPromptOption {
label: string;
value: string;
@@ -57,3 +57,6 @@ export const getOAuthAuthMethods = (methods: AuthMethod[]): OAuthAuthMethodEntry
methods
.map((method, methodIndex) => ({ method, methodIndex }))
.filter(({ method }) => normalizeAuthType(method) === 'oauth');
export const requiresOpenCodeRestartAfterOAuth = (providerId: string): boolean =>
providerId !== 'claude-code';
@@ -12,9 +12,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
const formatProjectLabel = (label: string): string => {
return label.replace(/[-_]/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
};
const formatProjectLabel = (label: string): string => label.trim();
export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ className }) => {
const { t } = useI18n();
@@ -59,7 +59,7 @@ export const SETTINGS_SECTION_TITLE_CLASS =
/** Split-pane sidebar panel title — same level as section titles. */
export const SETTINGS_PANEL_TITLE_CLASS = SETTINGS_SECTION_TITLE_CLASS;
/** L3 — control-group heading inside a section. */
export const SETTINGS_GROUP_TITLE_CLASS =
const SETTINGS_GROUP_TITLE_CLASS =
'typography-settings-group-title text-foreground';
/** L4 — field / control labels. */
export const SETTINGS_FIELD_LABEL_CLASS =
@@ -1,99 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
import type { PaceInfo } from '@/lib/quota';
import { getPaceStatusColor, formatRemainingTime } from '@/lib/quota';
import { useI18n } from '@/lib/i18n';
interface PaceIndicatorProps {
paceInfo: PaceInfo;
className?: string;
/** Compact mode shows just the status dot and prediction */
compact?: boolean;
}
/**
* Visual indicator showing whether usage is on track, slightly fast, or too fast.
* Inspired by opencode-bar's pace visualization.
*/
export const PaceIndicator: React.FC<PaceIndicatorProps> = ({
paceInfo,
className,
compact = false,
}) => {
const { t } = useI18n();
const statusColor = getPaceStatusColor(paceInfo.status);
const statusLabel = React.useMemo(() => {
switch (paceInfo.status) {
case 'on-track':
return t('settings.usage.pace.status.onTrack');
case 'slightly-fast':
return t('settings.usage.pace.status.slightlyFast');
case 'too-fast':
return t('settings.usage.pace.status.tooFast');
case 'exhausted':
return t('settings.usage.pace.status.usedUp');
}
}, [paceInfo.status, t]);
const predictionTooltip = t('settings.usage.pace.predictionTooltip', { prediction: paceInfo.predictText });
if (compact) {
return (
<div className={cn('flex items-center gap-1.5', className)}>
<div
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: statusColor }}
title={statusLabel}
/>
<span
className="typography-micro tabular-nums"
style={{ color: statusColor }}
title={paceInfo.isExhausted ? undefined : predictionTooltip}
>
{paceInfo.isExhausted ? (
<>{t('settings.usage.pace.wait', { duration: formatRemainingTime(paceInfo.remainingSeconds) })}</>
) : (
<>{t('settings.usage.pace.prediction', { prediction: paceInfo.predictText })}</>
)}
</span>
</div>
);
}
return (
<div className={cn('flex items-center justify-between gap-2', className)}>
<div className="flex items-center gap-1.5">
{!paceInfo.isExhausted && (
<span className="typography-micro text-muted-foreground">
{t('settings.usage.pace.rate', { rate: paceInfo.paceRateText })}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
<span
className="typography-micro tabular-nums"
style={{ color: statusColor }}
>
{paceInfo.isExhausted ? (
<>
<span className="font-medium">{statusLabel}</span>
<span className="text-muted-foreground">{t('settings.usage.pace.waitSeparator')}</span>
<span className="font-medium">{formatRemainingTime(paceInfo.remainingSeconds)}</span>
</>
) : (
<span title={predictionTooltip}>
<span className="text-muted-foreground">{t('settings.usage.pace.predictionLabel')}</span>
<span className="font-medium">{paceInfo.predictText}</span>
</span>
)}
</span>
<div
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: statusColor }}
title={statusLabel}
/>
</div>
</div>
);
};
@@ -5,8 +5,8 @@ import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
type ProviderId = 'opencode-go' | 'ollama-cloud' | 'cursor';
type Status = { configured: boolean; workspaceId?: string; secretMasked?: string };
type ProviderId = 'ollama-cloud' | 'cursor';
type Status = { configured: boolean; secretMasked?: string };
export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: string }> = ({ providerId, providerName }) => {
const { t } = useI18n();
@@ -17,7 +17,7 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName:
React.useEffect(() => { void runtimeFetch(route).then(async (response) => {
if (!response.ok) throw new Error();
const next = await response.json() as Status;
setStatus(next); setValues(next.workspaceId ? { workspaceId: next.workspaceId } : {});
setStatus(next); setValues({});
}).catch(() => setStatus({ configured: false })); }, [route]);
const request = async (path: string, method: string, body?: object) => {
setBusy(true);
@@ -26,17 +26,15 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName:
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error);
if (payload?.configured !== undefined) setStatus(payload);
setValues((current) => current.workspaceId ? { workspaceId: current.workspaceId } : {} as Record<string, string>);
setValues({});
toast.success(t('settings.providers.page.quotaCredentials.saved', { provider: providerName }));
} catch (error) { toast.error(error instanceof Error && error.message ? error.message : t('settings.providers.page.openCodeGo.saveFailed')); }
finally { setBusy(false); }
};
const field = (name: string, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type={name === 'workspaceId' ? 'text' : 'password'} autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
const field = (name: string, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
return <div data-settings-item={`usage.${providerId}-credentials`} className="mb-8">
<div className="mb-1 px-1"><h3 className="typography-ui-header font-medium text-foreground">{providerName}</h3></div>
<section className="space-y-3 px-2 pb-2 pt-0">
{providerId === 'opencode-go' && field('workspaceId', t('settings.providers.page.openCodeGo.workspaceId'), 'wrk_...')}
{providerId === 'opencode-go' && field('authCookie', t('settings.providers.page.openCodeGo.authCookie'), 'auth=...')}
{providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')}
{providerId === 'cursor' && field('accessToken', t('settings.providers.page.quotaCredentials.accessToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
{providerId === 'cursor' && field('refreshToken', t('settings.providers.page.quotaCredentials.refreshToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
@@ -1,8 +1,6 @@
import React from 'react';
import type { UsageWindow } from '@/types';
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel } from '@/lib/quota';
import { UsageProgressBar } from './UsageProgressBar';
import { PaceIndicator } from './PaceIndicator';
import { useQuotaStore } from '@/stores/useQuotaStore';
import { Checkbox } from '@/components/ui/checkbox';
import { useUIStore } from '@/stores/useUIStore';
@@ -25,7 +23,6 @@ export const UsageCard: React.FC<UsageCardProps> = ({
onToggle,
}) => {
const displayMode = useQuotaStore((state) => state.displayMode);
const showPredValues = useQuotaStore((state) => state.showPredValues);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const displayPercent = displayMode === 'remaining' ? window.remainingPercent : window.usedPercent;
const barLabel = displayMode === 'remaining' ? 'remaining' : 'used';
@@ -33,18 +30,6 @@ export const UsageCard: React.FC<UsageCardProps> = ({
const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference);
const windowLabel = formatWindowLabel(title);
const paceInfo = React.useMemo(() => {
return calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, title);
}, [window.usedPercent, window.resetAt, window.windowSeconds, title]);
const expectedMarkerPercent = React.useMemo(() => {
if (!paceInfo || paceInfo.dailyAllocationPercent === null) {
return null;
}
const expectedUsed = calculateExpectedUsagePercent(paceInfo.elapsedRatio);
return displayMode === 'remaining' ? 100 - expectedUsed : expectedUsed;
}, [paceInfo, displayMode]);
return (
<div className="py-3">
<div className="flex items-center justify-between gap-3">
@@ -72,7 +57,6 @@ export const UsageCard: React.FC<UsageCardProps> = ({
<UsageProgressBar
percent={displayPercent}
tonePercent={window.usedPercent}
expectedMarkerPercent={expectedMarkerPercent}
className="h-1.5"
/>
<div className="mt-1 flex items-center justify-between">
@@ -85,11 +69,6 @@ export const UsageCard: React.FC<UsageCardProps> = ({
</div>
</div>
{paceInfo && showPredValues && (
<div className="mt-1.5">
<PaceIndicator paceInfo={paceInfo} />
</div>
)}
</div>
);
};
@@ -80,7 +80,7 @@ export const UsagePage: React.FC = () => {
? selectedResult.error
: null;
const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false;
const hasCredentialsForm = selectedProviderId === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
const hasCredentialsForm = selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
const handleDropdownToggle = React.useCallback((enabled: boolean) => {
if (!selectedProviderId) {
return;
@@ -162,19 +162,24 @@ export const UsagePage: React.FC = () => {
description={
isLoading ? (
<span className="animate-pulse typography-settings-description text-muted-foreground">{t('settings.usage.page.header.refreshing')}</span>
) : selectedResult?.planLabel ? (
t('settings.usage.page.header.lastUpdatedWithPlan', {
plan: selectedResult.planLabel,
time: formatTime(lastUpdated, timeFormatPreference),
})
) : (
t('settings.usage.page.header.lastUpdated', { time: formatTime(lastUpdated, timeFormatPreference) })
)
}
showSaveStatus
>
<SettingsSection divider={false} settingsItem="usage.header-menu">
<SettingsSection divider={false} settingsItem="usage.work-status-panel">
<SettingsCheckboxRow
checked={showInDropdown}
onChange={handleDropdownToggle}
label={t('settings.usage.page.options.showInHeader')}
ariaLabel={t('settings.usage.page.options.showInHeaderAria')}
info={t('settings.usage.page.options.showInHeaderTooltip')}
label={t('settings.usage.page.options.showInWorkStatus')}
ariaLabel={t('settings.usage.page.options.showInWorkStatusAria')}
info={t('settings.usage.page.options.showInWorkStatusTooltip')}
/>
</SettingsSection>
@@ -199,7 +204,7 @@ export const UsagePage: React.FC = () => {
</div>
)}
{(selectedProviderId === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
{(selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
<QuotaCredentials providerId={selectedProviderId} providerName={providerName} />
)}
@@ -6,24 +6,15 @@ interface UsageProgressBarProps {
percent: number | null;
tonePercent?: number | null;
className?: string;
/**
* Position (0-100) to show a marker indicating expected usage based on time elapsed.
* Used for weekly/monthly quotas to show where usage "should" be if evenly distributed.
*/
expectedMarkerPercent?: number | null;
}
export const UsageProgressBar: React.FC<UsageProgressBarProps> = ({
percent,
tonePercent,
className,
expectedMarkerPercent,
}) => {
const clamped = clampPercent(percent) ?? 0;
const tone = resolveUsageTone(tonePercent ?? percent);
const markerClamped = expectedMarkerPercent != null
? Math.max(0, Math.min(100, expectedMarkerPercent))
: null;
const fillStyle = tone === 'critical'
? { backgroundColor: 'var(--status-error)' }
@@ -41,14 +32,6 @@ export const UsageProgressBar: React.FC<UsageProgressBarProps> = ({
aria-valuemin={0}
aria-valuemax={100}
/>
{markerClamped != null && markerClamped > 0 && markerClamped < 100 && (
<div
className="absolute top-0 h-full w-0.5 bg-foreground"
style={{ left: `${markerClamped}%` }}
title={`Expected usage if spread evenly: ${Math.round(markerClamped)}% of quota`}
aria-hidden="true"
/>
)}
</div>
);
};
@@ -2,8 +2,6 @@ import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
@@ -35,21 +33,15 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
const setSelectedProvider = useQuotaStore((state) => state.setSelectedProvider);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const isLoading = useQuotaStore((state) => state.isLoading);
const usageAutoRefresh = useQuotaStore((state) => state.autoRefresh);
const usageRefreshIntervalMs = useQuotaStore((state) => state.refreshIntervalMs);
const usageDisplayMode = useQuotaStore((state) => state.displayMode);
const setUsageAutoRefresh = useQuotaStore((state) => state.setAutoRefresh);
const setUsageRefreshInterval = useQuotaStore((state) => state.setRefreshInterval);
const setUsageDisplayMode = useQuotaStore((state) => state.setDisplayMode);
const showPredValues = useQuotaStore((state) => state.showPredValues);
const setShowPredValues = useQuotaStore((state) => state.setShowPredValues);
const loadUsageSettings = useQuotaStore((state) => state.loadSettings);
React.useEffect(() => {
void loadUsageSettings();
}, [loadUsageSettings]);
const persistUsageSettings = React.useCallback(async (changes: { usageAutoRefresh?: boolean; usageRefreshIntervalMs?: number; usageDisplayMode?: 'usage' | 'remaining'; usageDropdownProviders?: string[]; usageShowPredValues?: boolean }) => {
const persistUsageSettings = React.useCallback(async (changes: { usageDisplayMode?: 'usage' | 'remaining'; usageDropdownProviders?: string[] }) => {
try {
await updateDesktopSettings(changes);
} catch (error) {
@@ -57,20 +49,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
}
}, []);
const handleUsageAutoRefreshChange = React.useCallback((enabled: boolean) => {
setUsageAutoRefresh(enabled);
void persistUsageSettings({ usageAutoRefresh: enabled });
}, [persistUsageSettings, setUsageAutoRefresh]);
const handleUsageRefreshIntervalChange = React.useCallback((value: string) => {
const next = Number(value);
if (!Number.isFinite(next)) {
return;
}
setUsageRefreshInterval(next);
void persistUsageSettings({ usageRefreshIntervalMs: next });
}, [persistUsageSettings, setUsageRefreshInterval]);
const handleUsageDisplayModeChange = React.useCallback((value: string) => {
if (value !== 'usage' && value !== 'remaining') {
return;
@@ -79,11 +57,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
void persistUsageSettings({ usageDisplayMode: value });
}, [persistUsageSettings, setUsageDisplayMode]);
const handleShowPredValuesChange = React.useCallback((enabled: boolean) => {
setShowPredValues(enabled);
void persistUsageSettings({ usageShowPredValues: enabled });
}, [persistUsageSettings, setShowPredValues]);
const bgClass = 'bg-background';
return (
@@ -93,34 +66,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.usage.sidebar.total', { count: QUOTA_PROVIDERS.length })}</span>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Checkbox
checked={usageAutoRefresh}
onChange={handleUsageAutoRefreshChange}
ariaLabel={t('settings.usage.sidebar.actions.toggleAutoRefreshAria')}
/>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{t('settings.usage.sidebar.tooltip.autoRefresh')}
</TooltipContent>
</Tooltip>
<Select
value={String(usageRefreshIntervalMs)}
onValueChange={handleUsageRefreshIntervalChange}
disabled={!usageAutoRefresh}
>
<SelectTrigger className="w-fit">
<SelectValue placeholder={t('settings.usage.sidebar.field.intervalPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="30000">30s</SelectItem>
<SelectItem value="60000">1m</SelectItem>
<SelectItem value="300000">5m</SelectItem>
</SelectContent>
</Select>
<Button size="sm"
variant="ghost"
className="h-7 w-7 px-0 text-muted-foreground"
@@ -145,16 +90,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
</SelectContent>
</Select>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="typography-micro text-muted-foreground">
{t('settings.usage.sidebar.field.showPredictions')}
</span>
<Checkbox
checked={showPredValues}
onChange={handleShowPredValuesChange}
ariaLabel={t('settings.usage.sidebar.field.showPredictions')}
/>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
@@ -382,12 +382,16 @@ export function ScheduledTasksDialog() {
}
setMutatingTaskID(task.id);
try {
const { sessionId } = await runScheduledTaskNow(selectedProjectID, task.id);
const { sessionId, persistError } = await runScheduledTaskNow(selectedProjectID, task.id);
await Promise.all([
reloadTasks(selectedProjectID, { silent: true }),
refreshGlobalSessions(),
]);
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
if (persistError) {
toast.warning(t('sessions.scheduledTasks.dialog.toast.startedPersistWarning'));
} else {
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
}
if (sessionId) {
// Jump straight into the started session; selecting it also closes
// this surface (MainLayout closes surfaces on session selection).
@@ -654,9 +654,12 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const unsubscribe = subscribeOpenchamberEvents((event) => {
if (event.type === 'scheduled-task-ran') {
needsGlobalRefresh = true;
} else {
} else if (event.type === 'session-created') {
sessionDirectories.add(event.directory);
requestWorktreeDiscovery();
} else {
// Browser control events carry no session state; nothing to refresh.
return;
}
if (refreshTimeout) {
clearTimeout(refreshTimeout);
@@ -1468,12 +1471,16 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
}
const key = getGitHubPrStatusKey(directory, branch);
const entry = useGitHubPrStatusStore.getState().entries[key];
const hasPr = Boolean(entry?.status?.pr);
const prState = entry?.status?.pr?.state;
const isTerminalPr = prState === 'closed' || prState === 'merged';
// Closed/merged associations are not live branch status — retry them on
// the same cadence as missing PRs so a newer open PR can appear.
const hasLivePr = Boolean(entry?.status?.pr) && !isTerminalPr;
const retryKey = `${directory}::${branch}`;
const noPrLastCheckedAt = Math.max(entry?.lastRefreshAt ?? 0, entry?.lastDiscoveryPollAt ?? 0);
const shouldRetryNoPr = Boolean(
entry?.isInitialStatusResolved
&& !hasPr
&& !hasLivePr
&& (
!retriedNoPrStatusKeysRef.current.has(retryKey)
|| now - noPrLastCheckedAt >= SIDEBAR_PR_NO_PR_RETRY_MS
@@ -1825,6 +1832,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
openNewSessionDraft={openNewSessionDraft}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
sessionOwnerBySessionId={sessionOwnership.bySessionId}
/>
<SessionPrefetchEffect
enabled={isVisible}
@@ -1,262 +0,0 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionGroup, SessionNode } from '../types';
// ---------------------------------------------------------------------------
// Helper: simulate the projectSessionMeta computation from the hook
// (same visitNodes logic as useProjectSessionSelection.ts lines 46-71)
// ---------------------------------------------------------------------------
type ProjectSection = {
project: { id: string; normalizedPath: string };
groups: SessionGroup[];
};
function computeProjectMeta(projectSections: ProjectSection[]) {
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
const visitNodes = (
projectId: string,
projectRoot: string,
fallbackDirectory: string | null,
nodes: SessionNode[],
) => {
if (!metaByProject.has(projectId)) {
metaByProject.set(projectId, new Map());
}
const projectMap = metaByProject.get(projectId)!;
nodes.forEach((node) => {
const sessionDirectory = (
node.worktree?.path
?? (node.session as Session & { directory?: string | null }).directory
?? fallbackDirectory
?? projectRoot
).replace(/\\/g, '/').replace(/\/+$/, '');
projectMap.set(node.session.id, { directory: sessionDirectory });
if (!firstSessionByProject.has(projectId)) {
firstSessionByProject.set(projectId, { id: node.session.id, directory: sessionDirectory });
}
if (node.children.length > 0) {
visitNodes(projectId, projectRoot, sessionDirectory, node.children);
}
});
};
projectSections.forEach((section) => {
section.groups.forEach((group) => {
visitNodes(section.project.id, section.project.normalizedPath, group.directory, group.sessions);
});
});
return { metaByProject, firstSessionByProject };
}
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const makeSession = (id: string, directory?: string): Session =>
({ id, directory } as unknown as Session);
const rootSession1 = makeSession('root-session-1', '/workspace/project');
const rootSession2 = makeSession('root-session-2', '/workspace/project');
const worktreeSession1 = makeSession('wt-session-1', '/workspace/project-wt');
const project2Session1 = makeSession('project-2-session-1', '/workspace/project-2');
const project2Session2 = makeSession('project-2-session-2', '/workspace/project-2');
const WORKTREE_PATH = '/workspace/project-wt';
// staleSections: root group only, no worktree group
const staleSections: ProjectSection[] = [
{
project: { id: 'project-1', normalizedPath: '/workspace/project' },
groups: [
{
id: 'root',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace/project',
sessions: [
{ session: rootSession1, children: [], worktree: null },
{ session: rootSession2, children: [], worktree: null },
],
},
],
},
];
// updatedSections: includes the worktree group
const updatedSections: ProjectSection[] = [
{
project: { id: 'project-1', normalizedPath: '/workspace/project' },
groups: [
{
id: 'root',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace/project',
sessions: [
{ session: rootSession1, children: [], worktree: null },
{ session: rootSession2, children: [], worktree: null },
],
},
{
id: 'wt-group',
label: 'feature-branch',
branch: 'feature-branch',
description: 'Worktree at ' + WORKTREE_PATH,
isMain: false,
worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' },
directory: WORKTREE_PATH,
sessions: [
{ session: worktreeSession1, children: [], worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' } },
],
},
],
},
];
// project-2Sections: separate project for project-switching tests
const project2Sections: ProjectSection[] = [
{
project: { id: 'project-2', normalizedPath: '/workspace/project-2' },
groups: [
{
id: 'root',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace/project-2',
sessions: [
{ session: project2Session1, children: [], worktree: null },
{ session: project2Session2, children: [], worktree: null },
],
},
],
},
];
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('useProjectSessionSelection — worktree session click race', () => {
test('stale projectSections (no worktree group) excludes worktree sessions from projectMap', () => {
const { metaByProject } = computeProjectMeta(staleSections);
const projectMap = metaByProject.get('project-1');
// Root sessions are present
expect(projectMap?.has('root-session-1')).toBe(true);
expect(projectMap?.has('root-session-2')).toBe(true);
// Worktree session is NOT present — this is what triggers the bug
expect(projectMap?.has('wt-session-1')).toBe(false);
});
test('stale data firstSessionByProject points to first root session, not worktree session', () => {
const { firstSessionByProject } = computeProjectMeta(staleSections);
// Path C would fall back to firstSessionByProject, which is the first ROOT session
const first = firstSessionByProject.get('project-1');
expect(first?.id).toBe('root-session-1');
expect(first?.id).not.toBe('wt-session-1');
});
test('updated projectSections includes all sessions including worktree', () => {
const { metaByProject } = computeProjectMeta(updatedSections);
const projectMap = metaByProject.get('project-1');
expect(projectMap?.has('root-session-1')).toBe(true);
expect(projectMap?.has('root-session-2')).toBe(true);
expect(projectMap?.has('wt-session-1')).toBe(true);
});
test('guard preserves currentSessionId when projectMap is stale (the bug fix)', () => {
const { metaByProject, firstSessionByProject } = computeProjectMeta(staleSections);
const projectMap = metaByProject.get('project-1')!;
const currentSessionId = 'wt-session-1';
// Path A fails: currentSessionId is set but not in stale projectMap
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(false);
// Guard: if (currentSessionId) return;
// This is what prevents the fallthrough to Path C (auto-select wrong session)
// Without the guard, Path C would select firstSessionByProject = root-session-1
// instead of preserving the user's wt-session-1 selection
const fallback = firstSessionByProject.get('project-1')?.id ?? null;
expect(fallback).toBe('root-session-1');
expect(fallback).not.toBe(currentSessionId);
});
test('second click works correctly when projectSections is updated', () => {
const { metaByProject } = computeProjectMeta(updatedSections);
const projectMap = metaByProject.get('project-1')!;
const currentSessionId = 'wt-session-1';
// After data arrives, Path A succeeds — no guard needed
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(true);
});
test('project switch: guard does NOT fire when currentSessionId matches new project', () => {
// Simulates: user clicks a session in project-2 (normal click, not worktree)
const { metaByProject } = computeProjectMeta(project2Sections);
const projectMap = metaByProject.get('project-2')!;
const currentSessionId = 'project-2-session-1';
// Path A succeeds — the session is in the new project's projectMap
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(true);
// Guard condition only fires when Path A fails — should not fire here
const guardWouldFire = Boolean(currentSessionId && !(projectMap?.has(currentSessionId)));
expect(guardWouldFire).toBe(false);
});
test('guard does NOT fire when currentSessionId is null (deleted/archived session)', () => {
const { metaByProject } = computeProjectMeta(staleSections);
const projectMap = metaByProject.get('project-1')!;
const currentSessionId = null;
// Path A: currentSessionId is null → skipped
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(false);
// Guard: currentSessionId is null → skipped, falls through to Path B/C
const guardWouldFire = currentSessionId !== null && !pathAHit;
expect(guardWouldFire).toBe(false);
});
test('guard does NOT fire for empty projects — falls through to Path B (open draft)', () => {
// Empty project: no groups/sessions in projectSections
const emptySections: ProjectSection[] = [
{
project: { id: 'empty-project', normalizedPath: '/workspace/empty' },
groups: [],
},
];
const { metaByProject } = computeProjectMeta(emptySections);
const projectMap = metaByProject.get('empty-project');
const currentSessionId = 'some-session-id';
// projectMap is undefined for empty project
expect(projectMap).toBe(undefined);
// Guard: projectMap is undefined → skipped, falls through to Path B
// which opens a new session draft for the empty project
const guardWouldFire = Boolean(currentSessionId && projectMap);
expect(guardWouldFire).toBe(false);
});
});
@@ -17,6 +17,7 @@ type Args = {
activeSessionByProject: Map<string, string>;
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
currentSessionId: string | null;
currentSessionOwnerProjectId?: string | null;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
newSessionDraftOpen: boolean;
mobileVariant: boolean;
@@ -25,6 +26,69 @@ type Args = {
setSessionSwitcherOpen: (open: boolean) => void;
};
export type MissingProjectSessionSelection =
| { kind: 'preserve-current' }
| { kind: 'open-draft' }
| { kind: 'select-session'; sessionId: string }
| { kind: 'none' };
/**
* Resolves the active-project action after its rendered session map does not
* contain the current session.
*
* Authoritative ownership wins. If ownership is still unknown, a session that
* already appears under another project's rendered map is treated as foreign,
* while a session missing from every rendered map is preserved so stale
* worktree metadata can catch up.
*/
export function resolveMissingProjectSessionSelection<T>({
activeProjectId,
currentSessionId,
currentSessionOwnerProjectId,
projectMap,
metaByProject,
rememberedSessionId,
fallbackSessionId,
}: {
activeProjectId: string;
currentSessionId: string | null;
currentSessionOwnerProjectId?: string | null;
projectMap: ReadonlyMap<string, T> | undefined;
metaByProject: ReadonlyMap<string, ReadonlyMap<string, T>>;
rememberedSessionId: string | undefined;
fallbackSessionId: string | null;
}): MissingProjectSessionSelection {
if (currentSessionId && currentSessionOwnerProjectId === activeProjectId) {
return { kind: 'preserve-current' };
}
if (currentSessionOwnerProjectId == null) {
const currentSessionBelongsToAnotherProject = Boolean(
currentSessionId
&& Array.from(metaByProject.entries()).some(
([projectId, sessions]) => projectId !== activeProjectId && sessions.has(currentSessionId),
),
);
if (currentSessionId && projectMap && !currentSessionBelongsToAnotherProject) {
return { kind: 'preserve-current' };
}
}
if (!projectMap || projectMap.size === 0) {
return { kind: 'open-draft' };
}
const remembered = rememberedSessionId && projectMap.has(rememberedSessionId)
? rememberedSessionId
: null;
const targetSessionId = remembered ?? fallbackSessionId;
if (!targetSessionId || targetSessionId === currentSessionId) {
return { kind: 'none' };
}
return { kind: 'select-session', sessionId: targetSessionId };
}
export const useProjectSessionSelection = (args: Args): void => {
const {
projectSections,
@@ -32,6 +96,7 @@ export const useProjectSessionSelection = (args: Args): void => {
activeSessionByProject,
setActiveSessionByProject,
currentSessionId,
currentSessionOwnerProjectId,
handleSessionSelect,
newSessionDraftOpen,
mobileVariant,
@@ -103,10 +168,10 @@ export const useProjectSessionSelection = (args: Args): void => {
if (!section) {
return;
}
previousActiveProjectRef.current = activeProjectId;
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
if (currentSessionId && projectMap && projectMap.has(currentSessionId)) {
previousActiveProjectRef.current = activeProjectId;
setActiveSessionByProject((prev) => {
if (prev.get(activeProjectId) === currentSessionId) {
return prev;
@@ -118,16 +183,28 @@ export const useProjectSessionSelection = (args: Args): void => {
return;
}
// Path A' — currentSessionId is set but not in stale projectMap.
// Preserve user's explicit selection when the projectMap exists but
// is missing the session (worktree data not yet loaded). For
// empty projects (projectMap is undefined), fall through to Path B
// so a new session draft is opened.
if (currentSessionId && projectMap) {
const selection = resolveMissingProjectSessionSelection({
activeProjectId,
currentSessionId,
currentSessionOwnerProjectId,
projectMap,
metaByProject: projectSessionMeta.metaByProject,
rememberedSessionId: activeSessionByProject.get(activeProjectId),
fallbackSessionId: projectSessionMeta.firstSessionByProject.get(activeProjectId)?.id ?? null,
});
// Keep the project unprocessed while ownership/maps may still catch up,
// so a later owner of another project can still select B.
if (selection.kind === 'preserve-current') {
if (currentSessionOwnerProjectId === activeProjectId) {
previousActiveProjectRef.current = activeProjectId;
}
return;
}
if (!projectMap || projectMap.size === 0) {
previousActiveProjectRef.current = activeProjectId;
if (selection.kind === 'open-draft') {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
@@ -139,21 +216,16 @@ export const useProjectSessionSelection = (args: Args): void => {
return;
}
const rememberedSessionId = activeSessionByProject.get(activeProjectId);
const remembered = rememberedSessionId && projectMap.has(rememberedSessionId)
? rememberedSessionId
: null;
const fallback = projectSessionMeta.firstSessionByProject.get(activeProjectId)?.id ?? null;
const targetSessionId = remembered ?? fallback;
if (!targetSessionId || targetSessionId === currentSessionId) {
if (selection.kind !== 'select-session') {
return;
}
const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null;
handleSessionSelect(targetSessionId, targetDirectory);
const targetDirectory = projectMap?.get(selection.sessionId)?.directory ?? null;
handleSessionSelect(selection.sessionId, targetDirectory);
}, [
activeProjectId,
activeSessionByProject,
currentSessionId,
currentSessionOwnerProjectId,
handleSessionSelect,
newSessionDraftOpen,
mobileVariant,
@@ -182,24 +254,28 @@ export const useProjectSessionSelection = (args: Args): void => {
return next;
});
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
};
type ProjectSessionSelectionEffectProps = Omit<
Args,
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen'
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen' | 'currentSessionOwnerProjectId'
> & {
initialActiveSessionByProject: Map<string, string>;
persistActiveSessionByProject: (value: Map<string, string>) => void;
sessionOwnerBySessionId?: ReadonlyMap<string, { projectId: string }>;
};
export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffectProps> = ({
initialActiveSessionByProject,
persistActiveSessionByProject,
sessionOwnerBySessionId,
...args
}) => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const currentSessionOwnerProjectId = currentSessionId
? sessionOwnerBySessionId?.get(currentSessionId)?.projectId ?? null
: null;
const [activeSessionByProject, setActiveSessionByProject] = React.useState(
() => new Map(initialActiveSessionByProject),
);
@@ -208,6 +284,7 @@ export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffe
activeSessionByProject,
setActiveSessionByProject,
currentSessionId,
currentSessionOwnerProjectId,
newSessionDraftOpen,
});
React.useEffect(() => {
@@ -28,7 +28,7 @@ const sessionDirectory = (session: Session | null | undefined): string | null =>
return typeof directory === 'string' && directory.trim() ? directory : null;
};
export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
const sessionPrefetchQueueRef = React.useRef<PrefetchRequest[]>([]);
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
@@ -156,11 +156,8 @@ export const resolveArchivedFolderName = (session: Session, projectRoot: string
return segments[segments.length - 1] ?? 'unassigned';
};
export const formatProjectLabel = (label: string): string => {
return label
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
};
// Folder names are shown exactly as they are on disk — no title-casing.
export const formatProjectLabel = (label: string): string => label.trim();
export const renderHighlightedText = (text: string, query: string): React.ReactNode => {
if (!query) {
@@ -1,6 +1,8 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useProviderLogo } from '@/hooks/useProviderLogo';
import { cn } from '@/lib/utils';
import { getProviderLogoFallbackIcon } from './providerLogoFallback';
interface ProviderLogoProps {
providerId: string;
@@ -16,6 +18,7 @@ export const ProviderLogo: React.FC<ProviderLogoProps> = ({
onError: externalOnError
}) => {
const { src, onError: handleInternalError, hasLogo } = useProviderLogo(providerId);
const fallbackIcon = getProviderLogoFallbackIcon(providerId);
const handleError = React.useCallback(() => {
handleInternalError();
@@ -23,7 +26,7 @@ export const ProviderLogo: React.FC<ProviderLogoProps> = ({
}, [handleInternalError, externalOnError]);
if (!hasLogo || !src) {
return null;
return fallbackIcon ? <Icon name={fallbackIcon} className={cn('text-muted-foreground', className)} /> : null;
}
return (
@@ -18,6 +18,8 @@ type ScrollableOverlayProps = React.HTMLAttributes<HTMLElement> & {
scrollShadowSize?: number;
/** Suppress the top fade (e.g. when sticky headers sit at the top edge). */
hideTopScrollShadow?: boolean;
/** Suppress the bottom fade while retaining scroll-state tracking. */
hideBottomScrollShadow?: boolean;
userIntentOnly?: boolean;
/** Forwarded to the inner element (e.g. textarea). */
disabled?: boolean;
@@ -40,6 +42,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
useScrollShadow = false,
scrollShadowSize,
hideTopScrollShadow = false,
hideBottomScrollShadow = false,
userIntentOnly = false,
...rest
}, ref) => {
@@ -61,6 +64,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
ref={containerRef as React.Ref<HTMLElement>}
size={scrollShadowSize}
hideTopShadow={hideTopScrollShadow}
hideBottomShadow={hideBottomScrollShadow}
className={cn(
"overlay-scrollbar-target overlay-scrollbar-container",
preventOverscroll && "overscroll-none",
@@ -39,7 +39,12 @@ const CollapsibleContent = ({
...props
}: React.ComponentProps<typeof BaseCollapsible.Panel>) => (
<BaseCollapsible.Panel
className={cn("overflow-hidden data-[closed]:animate-collapsible-up data-[open]:animate-collapsible-down", className)}
className={cn(
"transition-opacity duration-100 ease-out",
"data-[starting-style]:opacity-0 data-[ending-style]:opacity-0",
"motion-reduce:transition-none",
className,
)}
{...props}
/>
);
+1 -1
View File
@@ -93,7 +93,7 @@ function DialogContent({
data-slot="dialog-content"
data-state-slot="dialog"
className={cn(
"oc-glass-dialog relative pointer-events-auto text-foreground flex flex-col w-full max-w-lg max-h-full gap-4 rounded-xl border p-6 shadow-none overflow-y-auto pwa-dialog-content origin-center",
"relative pointer-events-auto bg-background text-foreground flex flex-col w-full max-w-lg max-h-full gap-4 rounded-xl border p-6 shadow-none overflow-y-auto pwa-dialog-content origin-center",
"transition-all duration-150 ease-out",
"data-[starting-style]:opacity-0 data-[starting-style]:scale-[0.98]",
"data-[ending-style]:opacity-0 data-[ending-style]:scale-[0.98]",

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