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:
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
+249
@@ -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,
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const composerEditorSource = readFileSync(
|
||||
new URL('../ComposerEditor.tsx', import.meta.url),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const writebackEffect = (): string => {
|
||||
const start = composerEditorSource.indexOf('// Controlled value:');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const end = composerEditorSource.indexOf('}, [value]);', start);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return composerEditorSource.slice(start, end);
|
||||
};
|
||||
|
||||
describe('composer value writeback composition guard (issue #2527)', () => {
|
||||
test('checks equality, then composition, before dispatching', () => {
|
||||
const effect = writebackEffect();
|
||||
const equalityCheck = effect.indexOf('if (current === value) return;');
|
||||
const compositionGuard = effect.indexOf('if (view.compositionStarted) return;');
|
||||
const dispatch = effect.indexOf('view.dispatch({');
|
||||
|
||||
expect(equalityCheck).toBeGreaterThan(-1);
|
||||
expect(compositionGuard).toBeGreaterThan(equalityCheck);
|
||||
expect(dispatch).toBeGreaterThan(compositionGuard);
|
||||
});
|
||||
});
|
||||
@@ -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)',
|
||||
'',
|
||||
].join('\n\n'), 'label');
|
||||
|
||||
expect(html).toContain('data-openchamber-markdown-image-label="true"');
|
||||
expect(html).toContain('extension.jpg');
|
||||
expect(html).not.toContain('image syntax');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html.match(/<a /g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps non-chat Markdown images inline', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[remote link](https://example.test/image.png)',
|
||||
'',
|
||||
].join('\n\n'));
|
||||
|
||||
expect(html).toContain('<a href="https://example.test/image.png"');
|
||||
expect(html).toContain('<img src="https://example.test/image.png" alt="remote image">');
|
||||
expect(html).not.toContain('data-openchamber-markdown-image-label');
|
||||
});
|
||||
|
||||
test('collects image syntax across mixed Markdown and ignores links and code', () => {
|
||||
const candidates = extractMarkdownImageCandidates([
|
||||
[
|
||||
'Before [local link](screens/first%20view.png) and ``.',
|
||||
'',
|
||||
'- ',
|
||||
'- ',
|
||||
'',
|
||||
'```md',
|
||||
'',
|
||||
'```',
|
||||
].join('\n'),
|
||||
'After .',
|
||||
]);
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{ source: 'screens/first%20view.png', filename: 'first view.png' },
|
||||
{ source: 'https://example.test/second.webp?size=2', filename: 'second.webp' },
|
||||
{ source: 'data:image/png;base64,AAAA', filename: 'third' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not add an ordinary local image link to the gallery', () => {
|
||||
expect(extractMarkdownImageCandidates(['[download](screens/image.png)'])).toEqual([]);
|
||||
});
|
||||
|
||||
test('limits one finalized message gallery to twelve unique candidates', () => {
|
||||
const markdown = Array.from({ length: 14 }, (_, index) => ``).join('\n');
|
||||
|
||||
const candidates = extractMarkdownImageCandidates([markdown]);
|
||||
|
||||
expect(candidates).toHaveLength(12);
|
||||
expect(candidates.at(-1)?.source).toBe('screens/11.png');
|
||||
});
|
||||
|
||||
test('reuses extracted candidates across virtualized remounts without changing gallery behavior', () => {
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
const contents = Array.from({ length: 20 }, (_, index) => ``);
|
||||
|
||||
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
|
||||
expect(__markdownImageCandidateCacheForTests.stats().scans).toBe(12);
|
||||
|
||||
for (let round = 0; round < 1000; round += 1) {
|
||||
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
|
||||
}
|
||||
|
||||
const stats = __markdownImageCandidateCacheForTests.stats();
|
||||
expect(stats.entries).toBe(12);
|
||||
expect(stats.scans).toBe(12);
|
||||
});
|
||||
|
||||
test('scans one thousand independent messages once across virtualized remounts', () => {
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
const messages = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, index) => ``,
|
||||
);
|
||||
|
||||
for (const message of messages) extractMarkdownImageCandidates([message]);
|
||||
for (const message of messages) extractMarkdownImageCandidates([message]);
|
||||
|
||||
const stats = __markdownImageCandidateCacheForTests.stats();
|
||||
expect(stats.entries).toBe(1000);
|
||||
expect(stats.scans).toBe(1000);
|
||||
});
|
||||
|
||||
test('gives embedded images without alt text a stable filename', () => {
|
||||
const source = 'data:image/png;base64,AAAA';
|
||||
|
||||
expect(extractMarkdownImageCandidates([``])).toEqual([
|
||||
{ source, filename: 'image.png' },
|
||||
]);
|
||||
expect(renderMarkdownSync(``, 'label')).toContain('image.png');
|
||||
});
|
||||
|
||||
test('bounds cached candidate entries and bytes, and skips oversized individual content', () => {
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
for (let index = 0; index < 1025; index += 1) {
|
||||
extractMarkdownImageCandidates([``]);
|
||||
}
|
||||
const boundedStats = __markdownImageCandidateCacheForTests.stats();
|
||||
expect(boundedStats.entries).toBe(1024);
|
||||
expect(boundedStats.bytes <= 2 * 1024 * 1024).toBe(true);
|
||||
|
||||
__markdownImageCandidateCacheForTests.reset();
|
||||
const oversized = `\n${'x'.repeat(64 * 1024)}`;
|
||||
|
||||
extractMarkdownImageCandidates([oversized]);
|
||||
extractMarkdownImageCandidates([oversized]);
|
||||
expect(__markdownImageCandidateCacheForTests.stats()).toEqual({ entries: 0, bytes: 0, scans: 2 });
|
||||
});
|
||||
|
||||
test('validates embedded image bytes against the declared MIME type', async () => {
|
||||
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
expect(await resolveMarkdownImageSource(`data:image/png;base64,${png}`, signal)).toBe(`data:image/png;base64,${png}`);
|
||||
await resolveMarkdownImageSource(`data:image/jpeg;base64,${png}`, signal).then(
|
||||
() => { throw new Error('Expected mismatched image data to fail'); },
|
||||
(error: unknown) => expect((error as Error).message).toBe('Unsupported image data'),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not resolve images after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await resolveMarkdownImageSource('https://example.test/image.png', controller.signal).then(
|
||||
() => { throw new Error('Expected an aborted image load to fail'); },
|
||||
(error: unknown) => expect((error as Error).name).toBe('AbortError'),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the existing image renderer outside finalized assistant text', () => {
|
||||
const html = renderMarkdownSync('');
|
||||
|
||||
expect(html).toContain('<img src="https://example.test/image.png"');
|
||||
expect(html).not.toContain('data-openchamber-markdown-image');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const LOCAL_IMAGE_EXTENSION_RE = /\.(?:png|jpe?g|gif|webp)(?:[?#].*)?$/i;
|
||||
const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/;
|
||||
const URL_SCHEME_RE = /^[A-Za-z][A-Za-z\d+.-]*:/;
|
||||
|
||||
export interface MarkdownImageCandidate {
|
||||
source: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export type MarkdownImageMode = 'inline' | 'label';
|
||||
|
||||
export const MAX_MARKDOWN_IMAGE_COUNT = 12;
|
||||
|
||||
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRIES = 1024;
|
||||
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRY_BYTES = 64 * 1024;
|
||||
|
||||
type MarkdownImageCandidateCacheEntry = {
|
||||
candidates: MarkdownImageCandidate[];
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
const markdownImageCandidateCache = new Map<string, MarkdownImageCandidateCacheEntry>();
|
||||
let markdownImageCandidateCacheBytes = 0;
|
||||
let markdownImageCandidateScanCount = 0;
|
||||
|
||||
const isLocalMarkdownImageSource = (source: string): boolean => {
|
||||
if (/^\/\//.test(source) || !LOCAL_IMAGE_EXTENSION_RE.test(source)) return false;
|
||||
return WINDOWS_ABSOLUTE_PATH_RE.test(source)
|
||||
|| /^file:\/\//i.test(source)
|
||||
|| !URL_SCHEME_RE.test(source);
|
||||
};
|
||||
|
||||
const isSupportedMarkdownImageSource = (source: string): boolean => (
|
||||
/^(?:https?:)?\/\//i.test(source)
|
||||
|| /^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
|
||||
|| isLocalMarkdownImageSource(source)
|
||||
);
|
||||
|
||||
const getMarkdownImageFilename = (source: string, fallback: string): string => {
|
||||
if (/^data:image\/(png|jpeg|gif|webp)/i.test(source)) {
|
||||
const extension = /^data:image\/([^;,]+)/i.exec(source)?.[1]?.replace('jpeg', 'jpg') ?? 'png';
|
||||
return fallback.trim() || `image.${extension}`;
|
||||
}
|
||||
|
||||
const path = source.split(/[?#]/, 1)[0]?.replace(/\\/g, '/') ?? '';
|
||||
const encodedName = path.split('/').filter(Boolean).at(-1) ?? '';
|
||||
if (!encodedName) return fallback.trim();
|
||||
try {
|
||||
return decodeURIComponent(encodedName);
|
||||
} catch {
|
||||
return encodedName;
|
||||
}
|
||||
};
|
||||
|
||||
const estimateMarkdownImageCandidateCacheEntryBytes = (
|
||||
markdown: string,
|
||||
candidates: readonly MarkdownImageCandidate[],
|
||||
): number => (
|
||||
(markdown.length + candidates.reduce((total, candidate) => total + candidate.source.length + candidate.filename.length, 0)) * 2
|
||||
);
|
||||
|
||||
const scanMarkdownImageCandidates = (markdown: string): MarkdownImageCandidate[] => {
|
||||
markdownImageCandidateScanCount += 1;
|
||||
const candidates: MarkdownImageCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
const tokens = marked.lexer(markdown);
|
||||
marked.walkTokens(tokens, (token) => {
|
||||
if (token.type !== 'image') return;
|
||||
|
||||
const source = token.href ?? '';
|
||||
if (!source || !isSupportedMarkdownImageSource(source) || seen.has(source)) return;
|
||||
const fallback = typeof token.text === 'string' ? token.text : '';
|
||||
const filename = getMarkdownImageFilename(source, fallback);
|
||||
if (!filename) return;
|
||||
|
||||
seen.add(source);
|
||||
candidates.push({ source, filename });
|
||||
});
|
||||
return candidates;
|
||||
};
|
||||
|
||||
const getMarkdownImageCandidates = (markdown: string): MarkdownImageCandidate[] => {
|
||||
const cached = markdownImageCandidateCache.get(markdown);
|
||||
if (cached) {
|
||||
markdownImageCandidateCache.delete(markdown);
|
||||
markdownImageCandidateCache.set(markdown, cached);
|
||||
return cached.candidates;
|
||||
}
|
||||
|
||||
const candidates = scanMarkdownImageCandidates(markdown);
|
||||
const bytes = estimateMarkdownImageCandidateCacheEntryBytes(markdown, candidates);
|
||||
if (bytes > MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRY_BYTES) return candidates;
|
||||
|
||||
while (
|
||||
markdownImageCandidateCache.size >= MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRIES
|
||||
|| markdownImageCandidateCacheBytes + bytes > MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_BYTES
|
||||
) {
|
||||
const oldest = markdownImageCandidateCache.entries().next().value;
|
||||
if (!oldest) break;
|
||||
markdownImageCandidateCache.delete(oldest[0]);
|
||||
markdownImageCandidateCacheBytes -= oldest[1].bytes;
|
||||
}
|
||||
markdownImageCandidateCache.set(markdown, { candidates, bytes });
|
||||
markdownImageCandidateCacheBytes += bytes;
|
||||
return candidates;
|
||||
};
|
||||
|
||||
/** @internal Test-only cache instrumentation for deterministic regression tests. */
|
||||
export const __markdownImageCandidateCacheForTests = {
|
||||
reset: (): void => {
|
||||
markdownImageCandidateCache.clear();
|
||||
markdownImageCandidateCacheBytes = 0;
|
||||
markdownImageCandidateScanCount = 0;
|
||||
},
|
||||
stats: () => ({
|
||||
entries: markdownImageCandidateCache.size,
|
||||
bytes: markdownImageCandidateCacheBytes,
|
||||
scans: markdownImageCandidateScanCount,
|
||||
}),
|
||||
};
|
||||
|
||||
const renderMarkdownImageLabel = ({
|
||||
href,
|
||||
title,
|
||||
text,
|
||||
}: {
|
||||
href: string;
|
||||
title?: string | null;
|
||||
text: string;
|
||||
}): string => {
|
||||
const label = getMarkdownImageFilename(href ?? '', text);
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
return `<span${titleAttr} class="inline-flex items-center gap-1 align-text-bottom text-muted-foreground" data-openchamber-markdown-image-label="true">${escapeAttr(label)}</span>`;
|
||||
};
|
||||
|
||||
export const extractMarkdownImageCandidates = (
|
||||
markdownTexts: readonly string[],
|
||||
limit = MAX_MARKDOWN_IMAGE_COUNT,
|
||||
): MarkdownImageCandidate[] => {
|
||||
if (limit <= 0) return [];
|
||||
|
||||
const candidates: MarkdownImageCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const markdown of markdownTexts) {
|
||||
if (!markdown || candidates.length >= limit) continue;
|
||||
for (const candidate of getMarkdownImageCandidates(markdown)) {
|
||||
if (candidates.length >= limit) break;
|
||||
if (seen.has(candidate.source)) continue;
|
||||
seen.add(candidate.source);
|
||||
candidates.push({ ...candidate });
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming block segmentation (port of OpenCode's markdown-stream)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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'],
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user