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
@@ -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'],
]);