Refactor application architecture and shared functionality

This commit is contained in:
Jakub Syty
2026-08-21 10:59:47 +02:00
398 changed files with 28819 additions and 4361 deletions
+19 -5
View File
@@ -14,6 +14,7 @@ import { useTraySync } from '@/hooks/useTraySync';
import { useRouter } from '@/hooks/useRouter';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
import { useAgentMemorySync } from '@/hooks/useAgentMemorySync';
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -54,7 +55,11 @@ import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { useI18n } from '@/lib/i18n';
import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
import {
EMBEDDED_VISIBILITY_UPDATE,
isEmbeddedSessionChat,
requestEmbeddedSessionVisibility,
} from '@/components/layout/contextPanelEmbeddedChat';
import { SyncAppEffects } from '@/apps/AppEffects';
import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset';
import { useAppFontEffects } from '@/apps/useAppFontEffects';
@@ -205,6 +210,11 @@ const EmbeddedSessionChatContent: React.FC<{
<OpenCodeUpdateToast />
<ChatView
active={embeddedBackgroundWorkEnabled}
// Always subscribe to message history in the mounted session-chat
// iframe. Visibility still gates composer focus and background work so
// a boot-inactive / lost-handshake race cannot leave a busy subagent
// showing only its status row (#2903 / #2892).
messagesEnabled={true}
readOnly={embeddedSessionChat.readOnly}
initialAllowPromptingSubagentSessions={embeddedSessionChat.allowPromptingSubagentSessions}
/>
@@ -538,17 +548,16 @@ function App({ apis }: AppProps) {
}
const applyVisibility = (payload?: EmbeddedVisibilityPayload) => {
const nextVisible = payload?.visible === true;
setIsEmbeddedVisible(nextVisible);
setIsEmbeddedVisible(payload?.visible === true);
};
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) {
if (event.origin !== window.location.origin || event.source !== window.parent) {
return;
}
const data = event.data as { type?: unknown; payload?: EmbeddedVisibilityPayload };
if (data?.type !== 'openchamber:embedded-visibility') {
if (data?.type !== EMBEDDED_VISIBILITY_UPDATE) {
return;
}
@@ -561,6 +570,7 @@ function App({ apis }: AppProps) {
scopedWindow.__openchamberSetEmbeddedVisibility = applyVisibility;
window.addEventListener('message', handleMessage);
requestEmbeddedSessionVisibility();
return () => {
window.removeEventListener('message', handleMessage);
@@ -694,6 +704,10 @@ function App({ apis }: AppProps) {
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled });
// Loaded here rather than by the Memory tab: the session index is built from
// this snapshot, so leaving it to the panel meant a user who never opened
// Project notes sent every message with no memory index at all.
useAgentMemorySync(currentDirectory || null);
usePwaInstallPrompt();
useWindowTitle();
+3 -2
View File
@@ -83,6 +83,7 @@ const MOBILE_SETTINGS_PAGES = [
'providers',
'usage',
'voice',
'integrations',
'about',
] as const;
@@ -108,7 +109,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
const [workspaceTab, setWorkspaceTab] = React.useState<MobileWorkspaceTab>('changes');
// A plan opened from the workspace drawer's Notes tab, shown as a fullscreen
// layer on top of it (back returns to the notes).
const [openPlan, setOpenPlan] = React.useState<{ path: string; title: string } | null>(null);
const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string } | null>(null);
const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav');
// When set, the Changes surface opens directly into the per-file diff for this path.
const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null);
@@ -539,7 +540,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
>
<ErrorBoundary>
<PlanView
targetPath={openPlan.path}
projectPlanId={openPlan.id}
onNavigatedToChat={() => {
closeSurface();
closeWorkspace();
@@ -388,6 +388,7 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & {
tokens?: {
total?: unknown;
input?: unknown;
output?: unknown;
reasoning?: unknown;
@@ -395,6 +396,11 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
};
};
if (message.role !== 'assistant' || !message.tokens) continue;
// Multi-step turns accumulate the fields across API round-trips, so
// summing them overstates the window. The server-reported total is the
// final round-trip's window; sum only when the server did not send it.
const reportedTotal = getTokenCount(message.tokens.total);
if (reportedTotal > 0) return reportedTotal;
const total = getTokenCount(message.tokens.input)
+ getTokenCount(message.tokens.output)
+ getTokenCount(message.tokens.reasoning)
@@ -105,7 +105,7 @@ export const MobileWorkspaceDrawer: React.FC<{
/** When set, the Changes tab opens directly into the per-file diff. */
pendingChangesDiff: { path: string; staged: boolean } | null;
/** Notes tab: opens a plan fullscreen (layered above the drawer). */
onOpenPlan: (plan: { path: string; title: string }) => void;
onOpenPlan: (plan: { id: string; title: string }) => void;
/** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */
onOpenMcpSettings: () => void;
variant?: 'drawer' | 'panel';
+22 -18
View File
@@ -23,7 +23,7 @@ import type { PairingConnectionPayload, PairingEndpointCandidate } from '@/lib/c
import { isCapacitorApp } from '@/lib/platform';
import { adoptRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel';
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { addRuntimeProxyHeaders, runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { recordMobileConnectDebug } from './mobileConnectionDebug';
@@ -346,11 +346,11 @@ const nativeHttpRequest = async (url: string, init?: RequestInit): Promise<Mobil
if (!isCapacitorApp()) return null;
try {
const { CapacitorHttp } = await import('@capacitor/core');
const headers = Object.fromEntries(new Headers(init?.headers).entries());
const requestHeaders = addRuntimeProxyHeaders(url, new Headers(init?.headers));
const response = await CapacitorHttp.request({
url,
method: init?.method || 'GET',
headers,
headers: Object.fromEntries(requestHeaders.entries()),
data: getJsonRequestData(init?.body),
});
return {
@@ -1015,20 +1015,20 @@ export const autoConnectLastInstance = async (options?: { fast?: boolean; skipIf
logConnect('auto-connect:start', { hasCandidate: Boolean(candidate), fast });
if (!candidate) return { status: 'no-candidate' };
// The runtime transport needs a bearer token; only auto-connect when one is
// already saved. A missing/expired token must go through the login UI.
// The runtime transport authenticates with a bearer token when the server
// issued one. A connection saved WITHOUT a token means its last successful
// connect was tokenless (server auth disabled) — probe it the same way; the
// probe itself reports needs-login if the server has since enabled auth. Only
// an EXPECTED token that cannot be read must go through the login UI.
let token: string | undefined;
if (isCapacitorApp()) {
if (!candidate.hasToken) {
return { status: 'no-candidate' };
}
token = await readSecureToken(secureTokenKeyOf(candidate));
if (!token) {
return { status: 'no-candidate' };
if (candidate.hasToken) {
token = await readSecureToken(secureTokenKeyOf(candidate));
if (!token) return { status: 'no-candidate' };
}
} else {
token = candidate.clientToken;
if (!token) return { status: 'no-candidate' };
if (!token && candidate.hasToken) return { status: 'no-candidate' };
}
// Fast probe by default: the cold-launch splash should decide in a couple of
@@ -1050,7 +1050,7 @@ export const autoConnectLastInstance = async (options?: { fast?: boolean; skipIf
return { status: 'no-candidate' };
}
await upsertMobileConnection({ id: candidate.id, label: candidate.label, candidates: candidate.candidates }); // bump lastUsedAt (keeps token)
switchToTransport(result.transport, token, { runtimeKey: secureTokenKeyOf(candidate) });
switchToTransport(result.transport, token ?? null, { runtimeKey: secureTokenKeyOf(candidate) });
return { status: 'connected' };
};
@@ -1209,11 +1209,15 @@ export const reprobeActiveConnection = async (options?: { fast?: boolean }): Pro
} else {
token = active.clientToken;
}
if (!token) {
logConnect('reprobe:no-token', { hasToken: Boolean(active.hasToken) });
// Tokenless is valid (server auth disabled — the probe reports needs-login if
// that changed); bail only when an EXPECTED token cannot be read. 'unreachable'
// (not needs-login) so the resume retry ladder re-reads the token — a transient
// secure-storage failure must not force a re-login.
if (!token && active.hasToken) {
logConnect('reprobe:no-token', { hasToken: true });
return 'unreachable';
}
logConnect('reprobe:start', { candidates: active.candidates.map((c) => c.kind), fast });
logConnect('reprobe:start', { candidates: active.candidates.map((c) => c.kind), fast, hasToken: Boolean(token) });
const currentIndex = active.candidates.findIndex(
(candidate) => transportMatchesCurrentRuntime(candidate.kind === 'relay' ? { kind: 'relay', relay: candidate.relay } : { kind: 'direct', url: candidate.url }),
@@ -1225,7 +1229,7 @@ export const reprobeActiveConnection = async (options?: { fast?: boolean }): Pro
logConnect('reprobe:better', { status: better.status, probed: higher.length });
if (better.status === 'ok') {
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
switchToTransport(better.transport, token, { runtimeKey: secureTokenKeyOf(active) });
switchToTransport(better.transport, token ?? null, { runtimeKey: secureTokenKeyOf(active) });
return 'switched';
}
// The shared token was explicitly rejected — no transport will accept it.
@@ -1251,7 +1255,7 @@ export const reprobeActiveConnection = async (options?: { fast?: boolean }): Pro
logConnect('reprobe:fallback', { status: fallback.status, probed: lower.length });
if (fallback.status === 'ok') {
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
switchToTransport(fallback.transport, token, { runtimeKey: secureTokenKeyOf(active) });
switchToTransport(fallback.transport, token ?? null, { runtimeKey: secureTokenKeyOf(active) });
return 'switched';
}
if (fallback.status === 'needs-login') return 'needs-login';
@@ -3,6 +3,7 @@ import type { RuntimeEndpointChangedDetail } from '@/lib/runtime-switch';
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -52,6 +53,9 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
lastDisconnectReason: null,
});
useProjectsStore.getState().resetForRuntimeSwitch();
// Notes, todos, plans and the pinned-context bookkeeping are keyed by a
// path-derived project id, which two runtimes can collide on.
useProjectContextStore.getState().reset();
// Cross-project session list (mobile sessions sheet & co) belongs to the
// previous instance — drop it so stale sessions can't linger after a switch.
useGlobalSessionsStore.getState().resetForRuntimeSwitch();
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- Claude AI symbol (CC0, Wikimedia Commons File:Claude_AI_symbol.svg), monochrome for theme invert -->
<path fill="currentColor" d="m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,15 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Command Code</title>
<path
d="M8 2.4C5.7 2.45 4.35 2.75 3.45 3.7C2.55 4.65 2.3 6.1 2.25 8.35C2.2 10.15 2.2 13.85 2.25 15.65C2.3 17.9 2.55 19.35 3.45 20.3C4.35 21.25 5.7 21.55 8 21.6C9.9 21.65 14.1 21.65 16 21.6C18.3 21.55 19.65 21.25 20.55 20.3C21.45 19.35 21.7 17.9 21.75 15.65C21.8 13.85 21.8 10.15 21.75 8.35C21.7 6.1 21.45 4.65 20.55 3.7C19.65 2.75 18.3 2.45 16 2.4C14.1 2.35 9.9 2.35 8 2.4Z"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linejoin="round"
/>
<path
d="M10 8H14V6.5C14 4.567 15.567 3 17.5 3C19.433 3 21 4.567 21 6.5C21 8.433 19.433 10 17.5 10H16V14H17.5C19.433 14 21 15.567 21 17.5C21 19.433 19.433 21 17.5 21C15.567 21 14 19.433 14 17.5V16H10V17.5C10 19.433 8.433 21 6.5 21C4.567 21 3 19.433 3 17.5C3 15.567 4.567 14 6.5 14H8V10H6.5C4.567 10 3 8.433 3 6.5C3 4.567 4.567 3 6.5 3C8.433 3 10 4.567 10 6.5V8ZM8 8V6.5C8 5.67157 7.32843 5 6.5 5C5.67157 5 5 5.67157 5 6.5C5 7.32843 5.67157 8 6.5 8H8ZM8 16H6.5C5.67157 16 5 16.6716 5 17.5C5 18.3284 5.67157 19 6.5 19C7.32843 19 8 18.3284 8 17.5V16ZM16 8H17.5C18.3284 8 19 7.32843 19 6.5C19 5.67157 18.3284 5 17.5 5C16.6716 5 16 5.67157 16 6.5V8ZM16 16V17.5C16 18.3284 16.6716 19 17.5 19C18.3284 19 19 18.3284 19 17.5C19 16.6716 18.3284 16 17.5 16H16ZM10 10V14H14V10H10Z"
fill="currentColor"
transform="translate(4.56 4.56) scale(.62)"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -226,7 +226,7 @@ export const BrowserToolbar: React.FC<BrowserToolbarProps> = ({
) : null}
{onAnnotate ? (
<ToolbarButton
icon="cursor"
icon="markup"
label={t('contextPanel.browser.annotate.toggle')}
onClick={onAnnotate}
pressed={isAnnotating}
@@ -533,12 +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, initialAllowPromptingSubagentSessions }) => {
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);
@@ -591,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,
});
@@ -1042,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;
@@ -1069,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),
+85 -41
View File
@@ -7,11 +7,12 @@ import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, typ
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useInputStore } from '@/sync/input-store';
import { prepareLocalAttachments, useInputStore } from '@/sync/input-store';
import {
ACCEPTED_ATTACHMENT_EXTENSIONS,
ATTACHMENT_ACCEPT,
getUnsupportedAttachmentInputs,
isDocumentAttachmentFilename,
type AttachmentInputModality,
} from '@/sync/attachment-files';
import type { AttachedFile } from '@/stores/types/sessionTypes';
@@ -24,6 +25,7 @@ import { appendInlineComments } from '@/lib/messages/inlineComments';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { startReviewFlow } from '@/lib/reviewFlow';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { runtimeFetch } from '@/lib/runtime-fetch';
import {
createChatDraftIdentity,
readChatDraft,
@@ -596,59 +598,62 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
[],
);
const extractInlineFileMentions = React.useCallback((rawText: string): { sanitizedText: string; attachments: AttachedFile[] } => {
const resolveInlineFileMention = React.useCallback((mentionPath: string): { serverPath: string; filename: string } | null => {
const kind = classifyMention(mentionPath, {
knownAgentNames: knownAgentNamesRef.current,
confirmedMentions: confirmedMentionsRef.current,
});
if (kind !== 'file') return null;
const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
if (!normalizedMentionPath) return null;
const clientDirectory = opencodeClient.getDirectory() || '';
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
let serverPath: string | null = null;
if (mentionPath.startsWith('/')) {
serverPath = mentionPath.replace(/\\/g, '/');
} else if (root) {
serverPath = `${root}/${normalizedMentionPath}`;
}
if (!serverPath) return null;
return {
serverPath: serverPath.replace(/\/+/g, '/'),
filename: normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath,
};
}, [chatSearchDirectory]);
const extractInlineFileMentions = React.useCallback((
rawText: string,
preparedDocumentMentions?: ReadonlyMap<string, AttachedFile[]>,
) => {
if (!rawText || !rawText.includes('@')) {
return { sanitizedText: rawText, attachments: [] };
}
const clientDirectory = opencodeClient.getDirectory() || '';
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
const seenPaths = new Set<string>();
const attachments: AttachedFile[] = [];
for (const token of scanMentions(rawText)) {
const mentionPath = token.name;
const kind = classifyMention(mentionPath, {
knownAgentNames: knownAgentNamesRef.current,
confirmedMentions: confirmedMentionsRef.current,
});
// Agents are routed separately by parseAgentMentions; only file
// references become attachments here.
if (kind !== 'file') {
const mention = resolveInlineFileMention(token.name);
if (!mention || seenPaths.has(mention.serverPath)) continue;
seenPaths.add(mention.serverPath);
const prepared = preparedDocumentMentions?.get(mention.serverPath);
if (prepared) {
attachments.push(...prepared);
continue;
}
const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
if (!normalizedMentionPath) {
continue;
}
const serverPath = mentionPath.startsWith('/')
? mentionPath.replace(/\\/g, '/')
: root
? `${root}/${normalizedMentionPath}`
: null;
if (!serverPath) {
continue;
}
const normalizedServerPath = serverPath.replace(/\/+/g, '/');
if (seenPaths.has(normalizedServerPath)) {
continue;
}
seenPaths.add(normalizedServerPath);
const filename = normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath;
attachments.push({
id: `inline-server-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
file: new File([], filename, { type: 'text/plain' }),
filename,
file: new File([], mention.filename, { type: 'text/plain' }),
filename: mention.filename,
mimeType: 'text/plain',
size: 0,
dataUrl: toServerFileUrl(normalizedServerPath),
dataUrl: toServerFileUrl(mention.serverPath),
source: 'server',
serverPath: normalizedServerPath,
serverPath: mention.serverPath,
});
}
@@ -656,7 +661,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
sanitizedText: rawText,
attachments,
};
}, [chatSearchDirectory]);
}, [resolveInlineFileMention]);
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const prevWasAbortedRef = React.useRef(false);
@@ -960,6 +965,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
};
const handleSubmit = async (options?: SubmitOptions) => {
const submitRuntimeKey = getRuntimeKey();
const queuedOnly = options?.queuedOnly ?? false;
const queuedMessageId = options?.queuedMessageId;
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
@@ -1051,6 +1057,44 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
: undefined;
const preparedDocumentMentions = new Map<string, AttachedFile[]>();
const reservedFilenames = new Set([
...attachedFiles.map((attachment) => attachment.filename),
...queuedMessagesToSend.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []),
]);
const mentionTexts = [
...queuedMessagesToSend.map((queued) => queued.content),
...(!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : []),
];
for (const rawText of mentionTexts) {
for (const token of scanMentions(rawText)) {
const mention = resolveInlineFileMention(token.name);
if (
!mention
|| !isDocumentAttachmentFilename(mention.filename)
|| preparedDocumentMentions.has(mention.serverPath)
) {
continue;
}
try {
const response = await runtimeFetch('/api/fs/raw', { query: { path: mention.serverPath } });
if (!response.ok) throw new Error(`Failed to read ${mention.filename}`);
const sourceBlob = await response.blob();
if (getRuntimeKey() !== submitRuntimeKey) return;
const source = new File([sourceBlob], mention.filename);
const prepared = await prepareLocalAttachments(source, reservedFilenames);
if (!prepared || prepared.length === 0) throw new Error(`Failed to prepare ${mention.filename}`);
if (getRuntimeKey() !== submitRuntimeKey) return;
preparedDocumentMentions.set(mention.serverPath, prepared);
for (const attachment of prepared) reservedFilenames.add(attachment.filename);
} catch {
if (getRuntimeKey() !== submitRuntimeKey) return;
toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: mention.filename }));
return;
}
}
}
// Inline review comments and synthetic context are consumed before
// assembly so a failed send can restore exactly what it took.
const syntheticParts = consumePendingSyntheticParts();
@@ -1079,7 +1123,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
return { text: sanitizedText, agentName: mention?.name };
},
extractFileMentions: (text) => {
const { sanitizedText, attachments } = extractInlineFileMentions(text);
const { sanitizedText, attachments } = extractInlineFileMentions(text, preparedDocumentMentions);
return { text: sanitizedText, attachments };
},
sanitizeAttachments: sanitizeAttachmentsForSend,
@@ -1,25 +1,92 @@
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 { resolveMarkdownImageSource } from './markdown/markdownImageAssets';
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, directory, onShowPopup }) => {
}> = ({
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 [image, setImage] = React.useState<{ url: string; status: 'loading' | 'ready' | 'error' }>({
url: '',
status: 'loading',
});
const local = isLocalMarkdownImageSource(candidate.source);
React.useEffect(() => {
const thumbnail = thumbnailRef.current;
@@ -28,7 +95,6 @@ const MarkdownImageThumbnail: React.FC<{
setShouldLoad(true);
return;
}
const observer = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
setShouldLoad(true);
@@ -39,20 +105,45 @@ const MarkdownImageThumbnail: React.FC<{
}, [shouldLoad]);
React.useEffect(() => {
if (!shouldLoad) return;
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, directory, controller.signal)
.then((url) => {
if (!controller.signal.aborted) setImage({ url, status: 'loading' });
})
.catch(() => {
if (!controller.signal.aborted) setImage({ url: '', status: 'error' });
});
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();
}, [candidate.source, directory, shouldLoad]);
}, [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,
@@ -61,7 +152,7 @@ const MarkdownImageThumbnail: React.FC<{
metadata: { tool: 'markdown-image-preview', filename: candidate.filename },
image: { url: image.url, filename: candidate.filename },
});
}, [candidate.filename, image, onShowPopup]);
}, [candidate.filename, image, onShowPopup, t]);
return (
<button
@@ -69,7 +160,7 @@ const MarkdownImageThumbnail: React.FC<{
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 !== 'ready'}
disabled={image.status === 'loading'}
onClick={openPreview}
data-openchamber-markdown-image-action="true"
data-openchamber-markdown-image-source={candidate.source}
@@ -107,27 +198,94 @@ const MarkdownImageThumbnail: React.FC<{
};
export const MarkdownImageGallery: React.FC<{
sessionId?: string;
messageId: string;
contents: readonly string[];
onShowPopup?: (content: ToolPopupContent) => void;
}> = ({ contents, onShowPopup }) => {
}> = ({ 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]);
if (candidates.length === 0) return null;
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"
>
{candidates.map((candidate) => (
{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}
/>
))}
@@ -19,8 +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 { getMarkdownImageFilename, renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
import { resolveMarkdownImageSource } from './markdown/markdownImageAssets';
import { renderMarkdownBlocks, renderMarkdownSync, type MarkdownImageMode } from './markdown/markdownCore';
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
import {
@@ -109,59 +108,6 @@ const useExternalLinkInteractions = ({
}, [containerRef, enabled]);
};
const useMarkdownImageLinkInteractions = ({
containerRef,
directory,
enabled,
onShowPopup,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
directory: string;
enabled: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
}) => {
React.useEffect(() => {
const container = containerRef.current;
if (!enabled || !container || !onShowPopup) return;
const controller = new AbortController();
const handleClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0) return;
const target = event.target;
if (!(target instanceof Element)) return;
const link = target.closest<HTMLAnchorElement>('[data-openchamber-markdown-image-link="true"]');
if (!link || !container.contains(link)) return;
const source = link.getAttribute('data-openchamber-markdown-image-source') ?? '';
const filename = link.getAttribute('data-openchamber-markdown-image-filename')
|| getMarkdownImageFilename(source, '');
if (!source || !filename) return;
event.preventDefault();
event.stopPropagation();
void resolveMarkdownImageSource(source, directory, controller.signal)
.then((url) => {
if (controller.signal.aborted || !link.isConnected) return;
onShowPopup({
open: true,
title: filename,
content: '',
metadata: { tool: 'markdown-image-preview', filename },
image: { url, filename },
});
})
.catch(() => undefined);
};
container.addEventListener('click', handleClick);
return () => {
controller.abort();
container.removeEventListener('click', handleClick);
};
}, [containerRef, directory, enabled, onShowPopup]);
};
const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = {
download: true,
copy: true,
@@ -195,7 +141,6 @@ interface MarkdownRendererProps {
variant?: MarkdownVariant;
onShowPopup?: (content: ToolPopupContent) => void;
enableFileReferences?: boolean;
enableLocalImages?: boolean;
}
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
@@ -557,10 +502,6 @@ const useFileReferenceInteractions = ({
let linkedCount = 0;
for (const candidate of Array.from(candidates)) {
if (candidate.matches('[data-openchamber-markdown-image-link="true"]')) {
clearFileLinkAttributes(candidate);
continue;
}
const rawCandidate = extractPathCandidateFromElement(candidate);
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
clearFileLinkAttributes(candidate);
@@ -894,16 +835,14 @@ const useMorphdomMarkdown = ({
containerRef,
text,
streaming,
cacheKey,
deferImages = false,
imageMode = 'inline',
syntaxVars,
ctx,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
text: string;
streaming: boolean;
cacheKey: string;
deferImages?: boolean;
imageMode?: MarkdownImageMode;
syntaxVars: Record<string, string>;
ctx: DecorateContext;
}) => {
@@ -942,7 +881,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, deferImages);
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
@@ -954,7 +893,7 @@ const useMorphdomMarkdown = ({
refreshMermaidViewers();
}
}
}, [containerRef, text, deferImages, ctx, refreshMermaidViewers]);
}, [containerRef, text, imageMode, ctx, refreshMermaidViewers]);
React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup();
@@ -967,7 +906,7 @@ const useMorphdomMarkdown = ({
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
let active = true;
void renderMarkdownBlocks(text, streaming, cacheKey, deferImages).then((blocks) => {
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active) return;
const existing = Array.from(target.children) as HTMLElement[];
@@ -1018,7 +957,7 @@ const useMorphdomMarkdown = ({
return () => {
active = false;
};
}, [containerRef, text, streaming, cacheKey, deferImages, ctx, refreshMermaidViewers]);
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
React.useEffect(() => {
const container = containerRef.current;
@@ -1065,7 +1004,6 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
variant = 'assistant',
onShowPopup,
enableFileReferences = true,
enableLocalImages = false,
}) => {
streamPerfCount('ui.markdown_renderer.render');
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
@@ -1097,40 +1035,30 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
enabled: enableFileReferences && !isStreaming,
});
useExternalLinkInteractions({ containerRef });
useMarkdownImageLinkInteractions({
containerRef,
directory: effectiveDirectory,
enabled: enableLocalImages && variant === 'assistant' && !isStreaming,
onShowPopup,
});
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
// Identity for the fade-in wrapper: a new part/message restarts the animation.
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
useMorphdomMarkdown({
containerRef,
text: content,
streaming: live,
cacheKey,
deferImages: enableLocalImages && variant === 'assistant' && !isStreaming,
imageMode: variant === 'assistant' ? 'label' : 'inline',
syntaxVars,
ctx,
});
const markdownContent = (
<div
className={cn('break-words w-full min-w-0', className)}
ref={containerRef}
data-openchamber-finalized-assistant-images={enableLocalImages && variant === 'assistant' && !isStreaming ? 'true' : undefined}
>
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
<div className={markdownContentClassName(variant)} data-markdown-content />
</div>
);
if (isAnimated) {
return (
<FadeInOnReveal key={cacheKey} skipAnimation={skipFadeIn}>
<FadeInOnReveal key={fadeKey} skipAnimation={skipFadeIn}>
{markdownContent}
</FadeInOnReveal>
);
@@ -1150,7 +1078,6 @@ export const MarkdownRenderer = React.memo(MarkdownRendererImpl, (prev, next) =>
&& prev.messageId === next.messageId
&& prev.onShowPopup === next.onShowPopup
&& prev.enableFileReferences === next.enableFileReferences
&& prev.enableLocalImages === next.enableLocalImages
&& prev.part?.id === next.part?.id;
});
@@ -1208,7 +1135,6 @@ const SimpleMarkdownRendererImpl: React.FC<{
containerRef,
text: renderedContent,
streaming: false,
cacheKey: `simple:${variant}`,
syntaxVars,
ctx,
});
@@ -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');
});
});
@@ -61,20 +61,46 @@ question of design, not of feasibility.
Selection rendering: every device runs CodeMirror's `drawSelection()` — it
keeps typing on the drawn-selection code path, and removing it makes
CodeMirror enforce cursor association on the native selection, which iOS
answers with severe input lag. Every device also layers
`composerNativeSelectionExtension` (`editor/theme.ts`) on top: it re-shows
answers with severe input lag. **That much is not platform-specific and must
not be undone.** What differs is who paints the selection, and
`composerSelectionExtension` (`editor/theme.ts`) picks that per platform.
When CodeMirror 6.43.9's iOS predicate does not match,
`composerNativeSelectionExtension` layers over `drawSelection()`: it re-shows
the native selection, and — only while a range is selected — the native caret,
hiding the painted layers those replace. The native selection is the one that
shows for two reasons: the painted layer sits behind the content, so tokens
with their own background (inline code, fences) cover it completely; and
iOS's selection drag handles attach to the visible native selection and take
their colour from the caret, so a transparent caret means invisible handles.
The range-only caret scoping is load-bearing — a native caret visible while
typing makes WebKit re-render its caret UI after every keystroke, felt as
severe input lag. The selection tint comes from `--primary`, not the selection
token:
themes define `--interactive-selection` with its own alpha, so a translucent
mix of it is nearly invisible.
with their own background (inline code, fences) cover it completely; and the
platform's selection drag handles attach to the visible native selection and
take their colour from the caret, so a transparent caret means invisible
handles. The range-only caret scoping is load-bearing — a native caret visible
while typing makes the browser re-render its caret UI after every keystroke,
felt as severe input lag.
When CodeMirror 6.43.9's exact iOS predicate matches,
`composerIOSSelectionExtension` leaves selection-handle geometry and appearance
to CodeMirror. CodeMirror puts the handles in `.cm-selectionLayer`, normally at
`z-index: -1`; the extension raises that layer above the content so opaque
token backgrounds cannot cover them, and leaves it transparent to touch.
The handle dots extend 8px past their range; matching scroller padding and
negative margin expand the clip area without moving the text or changing the
composer height. iOS still paints its taller system selection overlay even
when CSS makes `::selection` transparent. The extension therefore suppresses
CodeMirror's synthetic selection rectangles on iOS while leaving its handles,
cursor path and `nativeSelectionHidden` facet active. Otherwise the grey system
highlight and themed rectangle overlap with visibly different heights.
Do not add a second custom layer or custom handles here: overlapping translucent
rectangles make selection darker at their seams and imitated handles drift from
the geometry WebKit actually manipulates. What iOS avoids is installing the
native-selection workaround above: explicitly restoring native paint and caret
makes WebKit re-measure them after every decoration redraw, and the composer
rebuilds every decoration on every keystroke. That cost is felt worst during
IME composition.
The non-iOS native selection tint comes from `--primary`, not the selection
token: themes define `--interactive-selection` with its own alpha, so mixing it
with transparent again is nearly invisible. The iOS system overlay owns its
visible selection fill.
`composerLanguage.ts` retokenizes the whole document on every change. The
composer holds a prompt, not a source file: it is short enough that a full pass
@@ -36,7 +36,7 @@ import { cn } from '@/lib/utils';
import type { ComposerLanguageContext } from '../language/tokenize';
import { composerLanguage, setLanguageContext } from './composerLanguage';
import type { ComposerEditorViewStore } from './viewStore';
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
import { composerEditorTheme, composerSelectionExtension } from './theme';
import { handleComposerHostMouseDown } from './hostMouseDown';
export interface ComposerSelection {
@@ -234,14 +234,13 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
doc: handlersRef.current.value,
extensions: [
history(),
// `drawSelection()` must stay even though the native
// selection is what actually shows (see the theme's
// comment on `composerNativeSelectionExtension`):
// removing it makes CodeMirror enforce cursor
// association on the native selection, which iOS
// answers with severe input lag.
// `drawSelection()` must stay on every platform.
// `composerSelectionExtension()` changes only who
// paints the selection; removing `drawSelection()`
// makes CodeMirror enforce cursor association on the
// native selection, which iOS answers with severe lag.
drawSelection(),
composerNativeSelectionExtension,
composerSelectionExtension(),
EditorView.lineWrapping,
// Highest precedence: the composer's own keys must win
// over CodeMirror's defaults (Enter sends, ArrowUp
@@ -344,6 +343,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,
@@ -1,16 +1,29 @@
import { describe, expect, test } from 'bun:test';
import { EditorState } from '@codemirror/state';
import { EditorState, type Extension } from '@codemirror/state';
import {
COMPOSER_EDITOR_THEME_SPEC,
IOS_SELECTION_THEME_SPEC,
NATIVE_SELECTION_THEME_SPEC,
composerEditorTheme,
composerIOSSelectionExtension,
composerNativeSelectionExtension,
composerSelectionExtension,
isCodeMirrorIOSNavigator,
} from '../theme';
const selectors = Object.keys(COMPOSER_EDITOR_THEME_SPEC);
const declarations = JSON.stringify(COMPOSER_EDITOR_THEME_SPEC);
function installationError(extension: Extension): string | null {
try {
EditorState.create({ extensions: [extension] });
return null;
} catch (error) {
return String(error);
}
}
describe('composerEditorTheme', () => {
/**
* EditorView.theme compiles its selectors when this module is imported and
@@ -20,13 +33,7 @@ describe('composerEditorTheme', () => {
* surfaces only in the running app, where it takes the composer down.
*/
test('its selectors compile and the theme can be installed', () => {
let failure: unknown = null;
try {
EditorState.create({ extensions: [composerEditorTheme] });
} catch (error) {
failure = error;
}
expect(failure).toBeNull();
expect(installationError(composerEditorTheme)).toBeNull();
});
/**
@@ -101,6 +108,10 @@ describe('composerEditorTheme', () => {
expect(rule.background.includes('transparent')).toBe(true);
}
});
test('the common theme does not re-show the native selection', () => {
expect(selectors.some((selector) => selector.includes('::selection'))).toBe(false);
});
});
describe('composerNativeSelectionTheme', () => {
@@ -108,21 +119,16 @@ describe('composerNativeSelectionTheme', () => {
const nativeDeclarations = JSON.stringify(NATIVE_SELECTION_THEME_SPEC);
/**
* Every device layers this over `drawSelection()`: the native selection
* paints over token backgrounds (the painted layer is hidden behind them)
* and iOS attaches its selection handles to it. `drawSelection()` must
* NOT be removed for that: without it CodeMirror starts enforcing cursor
* association on the native selection while typing in wrapped text, and
* iOS answers those programmatic selection moves with severe input lag.
* Every device except iOS layers this over `drawSelection()`: the native
* selection paints over token backgrounds (the painted layer is hidden
* behind them) and the platform attaches its selection handles to it.
* `drawSelection()` must NOT be removed for that: without it CodeMirror
* starts enforcing cursor association on the native selection while typing
* in wrapped text, and iOS answers those programmatic selection moves with
* severe input lag.
*/
test('it compiles and can be installed', () => {
let failure: unknown = null;
try {
EditorState.create({ extensions: [composerNativeSelectionExtension] });
} catch (error) {
failure = error;
}
expect(failure).toBeNull();
expect(installationError(composerNativeSelectionExtension)).toBeNull();
});
/**
@@ -150,9 +156,9 @@ describe('composerNativeSelectionTheme', () => {
});
/**
* iOS colours its selection drag handles from the caret colour. With
* `drawSelection()`'s `caret-color: transparent !important` in effect the
* handles are drawn invisibly. The native caret must come back with
* A platform showing native handles colours them from the caret colour.
* With `drawSelection()`'s `caret-color: transparent !important` in effect
* the handles are drawn invisibly. The native caret must come back with
* enough weight to win, and the drawn cursor layer must go so there are
* not two carets.
*
@@ -195,3 +201,106 @@ describe('composerNativeSelectionTheme', () => {
expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]);
});
});
describe('composerIOSSelectionExtension', () => {
const layerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller > .cm-selectionLayer'];
const scrollerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller'];
const selectionBackgroundRule = IOS_SELECTION_THEME_SPEC['& .cm-selectionBackground'];
test('it compiles and can be installed', () => {
expect(installationError(composerIOSSelectionExtension)).toBeNull();
});
/**
* CodeMirror renders its selection layer at `z-index: -1`, behind the
* text. Inline code and code fences have opaque backgrounds and otherwise
* cover both the selection and the iOS handles. The base value is inline,
* so raising it without `!important` silently does nothing.
*/
test('CodeMirror selection and handles are raised above token backgrounds', () => {
expect(layerRule.zIndex).toBe('100 !important');
});
/**
* The layer now sits over the content and would intercept taps and drags
* by default. It only paints; CodeMirror/WebKit still own the gestures.
*/
test('the layer does not intercept touch', () => {
expect(layerRule.pointerEvents).toBe('none');
});
/**
* A higher z-index cannot escape overflow clipping. CodeMirror's dots
* extend 8px past the range, so the scroller needs that much internal room;
* the matching negative margin keeps the text and composer height fixed.
*/
test('the scroller reserves unclipped room for both handles', () => {
expect(scrollerRule.paddingBlock).toBe('8px');
expect(scrollerRule.marginBlock).toBe('-8px');
});
test('the CodeMirror fill does not stack over the iOS system highlight', () => {
expect(selectionBackgroundRule.background).toBe('transparent !important');
});
/**
* A second custom layer was visually indistinguishable from duplicate
* native selection UI. iOS must only reposition the one layer that
* CodeMirror already uses for both selection rectangles and handles.
*/
test('it does not add a second selection implementation', () => {
expect(Object.keys(IOS_SELECTION_THEME_SPEC)).toEqual([
'& .cm-scroller',
'& .cm-scroller > .cm-selectionLayer',
'& .cm-selectionBackground',
]);
});
});
describe('composerSelectionExtension', () => {
/**
* The split is the point: iOS is the only platform that pays for a visible
* native selection during composition, and CodeMirror 6.43.9 draws its
* handles. Collapsing the two branches into one would
* either restore the latency on iOS or leave every other platform without
* discoverable range selection.
*/
test('the CodeMirror iOS path uses its handles; other platforms keep native selection', () => {
expect(composerSelectionExtension(true)).toBe(composerIOSSelectionExtension);
expect(composerSelectionExtension(false)).toBe(composerNativeSelectionExtension);
});
/**
* The composer may remove the native fallback only when CodeMirror's own
* browser predicate enables its replacement handles. This deliberately
* includes CodeMirror's vendor and touch thresholds rather than using a
* broader application-level iOS heuristic.
*/
test('the platform predicate matches CodeMirror 6.43.9', () => {
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6) Mobile/15E148 Safari/604.1',
'Apple Computer, Inc.',
5,
)).toBe(true);
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
'Apple Computer, Inc.',
5,
)).toBe(true);
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
'Google Inc.',
5,
)).toBe(false);
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
'Apple Computer, Inc.',
0,
)).toBe(false);
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0)',
'Apple Computer, Inc.',
5,
)).toBe(false);
});
});
@@ -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);
});
});
@@ -5,6 +5,7 @@
* language layer emits, so the composer and the message list stay in step.
*/
import type { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
/**
@@ -78,23 +79,16 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
'&.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
background: 'color-mix(in srgb, var(--interactive-selection) 55%, transparent)',
},
// The native selection still shows through in places CodeMirror does not
// draw over, such as the placeholder. Same colour as the native-selection
// theme below, for the same reason: the selection token carries its own
// alpha and reads as nearly invisible when mixed down again.
'& ::selection': {
background: 'color-mix(in srgb, var(--primary) 25%, transparent)',
},
};
export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
/**
* Every device keeps `drawSelection()` but shows the NATIVE selection through
* it, for two independent reasons:
* Outside CodeMirror's iOS branch, devices keep `drawSelection()` but show the
* NATIVE selection through it, for two independent reasons:
*
* - iOS attaches its selection handles (the draggable pins after a
* double-tap) to the *visible* native selection, and `drawSelection()`
* - Their selection drag handles (the draggable pins after a double-tap)
* attach to the *visible* native selection, and `drawSelection()`
* hides it with `.cm-line ::selection { background: transparent
* !important }`, so the handles never appear and range selection is
* undiscoverable.
@@ -103,12 +97,15 @@ export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
* the selection is invisible inside those spans. The native selection
* paints over element backgrounds.
*
* Dropping `drawSelection()` entirely is NOT an option: without it CodeMirror
* clears the `nativeSelectionHidden` facet and starts enforcing cursor
* association on the native selection while typing in wrapped text
* programmatic selection moves that iOS answers with severe input lag (each
* one also resets the keyboard's autocorrect context). Typing must stay on
* the drawn-selection code path; only the paint changes.
* Dropping `drawSelection()` entirely is NOT an option, on any platform:
* without it CodeMirror clears the `nativeSelectionHidden` facet and starts
* enforcing cursor association on the native selection while typing in
* wrapped text programmatic selection moves that iOS answers with severe
* input lag (each one also resets the keyboard's autocorrect context). Typing
* must stay on the drawn-selection code path; only the paint changes.
*
* CodeMirror's iOS branch does NOT use this arrangement
* `composerIOSSelectionExtension` below explains why.
*
* Both rules below fight `drawSelection()`'s own `Prec.highest` theme, so
* they carry `!important` and one class more specificity
@@ -155,14 +152,103 @@ export const NATIVE_SELECTION_THEME_SPEC = {
const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
/**
* The native-selection arrangement, installed on every device: the theme
* above plus the `.oc-native-range` marker class that scopes its caret rules
* to the moments a range is actually selected. `editorAttributes`
* The native-selection arrangement, installed outside CodeMirror's iOS branch:
* the theme above plus the `.oc-native-range` marker class that scopes its
* caret rules to the moments a range is actually selected. `editorAttributes`
* re-evaluates on every update, so the class follows the selection with no
* listener of its own.
*/
export const composerNativeSelectionExtension = [
export const composerNativeSelectionExtension: Extension = [
composerNativeSelectionTheme,
EditorView.editorAttributes.of((view) =>
view.state.selection.main.empty ? null : { class: 'oc-native-range' }),
];
/**
* When its iOS predicate matches, CodeMirror 6.43.9 draws the range handles
* into the same layer as the selection, so CodeMirror owns both their geometry
* and appearance.
*
* That layer normally renders at `z-index: -1`, behind the content. Inline
* code and code fences have opaque backgrounds and would cover both the tint
* and handles. Raising the one existing layer fixes that without introducing
* a second set of rectangles or trying to imitate WebKit's controls. The
* layer remains transparent to touch so WebKit receives selection gestures.
*
* What iOS avoids is the native-selection workaround above: explicitly
* restoring the native highlight and caret makes WebKit re-measure and repaint
* that UI after every decoration redraw. `composerLanguage.ts` rebuilds the
* whole decoration set on every keystroke, so the cost is felt worst during
* IME composition where each intermediate replacement pays for it. WebKit's
* unavoidable system selection overlay remains the only visible fill.
*/
export const IOS_SELECTION_THEME_SPEC = {
// The handles extend 8px above/below their range. The scroller clips them
// at its own edge even when the layer has a high z-index, so reserve that
// room inside the clipping box and pull the box outward by the same amount.
// Text and composer height stay where they were; only the clip area grows.
'& .cm-scroller': {
marginBlock: '-8px',
paddingBlock: '8px',
},
'& .cm-scroller > .cm-selectionLayer': {
// CodeMirror writes `z-index: -1` inline. `!important` is intentional:
// without it token backgrounds cover the selection and its handles.
zIndex: '100 !important',
pointerEvents: 'none',
},
// iOS keeps showing its taller system selection overlay even when
// ::selection is transparent. Painting CodeMirror's themed rectangles as
// well produces two visibly misaligned fills, so only the synthetic
// background is suppressed. The handles in this layer remain visible.
'& .cm-selectionBackground': {
background: 'transparent !important',
},
};
export const composerIOSSelectionExtension: Extension =
EditorView.theme(IOS_SELECTION_THEME_SPEC);
/**
* Which selection paint the composer installs. The split is the platform's,
* not a preference: iOS is the one place where restoring native selection
* paint and caret costs measurable input latency, and the only place
* CodeMirror supplies replacement drag handles.
*
* The caller can pass the policy, so the choice stays testable and is made
* once per editor rather than once per module load.
*/
export function composerSelectionExtension(
useCodeMirrorIOSHandles: boolean = usesCodeMirrorIOSSelectionHandles(),
): Extension {
return useCodeMirrorIOSHandles
? composerIOSSelectionExtension
: composerNativeSelectionExtension;
}
/**
* Mirrors @codemirror/view 6.43.9's iOS predicate. This branch may only rely
* on the drawn handles when CodeMirror itself will create them; a broader iOS
* heuristic could remove the native fallback without installing a replacement.
*/
export function isCodeMirrorIOSNavigator(
userAgent: string,
vendor: string,
maxTouchPoints: number,
): boolean {
const isIE = /Edge\/(\d+)/.test(userAgent)
|| /MSIE \d/.test(userAgent)
|| /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.test(userAgent);
if (isIE || !/Apple Computer/.test(vendor)) return false;
return /Mobile\/\w+/.test(userAgent) || maxTouchPoints > 2;
}
function usesCodeMirrorIOSSelectionHandles(): boolean {
const nav = globalThis.navigator;
if (!nav) return false;
return isCodeMirrorIOSNavigator(
nav.userAgent || '',
nav.vendor || '',
nav.maxTouchPoints ?? 0,
);
}
@@ -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);
@@ -0,0 +1,125 @@
// Bounded LRU for rendered markdown / Shiki highlight results.
//
// Used by `markdownCore` (per-block HTML) and by the main-thread markdown
// worker client (highlight results) so unchanged content is never re-rendered
// or re-tokenized. Keys are short content fingerprints (not the full source) so
// cache maps do not duplicate large strings. Entry byte sizes are recorded once
// at insert time — get/evict never re-walk the payload.
export type HighlightResultCacheOptions = {
maxEntries: number;
maxBytes: number;
};
type CacheEntry<T> = {
value: T;
bytes: number;
};
/** UTF-16 storage estimate for a JS string (chars × 2). Avoids TextEncoder allocs. */
export const utf16Bytes = (value: string): number => value.length * 2;
/** Final avalanche so near-identical sources do not land in adjacent buckets. */
const mix32 = (hash: number): number => {
let h = hash;
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
return h >>> 0;
};
/**
* Short stable fingerprint for cache keys: length + two independent 32-bit
* multiplicative hashes (~64 bits of key space).
*
* These caches are content-addressed and global, so a collision does not merely
* mis-color a block the cache returns a *different* block's rendered HTML and
* the user is shown source they never wrote. One 32-bit hash is not enough for
* that failure mode: a few thousand same-length entries reach a birthday
* collision probability worth caring about, and the result would be
* undiagnosable in the field. Two multiplies per character are free next to
* Shiki tokenization.
*/
export const contentFingerprint = (value: string): string => {
let h1 = 0x811c9dc5;
let h2 = 0xc2b2ae35;
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
h1 = Math.imul(h1 ^ code, 0x01000193);
h2 = Math.imul(h2 ^ code, 0x27220a95);
}
return `${value.length.toString(36)}_${mix32(h1).toString(36)}_${mix32(h2).toString(36)}`;
};
/** Approximate byte cost of token-run lines without JSON.stringify. */
export const estimateTokenRunsBytes = (
lines: ReadonlyArray<ReadonlyArray<readonly [number, string, number]>>,
): number => {
let total = 0;
for (const line of lines) {
total += 4;
for (const run of line) {
total += 8 + utf16Bytes(run[1]);
}
}
return total;
};
export class HighlightResultCache<T> {
private readonly maxEntries: number;
private readonly maxBytes: number;
private readonly map = new Map<string, CacheEntry<T>>();
private totalBytes = 0;
constructor(options: HighlightResultCacheOptions) {
this.maxEntries = Math.max(1, options.maxEntries);
this.maxBytes = Math.max(1, options.maxBytes);
}
get size(): number {
return this.map.size;
}
get bytes(): number {
return this.totalBytes;
}
get(key: string): T | undefined {
const entry = this.map.get(key);
if (entry === undefined) return undefined;
// Refresh LRU order without recomputing size.
this.map.delete(key);
this.map.set(key, entry);
return entry.value;
}
set(key: string, value: T, bytes: number): void {
const existing = this.map.get(key);
if (existing !== undefined) {
this.totalBytes -= existing.bytes;
this.map.delete(key);
}
const entryBytes = Math.max(0, bytes);
while (
this.map.size > 0
&& (this.map.size >= this.maxEntries || this.totalBytes + entryBytes > this.maxBytes)
) {
const oldest = this.map.keys().next().value;
if (oldest === undefined) break;
const oldestEntry = this.map.get(oldest);
if (oldestEntry !== undefined) this.totalBytes -= oldestEntry.bytes;
this.map.delete(oldest);
// Always allow a single oversized entry so huge files still cache once.
if (this.map.size === 0) break;
}
this.map.set(key, { value, bytes: entryBytes });
this.totalBytes += entryBytes;
}
clear(): void {
this.map.clear();
this.totalBytes = 0;
}
}
@@ -1,4 +1,10 @@
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
import {
contentFingerprint,
estimateTokenRunsBytes,
HighlightResultCache,
utf16Bytes,
} from './highlightResultCache';
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
// Main-thread client for the markdown Shiki worker. Moves syntax tokenization
@@ -6,9 +12,39 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse }
// ready-to-splice Shiki HTML. On any failure (no worker support, worker crash,
// tokenization error) the promise resolves to `null` and the caller keeps the
// escaped plain-text code — highlighting never falls back onto the main thread.
//
// Results are memoized by content fingerprint (+ lang / theme). Unchanged
// content must not re-enter the worker — that was the sustained ~40 msg/s
// re-highlight load in openchamber/openchamber#2769. In-flight requests with
// the same key coalesce so remount storms share one round-trip. Cache keys are
// fingerprints (not full source) so large files are not duplicated in the Map.
//
// This module is the only sender to the worker, so memoizing here is sufficient
// and the worker itself stays stateless apart from the Shiki instance. A second
// cache inside the worker would only duplicate these payloads in another heap.
//
// `highlight` / `highlightLines` results are theme-independent: the worker
// tokenizes with the CSS-variable `MARKDOWN_SHIKI_THEME`, so a theme switch
// repaints via CSS and must not invalidate these entries. Only
// `highlightTokens` resolves concrete colors, so only its key carries a theme.
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
type CachedHighlight =
| { type: 'highlight'; html: string }
| { type: 'highlightLines'; lines: string[] }
| { type: 'highlightTokens'; lines: MarkdownTokenRun[][] };
const CLIENT_CACHE_MAX_ENTRIES = 2000;
const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024;
const resultCache = new HighlightResultCache<CachedHighlight>({
maxEntries: CLIENT_CACHE_MAX_ENTRIES,
maxBytes: CLIENT_CACHE_MAX_BYTES,
});
const inflight = new Map<string, Promise<CachedHighlight | null>>();
let worker: Worker | undefined;
let nextId = 0;
const pending = new Map<number, PendingResolver>();
@@ -16,10 +52,23 @@ const pending = new Map<number, PendingResolver>();
// repeat tokenization sends only the name (not the whole theme object) again.
const sentThemes = new Set<string>();
const entryBytes = (key: string, value: CachedHighlight): number => {
const keyBytes = utf16Bytes(key);
if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html);
if (value.type === 'highlightLines') {
let total = keyBytes;
for (const line of value.lines) total += utf16Bytes(line);
return total;
}
return keyBytes + estimateTokenRunsBytes(value.lines);
};
const failAll = (): void => {
pending.forEach((resolve) => resolve(null));
pending.clear();
sentThemes.clear();
// Drop in-flight waiters; cached results remain valid (pure fn of inputs).
inflight.clear();
worker?.terminate();
worker = undefined;
};
@@ -55,13 +104,47 @@ const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<Markdo
});
};
const coalesce = (
key: string,
run: () => Promise<CachedHighlight | null>,
): Promise<CachedHighlight | null> => {
const existing = inflight.get(key);
if (existing) return existing;
const pendingRequest = run().finally(() => {
inflight.delete(key);
});
inflight.set(key, pendingRequest);
return pendingRequest;
};
const cacheKeyFor = (kind: string, lang: string, code: string, themeName?: string): string => {
const fp = contentFingerprint(code);
return themeName === undefined ? `${kind}:${lang}:${fp}` : `${kind}:${themeName}:${lang}:${fp}`;
};
/** Test-only: clear client-side highlight memoization. */
export const resetMarkdownWorkerClientCacheForTests = (): void => {
resultCache.clear();
inflight.clear();
};
/**
* Highlight a complete code block in the worker. Resolves to Shiki `<pre>` HTML,
* or `null` if highlighting is unavailable or failed (caller keeps plain code).
*/
export const highlightCodeInWorker = async (code: string, lang: string): Promise<string | null> => {
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
return response?.type === 'highlight' ? response.html : null;
const key = cacheKeyFor('highlight', lang, code);
const cached = resultCache.get(key);
if (cached?.type === 'highlight') return cached.html;
const result = await coalesce(key, async () => {
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
if (response?.type !== 'highlight') return null;
const entry: CachedHighlight = { type: 'highlight', html: response.html };
resultCache.set(key, entry, entryBytes(key, entry));
return entry;
});
return result?.type === 'highlight' ? result.html : null;
};
/**
@@ -70,8 +153,18 @@ export const highlightCodeInWorker = async (code: string, lang: string): Promise
* round-trip instead of one per line. Resolves to `null` on failure.
*/
export const highlightLinesInWorker = async (code: string, lang: string): Promise<string[] | null> => {
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
return response?.type === 'highlightLines' ? response.lines : null;
const key = cacheKeyFor('highlightLines', lang, code);
const cached = resultCache.get(key);
if (cached?.type === 'highlightLines') return cached.lines;
const result = await coalesce(key, async () => {
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
if (response?.type !== 'highlightLines') return null;
const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines };
resultCache.set(key, entry, entryBytes(key, entry));
return entry;
});
return result?.type === 'highlightLines' ? result.lines : null;
};
/**
@@ -86,18 +179,25 @@ export const highlightTokensInWorker = async (
themeName: string,
theme: unknown,
): Promise<MarkdownTokenRun[][] | null> => {
const needsTheme = !sentThemes.has(themeName);
const response = await request((id) => ({
type: 'highlightTokens',
id,
code,
lang,
themeName,
...(needsTheme ? { theme } : {}),
}));
if (response?.type === 'highlightTokens') {
const key = cacheKeyFor('highlightTokens', lang, code, themeName);
const cached = resultCache.get(key);
if (cached?.type === 'highlightTokens') return cached.lines;
const result = await coalesce(key, async () => {
const needsTheme = !sentThemes.has(themeName);
const response = await request((id) => ({
type: 'highlightTokens',
id,
code,
lang,
themeName,
...(needsTheme ? { theme } : {}),
}));
if (response?.type !== 'highlightTokens') return null;
sentThemes.add(themeName);
return response.lines;
}
return null;
const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
resultCache.set(key, entry, entryBytes(key, entry));
return entry;
});
return result?.type === 'highlightTokens' ? result.lines : null;
};
@@ -13,7 +13,11 @@ mock.module('./markdown-worker', () => ({
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
const { extractMarkdownImageCandidates, renderMarkdownSync } = await import('./markdownCore');
const {
__markdownImageCandidateCacheForTests,
extractMarkdownImageCandidates,
renderMarkdownSync,
} = await import('./markdownCore');
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
describe('markdown sanitization', () => {
@@ -39,45 +43,31 @@ describe('markdown sanitization', () => {
});
describe('Markdown images', () => {
test('keeps local image links in text and emits inert image placeholders', () => {
test('renders assistant images as icon-ready text without loading the source', () => {
const html = renderMarkdownSync([
'[linked image](packages/vscode/extension.jpg)',
'![image syntax](packages/vscode/extension.jpg)',
].join('\n\n'), true);
].join('\n\n'), 'label');
expect(html).toContain('data-openchamber-markdown-image-link="true"');
expect(html.match(/data-openchamber-markdown-image-source="packages\/vscode\/extension.jpg"/g)).toHaveLength(1);
expect(html).toContain('data-openchamber-markdown-image-placeholder="true"');
expect(html).toContain('image syntax');
expect(html).not.toContain('src="packages/vscode/extension.jpg"');
expect(html).not.toContain('data-openchamber-markdown-image-state');
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 HTTP links as links and defers remote image tokens to finalized rendering', () => {
test('keeps non-chat Markdown images inline', () => {
const html = renderMarkdownSync([
'[remote link](https://example.test/image.png)',
'![remote image](https://example.test/image.png)',
].join('\n\n'), true);
].join('\n\n'));
expect(html).toContain('<a href="https://example.test/image.png"');
expect(html).not.toContain('<img');
expect(html).toContain('data-openchamber-markdown-image-placeholder="true"');
expect(html).toContain('remote image');
expect(html).toContain('<img src="https://example.test/image.png" alt="remote image">');
expect(html).not.toContain('data-openchamber-markdown-image-label');
});
test('preserves file URLs inertly and never activates unknown schemes', () => {
const html = renderMarkdownSync([
'![file](file:///workspace/image.png)',
'![unsafe](javascript:alert(1))',
].join('\n\n'), true);
expect(html).toContain('role="img"');
expect(html).not.toContain('src="file:');
expect(html).not.toContain('src="javascript:');
});
test('collects a single ordered gallery across mixed Markdown and ignores code', () => {
test('collects image syntax across mixed Markdown and ignores links and code', () => {
const candidates = extractMarkdownImageCandidates([
[
'Before [local link](screens/first%20view.png) and `![code](ignored.png)`.',
@@ -99,6 +89,10 @@ describe('Markdown images', () => {
]);
});
test('does not add an ordinary local image link to the gallery', () => {
expect(extractMarkdownImageCandidates(['[download](screens/image.png)'])).toEqual([]);
});
test('limits one finalized message gallery to twelve unique candidates', () => {
const markdown = Array.from({ length: 14 }, (_, index) => `![image ${index}](screens/${index}.png)`).join('\n');
@@ -108,12 +102,69 @@ describe('Markdown images', () => {
expect(candidates.at(-1)?.source).toBe('screens/11.png');
});
test('reuses extracted candidates across virtualized remounts without changing gallery behavior', () => {
__markdownImageCandidateCacheForTests.reset();
const contents = Array.from({ length: 20 }, (_, index) => `![image ${index}](screens/${index}.png)`);
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
expect(__markdownImageCandidateCacheForTests.stats().scans).toBe(12);
for (let round = 0; round < 1000; round += 1) {
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
}
const stats = __markdownImageCandidateCacheForTests.stats();
expect(stats.entries).toBe(12);
expect(stats.scans).toBe(12);
});
test('scans one thousand independent messages once across virtualized remounts', () => {
__markdownImageCandidateCacheForTests.reset();
const messages = Array.from(
{ length: 1000 },
(_, index) => `![image ${index}](screens/${index}.png)`,
);
for (const message of messages) extractMarkdownImageCandidates([message]);
for (const message of messages) extractMarkdownImageCandidates([message]);
const stats = __markdownImageCandidateCacheForTests.stats();
expect(stats.entries).toBe(1000);
expect(stats.scans).toBe(1000);
});
test('gives embedded images without alt text a stable filename', () => {
const source = 'data:image/png;base64,AAAA';
expect(extractMarkdownImageCandidates([`![](${source})`])).toEqual([
{ source, filename: 'image.png' },
]);
expect(renderMarkdownSync(`![](${source})`, 'label')).toContain('image.png');
});
test('bounds cached candidate entries and bytes, and skips oversized individual content', () => {
__markdownImageCandidateCacheForTests.reset();
for (let index = 0; index < 1025; index += 1) {
extractMarkdownImageCandidates([`![image ${index}](screens/${index}.png)`]);
}
const boundedStats = __markdownImageCandidateCacheForTests.stats();
expect(boundedStats.entries).toBe(1024);
expect(boundedStats.bytes <= 2 * 1024 * 1024).toBe(true);
__markdownImageCandidateCacheForTests.reset();
const oversized = `![image](screens/large.png)\n${'x'.repeat(64 * 1024)}`;
extractMarkdownImageCandidates([oversized]);
extractMarkdownImageCandidates([oversized]);
expect(__markdownImageCandidateCacheForTests.stats()).toEqual({ entries: 0, bytes: 0, scans: 2 });
});
test('validates embedded image bytes against the declared MIME type', async () => {
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
const signal = new AbortController().signal;
expect(await resolveMarkdownImageSource(`data:image/png;base64,${png}`, '', signal)).toBe(`data:image/png;base64,${png}`);
await resolveMarkdownImageSource(`data:image/jpeg;base64,${png}`, '', signal).then(
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'),
);
@@ -123,7 +174,7 @@ describe('Markdown images', () => {
const controller = new AbortController();
controller.abort();
await resolveMarkdownImageSource('https://example.test/image.png', '', controller.signal).then(
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'),
);
@@ -133,6 +184,6 @@ describe('Markdown images', () => {
const html = renderMarkdownSync('![tool image](https://example.test/image.png)');
expect(html).toContain('<img src="https://example.test/image.png"');
expect(html).not.toContain('data-openchamber-markdown-image-placeholder');
expect(html).not.toContain('data-openchamber-markdown-image');
});
});
@@ -4,6 +4,7 @@ import katex from 'katex';
import DOMPurify from 'dompurify';
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
import { isVSCodeRuntime } from '@/lib/desktop';
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
import { highlightCodeInWorker } from './markdown-worker';
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
@@ -19,8 +20,23 @@ export interface MarkdownImageCandidate {
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)
@@ -34,8 +50,11 @@ const isSupportedMarkdownImageSource = (source: string): boolean => (
|| isLocalMarkdownImageSource(source)
);
export const getMarkdownImageFilename = (source: string, fallback: string): string => {
if (/^data:/i.test(source)) return fallback.trim();
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) ?? '';
@@ -47,6 +66,87 @@ export const getMarkdownImageFilename = (source: string, fallback: string): stri
}
};
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,
@@ -58,47 +158,17 @@ export const extractMarkdownImageCandidates = (
for (const markdown of markdownTexts) {
if (!markdown || candidates.length >= limit) continue;
const tokens = marked.lexer(markdown);
marked.walkTokens(tokens, (token) => {
if (candidates.length >= limit) return;
if (token.type !== 'image' && token.type !== 'link') return;
if (token.type === 'link' && !isLocalMarkdownImageSource(token.href ?? '')) 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 });
});
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;
};
const renderMarkdownImage = ({
href,
title,
text,
}: {
href: string;
title?: string | null;
text: string;
}): string => {
const source = href ?? '';
const alt = escapeAttr(text ?? '');
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
const supported = isSupportedMarkdownImageSource(source);
if (!supported) {
return `<span role="img" aria-label="${alt}"${titleAttr}>${alt}</span>`;
}
return `<span role="img" aria-label="${alt}"${titleAttr} data-openchamber-markdown-image-placeholder="true">${alt}</span>`;
};
// ---------------------------------------------------------------------------
// Streaming block segmentation (port of OpenCode's markdown-stream)
// ---------------------------------------------------------------------------
@@ -251,7 +321,7 @@ const blockMathExtension = {
},
};
const createParser = (deferImages: boolean) => new Marked().use({
const createParser = (imageMode: MarkdownImageMode) => new Marked().use({
gfm: true,
breaks: false,
extensions: [inlineMathExtension, blockMathExtension],
@@ -264,11 +334,6 @@ const createParser = (deferImages: boolean) => new Marked().use({
},
link({ href, title, text }) {
const target = href ?? '';
if (deferImages && isLocalMarkdownImageSource(target)) {
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
const filename = getMarkdownImageFilename(target, '');
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer" data-openchamber-markdown-image-link="true" data-openchamber-markdown-image-source="${escapeAttr(target)}" data-openchamber-markdown-image-filename="${escapeAttr(filename)}">${text}</a>`;
}
const agentName = parseAgentHref(target);
if (agentName) {
return `<a href="${escapeAttr(buildAgentMentionUrl(agentName))}" data-openchamber-agent-mention="true" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`;
@@ -280,12 +345,12 @@ const createParser = (deferImages: boolean) => new Marked().use({
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`;
},
...(deferImages ? { image: renderMarkdownImage } : {}),
...(imageMode === 'label' ? { image: renderMarkdownImageLabel } : {}),
},
});
const parser = createParser(false);
const imageParser = createParser(true);
const inlineImageParser = createParser('inline');
const imageLabelParser = createParser('label');
// ---------------------------------------------------------------------------
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
@@ -351,32 +416,37 @@ const highlightCodeBlocks = async (html: string): Promise<string> => {
const lineLimit = isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT;
let result = html;
for (const match of matches) {
const [full, rawLang, escapedCode] = match;
const requested = (rawLang || 'text').toLowerCase();
// Leave mermaid fences untouched so the decorate pass can render them as
// diagrams (highlighting would strip the `language-mermaid` class).
if (requested === 'mermaid') continue;
// Highlight all eligible fences concurrently — sequential await was O(n)
// worker round-trips for messages with multiple code blocks.
const replacements = await Promise.all(
matches.map(async (match) => {
const [full, rawLang, escapedCode] = match;
const requested = (rawLang || 'text').toLowerCase();
// Leave mermaid fences untouched so the decorate pass can render them as
// diagrams (highlighting would strip the `language-mermaid` class).
if (requested === 'mermaid') return null;
const code = unescapeHtml(escapedCode ?? '');
const code = unescapeHtml(escapedCode ?? '');
// Oversized block: skip highlight, keep plain code but stamp the language.
if (exceedsLineLimit(code, lineLimit)) {
result = result.replace(full, () => full.replace('<pre', `<pre data-md-lang="${requested}"`));
continue;
}
// Oversized block: skip highlight, keep plain code but stamp the language.
if (exceedsLineLimit(code, lineLimit)) {
return { full, next: full.replace('<pre', `<pre data-md-lang="${requested}"`) };
}
// Tokenize off the main thread. On failure the worker resolves to null and
// we keep the original escaped <pre><code> (no main-thread highlight).
const highlighted = await highlightCodeInWorker(code, requested);
if (highlighted) {
// Tokenize off the main thread. On failure the worker resolves to null and
// we keep the original escaped <pre><code> (no main-thread highlight).
const highlighted = await highlightCodeInWorker(code, requested);
if (!highlighted) return null;
// Stamp the language so the decorate pass can show a header label.
const stamped = highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`);
result = result.replace(full, () => stamped);
}
}
return { full, next: highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`) };
}),
);
let result = html;
for (const replacement of replacements) {
if (!replacement) continue;
result = result.replace(replacement.full, () => replacement.next);
}
return result;
};
@@ -419,32 +489,64 @@ const sanitize = (html: string): string => {
// ---------------------------------------------------------------------------
// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
// Per-block HTML cache (content-addressed LRU)
// ---------------------------------------------------------------------------
//
// Keyed by content hash + mode + highlight flag + image mode — NOT by renderer
// instance id. `SimpleMarkdownRenderer` historically used a shared
// `simple:${variant}` key, so every same-variant instance fought over one cache
// slot and re-highlighted unchanged content on every pass
// (openchamber/openchamber#2769). Content addressing makes identical blocks
// share one entry and stops that thrash. Bounds are high enough for long
// sessions; byte cap keeps memory bounded.
//
// `full` (settled) and `live` (trailing, still streaming) blocks get separate
// caches. A live block's content changes on every stream step, so under one
// shared content-addressed cache each step would insert a new entry and a long
// streaming message would evict the settled blocks this fix exists to keep
// warm. The live cache is small on purpose: it only has to absorb repeat
// renders of the *same* step.
const CACHE_MAX = 240;
const htmlCache = new Map<string, { hash: string; html: string }>();
const FULL_CACHE_MAX_ENTRIES = 2000;
const FULL_CACHE_MAX_BYTES = 24 * 1024 * 1024;
const LIVE_CACHE_MAX_ENTRIES = 32;
const LIVE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
// FNV-1a 32-bit hash of the block content.
const hash = (value: string): string => {
let h = 0x811c9dc5;
for (let i = 0; i < value.length; i += 1) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(36);
const fullBlockCache = new HighlightResultCache<string>({
maxEntries: FULL_CACHE_MAX_ENTRIES,
maxBytes: FULL_CACHE_MAX_BYTES,
});
const liveBlockCache = new HighlightResultCache<string>({
maxEntries: LIVE_CACHE_MAX_ENTRIES,
maxBytes: LIVE_CACHE_MAX_BYTES,
});
const cacheForMode = (mode: MarkdownBlock['mode']): HighlightResultCache<string> =>
(mode === 'live' ? liveBlockCache : fullBlockCache);
/** Content-addressed cache key for a markdown block. */
const markdownBlockCacheKey = (
contentHash: string,
mode: MarkdownBlock['mode'],
highlight: boolean,
imageMode: MarkdownImageMode,
): string => `${contentHash}:${mode}:${highlight ? 1 : 0}:${imageMode}`;
/** Test-only: clear the render HTML caches between cases. */
export const resetMarkdownHtmlCacheForTests = (): void => {
fullBlockCache.clear();
liveBlockCache.clear();
};
const touch = (key: string, entry: { hash: string; html: string }): void => {
htmlCache.delete(key);
htmlCache.set(key, entry);
if (htmlCache.size <= CACHE_MAX) return;
const oldest = htmlCache.keys().next().value;
if (oldest) htmlCache.delete(oldest);
};
/** Test-only: entry counts per block cache, for churn/eviction assertions. */
export const __markdownBlockCacheSizesForTests = (): { full: number; live: number } => ({
full: fullBlockCache.size,
live: liveBlockCache.size,
});
const parseBlock = async (block: MarkdownBlock, deferImages: boolean): Promise<string> => {
const parsed = await Promise.resolve((deferImages ? imageParser : parser).parse(block.src));
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;
return sanitize(highlighted);
@@ -459,9 +561,10 @@ const parseBlock = async (block: MarkdownBlock, deferImages: boolean): Promise<s
* is synchronous (marked is not configured `async`), so this never blocks on a
* worker round-trip.
*/
export const renderMarkdownSync = (text: string, deferImages = false): string => {
export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => {
if (!text) return '';
const parsed = (deferImages ? imageParser : parser).parse(text) as string;
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = parser.parse(text) as string;
const withMath = renderMathExpressions(parsed);
return sanitize(withMath);
};
@@ -479,28 +582,29 @@ export type RenderedBlock = {
* splits into blocks, caches per-block, heals incomplete syntax. Returning
* blocks (instead of one joined string) lets the renderer re-morph only the
* block that changed, keeping per-step streaming cost ~O(last block).
*
* Lookup is content-addressed: distinct renderers holding identical blocks
* share one entry and cannot evict each other by identity collision.
*/
export const renderMarkdownBlocks = async (
text: string,
streaming: boolean,
cacheKey: string,
deferImages = false,
imageMode: MarkdownImageMode = 'inline',
): Promise<RenderedBlock[]> => {
if (!text) return [];
const blocks = streamBlocks(text, streaming);
return Promise.all(
blocks.map(async (block, index) => {
const contentHash = hash(block.raw);
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}:${deferImages ? 1 : 0}`;
const key = `${cacheKey}:${index}:${block.mode}:${deferImages ? 1 : 0}`;
const cached = htmlCache.get(key);
if (cached && cached.hash === contentHash) {
touch(key, cached);
return { id, html: cached.html };
blocks.map(async (block) => {
const contentHash = contentFingerprint(block.raw);
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
const cache = cacheForMode(block.mode);
const cached = cache.get(id);
if (cached !== undefined) {
return { id, html: cached };
}
const html = await parseBlock(block, deferImages);
touch(key, { hash: contentHash, html });
const html = await parseBlock(block, imageMode);
cache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
return { id, html };
}),
);
@@ -0,0 +1,242 @@
/**
* Regression tests for https://github.com/openchamber/openchamber/issues/2769
*
* Sustained Shiki worker CPU came from re-tokenizing unchanged content:
* 1. `htmlCache` keyed by renderer identity (`simple:${variant}`) so
* same-variant instances evicted each other every pass.
* 2. LRU capped at 240 entries, so long sessions missed 100% on every pass.
* 3. Worker/client had no result memoization.
*
* These tests assert the fixed contracts: content-addressed caching, room for
* long sessions, bounded LRU behavior, and fingerprint-key helpers.
*/
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import {
contentFingerprint,
estimateTokenRunsBytes,
HighlightResultCache,
utf16Bytes,
} from './highlightResultCache';
let highlightCalls = 0;
let highlightInflight = 0;
let highlightMaxInflight = 0;
const highlightCodeInWorkerMock = mock(async (code: string, lang: string) => {
highlightCalls += 1;
highlightInflight += 1;
highlightMaxInflight = Math.max(highlightMaxInflight, highlightInflight);
await Promise.resolve();
highlightInflight -= 1;
return `<pre data-lang="${lang}"><code>${code}</code></pre>`;
});
mock.module('./markdown-worker', () => ({
highlightCodeInWorker: highlightCodeInWorkerMock,
highlightLinesInWorker: mock(async () => null),
highlightTokensInWorker: mock(async () => null),
resetMarkdownWorkerClientCacheForTests: mock(() => undefined),
}));
const {
renderMarkdownBlocks,
resetMarkdownHtmlCacheForTests,
__markdownBlockCacheSizesForTests,
} = await import('./markdownCore');
const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker');
beforeEach(() => {
resetMarkdownHtmlCacheForTests();
resetMarkdownWorkerClientCacheForTests();
highlightCalls = 0;
highlightInflight = 0;
highlightMaxInflight = 0;
});
describe('HighlightResultCache', () => {
test('returns cached values for identical keys and refreshes LRU order', () => {
const cache = new HighlightResultCache<string>({ maxEntries: 2, maxBytes: 10_000 });
cache.set('a', 'one', utf16Bytes('a') + utf16Bytes('one'));
cache.set('b', 'two', utf16Bytes('b') + utf16Bytes('two'));
expect(cache.get('a')).toBe('one');
// Touch `a` so `b` is oldest; inserting `c` should evict `b`.
cache.set('c', 'three', utf16Bytes('c') + utf16Bytes('three'));
expect(cache.get('b')).toEqual(undefined);
expect(cache.get('a')).toBe('one');
expect(cache.get('c')).toBe('three');
});
test('evicts by byte budget while still caching a single oversized entry', () => {
const cache = new HighlightResultCache<string>({ maxEntries: 10, maxBytes: 64 });
cache.set('small', 'x', utf16Bytes('small') + utf16Bytes('x'));
cache.set('huge', 'y'.repeat(200), utf16Bytes('huge') + utf16Bytes('y'.repeat(200)));
expect(cache.get('huge')).toBe('y'.repeat(200));
// Oversized insert cleared prior entries to make room.
expect(cache.size).toBe(1);
});
test('contentFingerprint is stable and length-qualified', () => {
expect(contentFingerprint('const x = 1')).toBe(contentFingerprint('const x = 1'));
expect(contentFingerprint('const x = 1')).not.toBe(contentFingerprint('const x = 2'));
expect(contentFingerprint('ab')).not.toBe(contentFingerprint('abc'));
});
test('contentFingerprint stays collision-free across a realistic session', () => {
// A collision here does not mis-color a block — it returns a *different*
// block's HTML, showing the user source they never wrote. Keep enough key
// space that a session-sized working set never collides.
const seen = new Map<string, string>();
for (let i = 0; i < 20_000; i += 1) {
// Same-length, near-identical sources are the realistic worst case:
// repeated tool output differing by a few characters.
const source = `const value_${String(i).padStart(6, '0')} = ${String(i).padStart(6, '0')};`;
const fingerprint = contentFingerprint(source);
expect(seen.get(fingerprint) ?? source).toBe(source);
seen.set(fingerprint, source);
}
expect(seen.size).toBe(20_000);
});
test('estimateTokenRunsBytes avoids JSON and stays positive', () => {
const lines: Array<Array<[number, string, number]>> = [
[[3, '#fff', 0], [1, '', 1]],
[[8, 'var(--md-syntax-keyword)', 0]],
];
expect(estimateTokenRunsBytes(lines)).toBeGreaterThan(0);
});
});
describe('markdownCore content-addressed htmlCache (#2769)', () => {
test('repeat renders of unchanged content never re-enter the worker', async () => {
const toolOutputA = '```ts\nconst a = 1;\n```';
const toolOutputB = '```ts\nconst b = 2;\n```';
// First pass: cold miss for each distinct block.
await renderMarkdownBlocks(toolOutputA, false);
await renderMarkdownBlocks(toolOutputB, false);
const coldCalls = highlightCalls;
expect(coldCalls).toBeGreaterThan(0);
// 100 more passes. Renderers used to pass a shared `simple:${variant}`
// identity key here and evict each other every pass; lookup is now
// content-addressed, so no additional worker calls may happen.
for (let pass = 0; pass < 100; pass += 1) {
await renderMarkdownBlocks(toolOutputA, false);
await renderMarkdownBlocks(toolOutputB, false);
}
expect(highlightCalls).toBe(coldCalls);
});
test('long sessions (working set > former 240 cap) stay warm across re-render passes', async () => {
const parts = Array.from({ length: 600 }, (_, i) => ({
content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``,
}));
for (const part of parts) {
await renderMarkdownBlocks(part.content, false);
}
const afterCold = highlightCalls;
expect(afterCold).toBe(parts.length);
for (let pass = 0; pass < 5; pass += 1) {
for (const part of parts) {
await renderMarkdownBlocks(part.content, false);
}
}
// Unchanged content must not re-enter the worker.
expect(highlightCalls).toBe(afterCold);
});
test('content changes invalidate only the changed block', async () => {
const stable = '```ts\nconst stable = true;\n```';
const changing = '```ts\nconst n = 1;\n```';
await renderMarkdownBlocks(stable, false);
await renderMarkdownBlocks(changing, false);
const afterFirst = highlightCalls;
await renderMarkdownBlocks(stable, false);
await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false);
expect(highlightCalls).toBe(afterFirst + 1);
await renderMarkdownBlocks(stable, false);
expect(highlightCalls).toBe(afterFirst + 1);
});
test('image mode is part of the cache identity, not shared across modes', async () => {
const source = '![diagram](https://example.com/a.png)';
const [inline] = await renderMarkdownBlocks(source, false, 'inline');
expect(__markdownBlockCacheSizesForTests().full).toBe(1);
// Same source, different rendering: content addressing must not let the
// first-rendered mode answer for both.
const [label] = await renderMarkdownBlocks(source, false, 'label');
expect(inline?.id).not.toBe(label?.id);
expect(__markdownBlockCacheSizesForTests().full).toBe(2);
// Re-rendering a mode already seen stays a cache hit.
const [inlineAgain] = await renderMarkdownBlocks(source, false, 'inline');
expect(inlineAgain?.id).toBe(inline?.id);
expect(__markdownBlockCacheSizesForTests().full).toBe(2);
});
test('streaming a message does not evict settled blocks (live cache is separate)', async () => {
const settled = Array.from(
{ length: 40 },
(_, i) => `\`\`\`ts\nconst settled_${i} = ${i};\n\`\`\``,
);
for (const block of settled) {
await renderMarkdownBlocks(block, false);
}
const settledEntries = __markdownBlockCacheSizesForTests().full;
expect(settledEntries).toBe(settled.length);
const afterSettled = highlightCalls;
// Stream a message: every step is new content for the trailing live block,
// so a single shared content-addressed cache would insert one entry per
// step and evict the settled working set this fix exists to keep warm.
let streamed = '';
for (let step = 0; step < 150; step += 1) {
streamed += `word_${step} `;
await renderMarkdownBlocks(streamed, true);
}
const sizes = __markdownBlockCacheSizesForTests();
expect(sizes.live).toBeLessThanOrEqual(32);
expect(sizes.full).toBe(settledEntries);
for (const block of settled) {
await renderMarkdownBlocks(block, false);
}
expect(highlightCalls).toBe(afterSettled);
});
test('a repeated streaming step is served from the live cache', async () => {
const step = 'partial answer text';
const [first] = await renderMarkdownBlocks(step, true);
const [second] = await renderMarkdownBlocks(step, true);
expect(second?.id).toBe(first?.id);
expect(__markdownBlockCacheSizesForTests()).toEqual({ full: 0, live: 1 });
});
test('multiple code fences in one document highlight concurrently', async () => {
const multi = [
'```ts\nconst a = 1;\n```',
'',
'```ts\nconst b = 2;\n```',
'',
'```ts\nconst c = 3;\n```',
].join('\n');
await renderMarkdownBlocks(multi, false);
expect(highlightCalls).toBe(3);
// Sequential awaits would keep max inflight at 1.
expect(highlightMaxInflight).toBeGreaterThan(1);
});
});
@@ -0,0 +1,119 @@
import { describe, expect, mock, test } from 'bun:test';
let requestCount = 0;
let requestPaths: string[] = [];
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
'base64',
);
const runtimeFetch = mock(async (path: string, init?: RequestInit & { query?: Record<string, unknown> }) => {
requestPaths.push(path);
if (path === '/api/fs/stat') {
return new Response(JSON.stringify({ isFile: true, size: PNG.byteLength }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (path === '/api/fs/raw') {
return new Response(PNG, { status: 200, headers: { 'content-type': 'image/png' } });
}
requestCount += 1;
const body = JSON.parse(String(init?.body)) as { sources: string[] };
return new Response(JSON.stringify({
results: body.sources.map((source) => ({ source, status: 'ready', path: `/repo/${source}` })),
}), { status: 200, headers: { 'content-type': 'application/json' } });
});
const resolver = {
api: () => '',
authenticatedAsset: (path: string, query: Record<string, string | undefined>) => {
const params = new URLSearchParams(Object.entries(query).filter((entry): entry is [string, string] => Boolean(entry[1])));
return `${path}?${params}`;
},
};
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch }));
mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => resolver }));
class TestFileReader {
result: string | ArrayBuffer | null = null;
error: DOMException | null = null;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
readAsDataURL(blob: Blob) {
void blob.arrayBuffer().then((buffer) => {
this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}`;
this.onload?.();
}).catch((error) => {
this.error = error as DOMException;
this.onerror?.();
});
}
}
globalThis.FileReader = TestFileReader as unknown as typeof FileReader;
const {
getPreparedMarkdownImageUrl,
prepareLocalMarkdownImages,
resolveWorkspaceMarkdownImageSource,
} = await import('./markdownImageAssets');
describe('Markdown image asset preparation', () => {
test('prepares many images in one message-level request', async () => {
requestCount = 0;
const sources = Array.from({ length: 12 }, (_, index) => `${index}.png`);
const result = await prepareLocalMarkdownImages({
sources,
directory: '/repo',
sessionId: 'ses_batch',
messageId: 'msg_batch',
signal: new AbortController().signal,
});
expect(result.size).toBe(12);
expect(requestCount).toBe(1);
});
test('reuses preparation for one thousand messages after virtualized remounts', async () => {
requestCount = 0;
const requests = Array.from({ length: 1000 }, (_, index) => ({
sources: [`${index}.png`],
directory: '/repo',
sessionId: 'ses_long',
messageId: `msg_${index}`,
signal: new AbortController().signal,
}));
for (const request of requests) await prepareLocalMarkdownImages(request);
for (const request of requests) await prepareLocalMarkdownImages(request);
expect(requestCount).toBe(1000);
});
test('reuses the existing authenticated raw-file asset URL', () => {
const url = getPreparedMarkdownImageUrl({
status: 'ready',
path: '/tmp/opencode/image.png',
outsideFileGrant: 'grant-1',
}, '/repo');
expect(url).toContain('/api/fs/raw?');
expect(url).toContain('path=%2Ftmp%2Fopencode%2Fimage.png');
expect(url).toContain('outsideFileGrant=grant-1');
});
test('loads a workspace image through the local filesystem bridge', async () => {
requestPaths = [];
const url = await resolveWorkspaceMarkdownImageSource(
'screens/image.png',
'/repo',
new AbortController().signal,
);
expect(url.startsWith('data:image/png;base64,')).toBe(true);
expect(requestPaths).toEqual(['/api/fs/stat', '/api/fs/raw']);
});
});
@@ -1,7 +1,10 @@
import { isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
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',
@@ -9,6 +12,21 @@ const SUPPORTED_IMAGE_MIME_TYPES = new Set([
'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)) {
@@ -34,9 +52,13 @@ const parseLocalImagePath = (source: string): string => {
const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => typeof reader.result === 'string'
? resolve(reader.result)
: reject(new Error('Unable to encode image'));
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);
});
@@ -44,18 +66,21 @@ const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, rej
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));
if (mimeType === 'image/png') {
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG'
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
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;
}
if (mimeType === 'image/jpeg') {
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
}
if (mimeType === 'image/gif') {
const gif = ascii(0, 6);
return gif === 'GIF87a' || gif === 'GIF89a';
}
return mimeType === 'image/webp' && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
};
const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> => {
@@ -67,36 +92,125 @@ const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> =>
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');
const encoded = match[2];
if (encoded.length > Math.ceil(MAX_MARKDOWN_IMAGE_BYTES * 4 / 3) + 4) {
throw new Error('Image is too large');
}
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(encoded);
binary = atob(match[2]);
} catch {
throw new Error('Invalid image data URL');
}
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
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> => {
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
if (/^(?:https?:)?\/\//i.test(source)) return source;
if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)) {
await validateDataImage(source);
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
return source;
}
throwIfAborted(signal);
const localPath = parseLocalImagePath(source);
const absolutePath = toAbsoluteFilePath(directory, localPath);
if (!directory || !localPath || !isFilePathWithinDirectory(absolutePath, directory)) {
@@ -128,5 +242,19 @@ export const resolveMarkdownImageSource = async (
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,
},
);
@@ -41,7 +41,7 @@ import { ToolRevealOnMount } from './parts/ToolRevealOnMount';
import { StaticToolRow } from './parts/ProgressiveGroup';
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
import TurnActivity from '../components/TurnActivity';
import { createProjectPlanFile } from '@/lib/openchamberConfig';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useI18n } from '@/lib/i18n';
@@ -1509,7 +1509,7 @@ const AssistantMessageBody = React.memo(({
setIsSavingPlan(true);
try {
const created = await createProjectPlanFile(currentProjectRef, {
const created = await useProjectContextStore.getState().createPlan(currentProjectRef, {
title,
body: assistantPlanText,
});
@@ -1517,9 +1517,6 @@ const AssistantMessageBody = React.memo(({
toast.error(t('chat.messageBody.toast.savePlanFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
detail: { projectId: currentProjectRef.id },
}));
setIsPlanDialogOpen(false);
toast.success(t('chat.messageBody.toast.planSaved'));
} finally {
@@ -1876,7 +1873,6 @@ const AssistantMessageBody = React.memo(({
chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
enableMarkdownImages={isMessageCompleted}
/>
</div>
);
@@ -2031,7 +2027,6 @@ const AssistantMessageBody = React.memo(({
collapsedPreviewCount,
expandedTools,
isMobile,
isMessageCompleted,
isActivityOwnerMessage,
isSortedRenderMode,
lastRenderableTextPartIndex,
@@ -2244,6 +2239,8 @@ const AssistantMessageBody = React.memo(({
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
<MarkdownImageGallery
sessionId={sessionId}
messageId={messageId}
contents={finalizedAssistantMarkdownContents}
onShowPopup={onShowPopup}
/>
@@ -9,7 +9,8 @@ import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { summarizeSelectionForNotes } from '@/lib/smallModel';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
@@ -34,15 +35,9 @@ interface SelectionPayload {
rect: DOMRect;
}
const appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => {
const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '').slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH);
if (!trimmedInsight) {
return existingNotes;
}
const trimmedNotes = existingNotes.trimEnd();
return trimmedNotes ? `${trimmedNotes}\n${trimmedInsight}` : trimmedInsight;
};
const normalizeDistilledInsight = (insight: string): string => (
insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH)
);
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
@@ -366,19 +361,22 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
// Long selections are distilled into a compact note by the small model;
// short ones (and any generation failure) go in verbatim.
const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId);
const projectData = await getProjectNotesAndTodos(currentProjectRef);
const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText);
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
notes: nextNotes,
todos: projectData.todos,
const insight = normalizeDistilledInsight(noteText);
if (!insight) {
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
return;
}
// Recorded as its own note with provenance, so the distilled insight can
// later be traced back to the conversation it came from.
const saved = await useProjectContextStore.getState().createNote(currentProjectRef, {
body: insight,
source: 'selection',
...(currentSessionId ? { origin: { sessionId: currentSessionId } } : {}),
});
if (!saved) {
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', {
detail: { projectId: currentProjectRef.id },
}));
toast.success(t('chat.textSelection.toast.addToNotesSuccess'));
hideMenu();
window.getSelection()?.removeAllRanges();
@@ -19,7 +19,6 @@ interface AssistantTextPartProps {
chatRenderMode?: 'sorted' | 'live';
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
onShowPopup?: (content: ToolPopupContent) => void;
enableMarkdownImages?: boolean;
}
const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
@@ -28,7 +27,6 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
streamPhase,
chatRenderMode = 'live',
onShowPopup,
enableMarkdownImages = false,
}) => {
// Use part directly from props — parent provides the latest version from the store.
// No store subscription here to avoid re-render cascade from unrelated delta events.
@@ -103,7 +101,6 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
disableStreamAnimation={chatRenderMode === 'sorted'}
variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'}
enableFileReferences={isFinalized}
enableLocalImages={enableMarkdownImages && !isStreaming && part.type === 'text'}
onShowPopup={onShowPopup}
/>
</div>
@@ -55,23 +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 collects HTTP(S), embedded, and workspace-local
- 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, validated as PNG/JPEG/GIF/WebP, and local paths are
fetched through the active runtime before conversion to data URLs. Local
Markdown links whose target has one of
those image suffixes stay links in the text and open the same existing
full-screen image preview as the gallery; image syntax does not insert a
large inline image. A
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.
Thumbnail assets begin loading only when their gallery items approach the
viewport, so mounted historical messages do not eagerly read every image.
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.
@@ -1259,6 +1259,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 === 'openchamber_memory'
|| part.tool === 'apply_patch'
|| part.tool === 'edit'
|| part.tool === 'multiedit';
@@ -59,6 +59,9 @@ export const getToolIcon = (toolName: string) => {
if (tool === 'openchamber_web') {
return <Icon name="global" className={iconClass} />;
}
if (tool === 'openchamber_memory') {
return <Icon name="brain-4" className={iconClass} />;
}
if (tool === 'question') {
return <Icon name="survey" className={iconClass} />;
}
@@ -89,7 +89,7 @@ which requests only providers enabled for this panel.
| Context + cost | `contextUsage.ts` over `useSessionMessages`, `Session.cost` | see below — the store getters cannot serve this |
| Branch, ahead/behind, attention | `useGitStore` directory state | warmed via `runBackgroundNetworkTask(ensureStatus)` and refreshed from Git mutation hints |
| Changed files | `useGitStore` status `files` + `diffStats` | working tree, not session-authored edits |
| PR + checks | `usePrVisualSummary` | **read-only** |
| PR + checks | `useFreshestPrVisualSummaryForBranch` | **read-only**; follows the freshest remote-keyed entry for the branch |
| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses` | |
| Subagent blockers | directory `permission` / `question` maps | one subscription covers every child |
| Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not |
@@ -145,6 +145,10 @@ The panel never calls `startWatching`. PR watching is owned by the background
tracker, and its concurrency gate exists because per-consumer PR fetches once
saturated the browser's connection pool and stalled startup for ~20s. A panel
that started a watch per open session would reintroduce exactly that fan-out.
The PR surface can watch a concrete remote while passive readers initially know
only the automatic remote key, so the panel reads the freshest entry for the
directory and branch across remote keys. This keeps its PR and checks rows in
sync with the live PR surface without adding another request owner.
### Changed files come from git status, not the session
@@ -5,8 +5,16 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { useSession } from '@/sync/sync-context';
import { getLinkedIssues } from '@/lib/linkedIssues';
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { resolveProjectContextId } from '@/lib/projectContextApi';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
type Props = {
sessionId: string | null;
@@ -26,6 +34,11 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
const { t } = useI18n();
const session = useSession(sessionId ?? '', directory ?? undefined);
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const projects = useProjectsStore((state) => state.projects);
const isDraft = sessionId === null && newSessionDraft.open;
const skills = useSkillsStore((state) => state.skills);
const mcpStatus = useMcpStore(
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
@@ -43,6 +56,87 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
void loadSkills();
}, [directory, loadSkills]);
/**
* What this session carries. Read from the server
* rather than from the notes panel's store, because this must be right
* whether or not that panel has ever been opened.
*/
const [knowledge, setKnowledge] = React.useState<SessionKnowledgeSummary>(
{ notes: [], plans: [], memory: { global: 0, project: 0 } },
);
// Re-read when source content or memory changes, not only when the session does.
const contextEntries = useProjectContextStore((state) => state.entries);
const loadProjectContext = useProjectContextStore((state) => state.load);
const memoryProject = useAgentMemoryStore((state) => state.project);
const memoryGlobal = useAgentMemoryStore((state) => state.global);
const draftProject = React.useMemo(() => {
if (!isDraft) return null;
const selected = newSessionDraft.selectedProjectId
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
: null;
return selected ?? resolveProjectForSessionDirectory(
projects,
availableWorktreesByProject,
newSessionDraft.directoryOverride ?? directory,
);
}, [availableWorktreesByProject, directory, isDraft, newSessionDraft.directoryOverride, newSessionDraft.selectedProjectId, projects]);
const draftContextEntry = draftProject
? contextEntries[resolveProjectContextId({ id: draftProject.id, path: draftProject.path })]
: undefined;
React.useEffect(() => {
if (!isDraft || !draftProject) return;
void loadProjectContext({ id: draftProject.id, path: draftProject.path });
}, [draftProject, isDraft, loadProjectContext]);
React.useEffect(() => {
let cancelled = false;
void fetchSessionKnowledgeSummary(directory, sessionId).then((summary) => {
if (!cancelled) setKnowledge(summary);
});
return () => { cancelled = true; };
}, [directory, sessionId, session, contextEntries, memoryProject, memoryGlobal]);
const visibleKnowledge = React.useMemo<SessionKnowledgeSummary>(() => {
if (!isDraft) return knowledge;
const pinned = resolveDraftPinnedKnowledge(
draftContextEntry?.notes ?? [],
draftContextEntry?.plans ?? [],
newSessionDraft.projectContextPins ?? { notes: [], plans: [] },
);
return { ...knowledge, ...pinned };
}, [draftContextEntry?.notes, draftContextEntry?.plans, isDraft, knowledge, newSessionDraft.projectContextPins]);
// Unpinning from here, like the pinned-messages section: a panel that says
// what is attached should be able to detach it, or the user has to go find
// the surface that can.
const unpinNote = React.useCallback((noteId: string) => {
if (isDraft) {
setDraftProjectContextPin('note', noteId, false);
return;
}
if (!directory || !sessionId) return;
void setSessionProjectContextPin(directory, sessionId, 'note', noteId, false).then((pins) => {
if (pins) setKnowledge((current) => ({ ...current, notes: current.notes.filter((note) => note.id !== noteId) }));
});
}, [directory, isDraft, sessionId, setDraftProjectContextPin]);
const unpinPlan = React.useCallback((planId: string) => {
if (isDraft) {
setDraftProjectContextPin('plan', planId, false);
return;
}
if (!directory || !sessionId) return;
void setSessionProjectContextPin(directory, sessionId, 'plan', planId, false).then((pins) => {
if (pins) setKnowledge((current) => ({ ...current, plans: current.plans.filter((plan) => plan.id !== planId) }));
});
}, [directory, isDraft, sessionId, setDraftProjectContextPin]);
const memoryCount = visibleKnowledge.memory.global + visibleKnowledge.memory.project;
const pinnedCount = visibleKnowledge.notes.length + visibleKnowledge.plans.length;
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
// Connected servers only. A disabled server contributes nothing to the
// context, so counting it here contradicts the MCP section right above,
@@ -52,9 +146,14 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
[mcpStatus],
);
useReportWorkStatusPresence('context-sources', linked.length > 0 || skills.length > 0 || mcpCount > 0);
useReportWorkStatusPresence(
'context-sources',
linked.length > 0 || skills.length > 0 || mcpCount > 0 || pinnedCount > 0 || memoryCount > 0,
);
if (linked.length === 0 && skills.length === 0 && mcpCount === 0) return null;
if (linked.length === 0 && skills.length === 0 && mcpCount === 0 && pinnedCount === 0 && memoryCount === 0) {
return null;
}
// The heading names what is distinctive about this session when there is
// something — an attached thread — and falls back to the ambient counts
@@ -72,6 +171,12 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
? t('chat.workStatus.breakdown.prCountSingle', { count: prCount })
: t('chat.workStatus.breakdown.prCountPlural', { count: prCount }));
}
// Pinned knowledge outranks ambient counts because the user chose it for this session.
if (summaryParts.length === 0 && pinnedCount > 0) {
summaryParts.push(pinnedCount === 1
? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount })
: t('chat.workStatus.breakdown.pinnedKnowledgePlural', { count: pinnedCount }));
}
if (summaryParts.length === 0) {
if (skills.length > 0) {
summaryParts.push(skills.length === 1
@@ -115,6 +220,62 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
/>
))}
{/* Named individually: a count alone would not identify this session's context. */}
{/* The pin is the control, exactly as in the pinned-messages section
above: same icon, same placement, same behaviour. Two pins that look
different in one panel would read as two different things. */}
{visibleKnowledge.notes.map((note) => (
<WorkStatusRow
key={note.id}
muted
leading={(
<button
type="button"
disabled={!isDraft && (!sessionId || !directory)}
aria-label={t('chat.workStatus.breakdown.unpin')}
onClick={(event) => {
event.stopPropagation();
unpinNote(note.id);
}}
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40"
>
<Icon name="pushpin-2-fill" className="size-3.5" style={{ color: 'var(--primary)' }} />
</button>
)}
label={note.body.trim().split('\n')[0] || note.body.trim()}
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedNote')}</WorkStatusValue>}
/>
))}
{visibleKnowledge.plans.map((plan) => (
<WorkStatusRow
key={plan.id}
muted
leading={(
<button
type="button"
disabled={!isDraft && (!sessionId || !directory)}
aria-label={t('chat.workStatus.breakdown.unpin')}
onClick={(event) => {
event.stopPropagation();
unpinPlan(plan.id);
}}
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40"
>
<Icon name="pushpin-2-fill" className="size-3.5" style={{ color: 'var(--primary)' }} />
</button>
)}
label={plan.title}
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedPlan')}</WorkStatusValue>}
/>
))}
{memoryCount > 0 ? (
<WorkStatusRow
muted
label={t('chat.workStatus.breakdown.memory')}
value={<WorkStatusValue>{memoryCount}</WorkStatusValue>}
/>
) : null}
<WorkStatusRow
muted
label={t('chat.workStatus.breakdown.skills')}
@@ -97,6 +97,7 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
>
{mcpServers.map(([name, entry]) => {
const connected = entry?.status === 'connected';
const busy = busyServer === name;
const needsAuth = entry?.status === 'needs_auth' || entry?.status === 'needs_client_registration';
const failed = entry?.status === 'failed';
return (
@@ -105,8 +106,9 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
leading={(
<Switch
checked={connected}
disabled={busyServer === name}
className="scale-75 data-[checked]:bg-status-info"
disabled={busy}
loading={busy}
className="scale-75 disabled:opacity-100 data-[checked]:bg-status-info"
aria-label={t('chat.workStatus.mcp.toggle', { name })}
onCheckedChange={(checked) => { void handleToggle(name, checked); }}
/>
@@ -118,7 +120,7 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
value={needsAuth ? (
<WorkStatusRowAction
tone="warning"
disabled={busyServer === name}
disabled={busy}
onClick={() => { void handleAuthorize(name); }}
>
{t('chat.workStatus.mcp.needsAuth')}
@@ -126,7 +128,7 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
) : failed ? (
<WorkStatusRowAction
tone="error"
disabled={busyServer === name}
disabled={busy}
onClick={() => { void handleToggle(name, true); }}
>
{t('chat.workStatus.mcp.failed')}
@@ -3,7 +3,7 @@ import { useI18n } from '@/lib/i18n';
import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore';
import { useSession, useSessionMessages } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -107,11 +107,7 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
// Read-only: PR watching is owned by the background tracker. Starting a watch
// here would multiply GitHub requests per open session, which is exactly the
// fan-out the PR-status concurrency gate exists to prevent.
const prKey = React.useMemo(
() => (directory && branch ? getGitHubPrStatusKey(directory, branch) : null),
[directory, branch],
);
const prSummary = usePrVisualSummary(prKey);
const prSummary = useFreshestPrVisualSummaryForBranch(directory, branch);
// `getCurrentModel` is an imperative getter: its reference never changes, so
// calling it in render subscribes to nothing. Subscribe to the selected model
@@ -25,7 +25,7 @@ const SECTION_CLASS = cn(
'[&:not(:first-child)]:border-[var(--interactive-border)] [&:not(:first-child)]:pt-3',
);
const HEADING_CLASS = 'text-xs font-normal text-muted-foreground';
const HEADING_CLASS = 'text-xs font-semibold text-foreground';
export const WorkStatusSection: React.FC<{
title: string;
@@ -158,7 +158,10 @@ export const WorkStatusRow: React.FC<RowProps> = ({
</>
);
const shared = cn('flex h-7 w-full items-center gap-2 rounded-md px-1 text-left', className);
const shared = cn(
'flex h-7 w-full items-center gap-2 rounded-md px-1 text-left text-muted-foreground',
className,
);
if (!onClick) return <div className={shared}>{body}</div>;
@@ -124,8 +124,7 @@ export const WorkStatusUsageSection: React.FC = () => {
<React.Fragment key={group.providerId}>
<WorkStatusRow
leading={<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />}
label={group.providerName}
muted
label={<span className="font-semibold text-foreground">{group.providerName}</span>}
value={group.status && group.rows.length === 0 ? (
<WorkStatusValue tone="muted">{group.status}</WorkStatusValue>
) : undefined}
@@ -14,8 +14,8 @@ describe('computeContextUsage', () => {
});
test('reports the latest turn rather than a sum across turns', () => {
// Each assistant turn reports the whole window it saw, so adding them up
// would report several times the real fill.
// A turn's tokens describe that turn's window, so adding turns up would
// report several times the real fill.
const usage = computeContextUsage(
[
assistant({ input: 400, output: 0, reasoning: 0 }, 'old'),
@@ -61,4 +61,24 @@ describe('computeContextUsage', () => {
const usage = computeContextUsage([assistant({ input: 10 })], 100);
expect(usage?.totalTokens).toBe(10);
});
test('prefers the server-reported total over summing round-trip fields', () => {
// Real payload from opencode 1.18.18: ~14 tool-call round-trips accumulated
// cache.read to 3.29M while the 1M window really held 232,872. Summing
// rendered 330.6%; the reported total renders the real 23.3%.
const usage = computeContextUsage(
[assistant({ total: 232_872, input: 0, output: 14_523, reasoning: 0, cache: { read: 3_291_956, write: 0 } })],
1_000_000,
);
expect(usage?.totalTokens).toBe(232_872);
expect(usage?.percent.toFixed(4)).toBe('23.2872');
});
test('selects a message whose only signal is the reported total', () => {
const usage = computeContextUsage(
[assistant({ total: 5_000, input: 0, output: 0, reasoning: 0 })],
100_000,
);
expect(usage?.totalTokens).toBe(5_000);
});
});
@@ -13,7 +13,11 @@
* global read to race with.
*/
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
type MessageTokens = {
/** Server-reported window of the turn's final round-trip; absent on older servers. */
total?: number;
input?: number;
output?: number;
reasoning?: number;
@@ -37,18 +41,12 @@ type WorkStatusContextUsage = {
/** The store's own fallback when a model exposes no context limit. */
export const DEFAULT_CONTEXT_LIMIT = 200_000;
const sumTokens = (tokens: MessageTokens): number => (
(tokens.input ?? 0)
+ (tokens.output ?? 0)
+ (tokens.reasoning ?? 0)
+ (tokens.cache?.read ?? 0)
+ (tokens.cache?.write ?? 0)
);
/**
* Usage from the newest assistant message that reported a non-zero token count.
* Each assistant turn reports the whole window it saw, so the latest one is the
* current fill not a sum across turns.
* The latest turn describes the current fill not a sum across turns. Within
* a turn, the server-reported `total` is the final round-trip's window;
* summing the breakdown fields instead overstates multi-step turns, whose
* input/cache fields accumulate across round-trips.
*/
export const computeContextUsage = (
messages: readonly MessageLike[],
@@ -60,7 +58,7 @@ export const computeContextUsage = (
const message = messages[index];
if (message?.role !== 'assistant' || !message.tokens) continue;
const totalTokens = sumTokens(message.tokens);
const totalTokens = contextTokensFromBreakdown(message.tokens);
if (totalTokens <= 0) continue;
const limit = contextLimit > 0 ? contextLimit : DEFAULT_CONTEXT_LIMIT;
@@ -0,0 +1,24 @@
import { describe, expect, test } from 'bun:test';
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
describe('resolveDraftPinnedKnowledge', () => {
test('shows only notes and plans pinned on this draft', () => {
expect(resolveDraftPinnedKnowledge(
[{ id: 'note-a', body: 'Attached' }, { id: 'note-b', body: 'Not attached' }],
[{ id: 'plan-a', title: 'Attached plan' }, { id: 'plan-b', title: 'Other plan' }],
{ notes: ['note-a'], plans: ['plan-a'] },
)).toEqual({
notes: [{ id: 'note-a', body: 'Attached' }],
plans: [{ id: 'plan-a', title: 'Attached plan' }],
});
});
test('drops stale ids without borrowing project-wide pins', () => {
expect(resolveDraftPinnedKnowledge(
[{ id: 'note-a', body: 'Project note' }],
[{ id: 'plan-a', title: 'Project plan' }],
{ notes: ['missing'], plans: [] },
)).toEqual({ notes: [], plans: [] });
});
});
@@ -0,0 +1,17 @@
import type { SessionKnowledgeSummary, SessionProjectContextPins } from '@/lib/sessionKnowledgeApi';
type NoteSource = { id: string; body: string };
type PlanSource = { id: string; title: string };
export const resolveDraftPinnedKnowledge = (
notes: NoteSource[],
plans: PlanSource[],
pins: SessionProjectContextPins,
): Pick<SessionKnowledgeSummary, 'notes' | 'plans'> => {
const noteIds = new Set(pins.notes);
const planIds = new Set(pins.plans);
return {
notes: notes.filter((note) => noteIds.has(note.id)).map(({ id, body }) => ({ id, body })),
plans: plans.filter((plan) => planIds.has(plan.id)).map(({ id, title }) => ({ id, title })),
};
};
@@ -36,6 +36,10 @@ describe('resolveQuotaProviderId', () => {
expect(resolveQuotaProviderId('anthropic')).toBe('claude');
});
test('maps the opencode-claude integration provider onto Claude quota', () => {
expect(resolveQuotaProviderId('claude-code')).toBe('claude');
});
test('is case and whitespace tolerant, and rejects empties', () => {
expect(resolveQuotaProviderId(' OpenAI ')).toBe('codex');
expect(resolveQuotaProviderId('')).toBeNull();
@@ -13,11 +13,15 @@ import type { UsageProviderGroup, UsageLimitRow } from '@/components/usage/usage
/**
* Quota provider ids mostly match OpenCode provider ids; these are the ones
* that do not. Unmatched providers simply produce no headline.
*
* `claude-code` is the provider the opencode-claude integration registers, and
* it bills against the same Claude subscription the `claude` quota reports.
*/
const QUOTA_PROVIDER_ALIASES = new Map<string, string>([
['openai', 'codex'],
['chatgpt', 'codex'],
['anthropic', 'claude'],
['claude-code', 'claude'],
['gemini', 'google'],
]);
+24 -1
View File
@@ -30,6 +30,29 @@ function ensureSpriteOnce() {
spriteInjected = true
}
/**
* Append a single missing symbol. Needed when the sprite was injected before a
* newly generated icon landed (HMR / late sprite regenerate) a one-shot inject
* would otherwise leave `<use href="#oc-…"/>` pointing at nothing.
*/
function ensureSpriteSymbol(name: IconName) {
if (typeof document === "undefined") return
ensureSpriteOnce()
if (document.getElementById(`oc-${name}`)) return
const content = iconSpriteData[name]
if (typeof content !== "string") return
const sprite = document.getElementById(SPRITE_ID)
if (!sprite) return
const symbol = document.createElementNS("http://www.w3.org/2000/svg", "symbol")
symbol.id = `oc-${name}`
symbol.setAttribute("viewBox", "0 0 24 24")
symbol.innerHTML = content
sprite.appendChild(symbol)
}
export interface IconProps extends React.ComponentPropsWithoutRef<"svg"> {
name: IconName
}
@@ -38,7 +61,7 @@ export const Icon = React.memo(({ name, className, ...rest }: IconProps) => {
// Inline sprite injection during render must run before <use> tries
// to resolve the #oc-* reference during the same commit.
if (typeof document !== "undefined") {
ensureSpriteOnce()
ensureSpriteSymbol(name)
}
return (
+7 -1
View File
@@ -28,10 +28,12 @@ export const iconSpriteData = {
"bar-chart-2": `<path d="M2 13H8V21H2V13ZM16 8H22V21H16V8ZM9 3H15V21H9V3ZM4 15V19H6V15H4ZM11 5V19H13V5H11ZM18 10V19H20V10H18Z" fill="currentColor"/>`,
"bar-chart-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM7 13H9V17H7V13ZM11 7H13V17H11V7ZM15 10H17V17H15V10Z" fill="currentColor"/>`,
"book": `<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM5 15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H6C5.44772 4 5 4.44772 5 5V15.3368Z" fill="currentColor"/>`,
"book-marked": `<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z" fill="currentColor"/>`,
"book-open": `<path d="M13 21V23H11V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H9C10.1947 3 11.2671 3.52375 12 4.35418C12.7329 3.52375 13.8053 3 15 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H13ZM20 19V5H15C13.8954 5 13 5.89543 13 7V19H20ZM11 19V7C11 5.89543 10.1046 5 9 5H4V19H11Z" fill="currentColor"/>`,
"booklet": `<path d="M20.0049 2C21.1068 2 22 2.89821 22 3.9908V20.0092C22 21.1087 21.1074 22 20.0049 22H4V18H2V16H4V13H2V11H4V8H2V6H4V2H20.0049ZM8 4H6V20H8V4ZM20 4H10V20H20V4Z" fill="currentColor"/>`,
"braces": `<path d="M4 18V14.3C4 13.4716 3.32843 12.8 2.5 12.8H2V11.2H2.5C3.32843 11.2 4 10.5284 4 9.7V6C4 4.34315 5.34315 3 7 3H8V5H7C6.44772 5 6 5.44772 6 6V10.1C6 10.9858 5.42408 11.7372 4.62623 12C5.42408 12.2628 6 13.0142 6 13.9V18C6 18.5523 6.44772 19 7 19H8V21H7C5.34315 21 4 19.6569 4 18ZM20 14.3V18C20 19.6569 18.6569 21 17 21H16V19H17C17.5523 19 18 18.5523 18 18V13.9C18 13.0142 18.5759 12.2628 19.3738 12C18.5759 11.7372 18 10.9858 18 10.1V6C18 5.44772 17.5523 5 17 5H16V3H17C18.6569 3 20 4.34315 20 6V9.7C20 10.5284 20.6716 11.2 21.5 11.2H22V12.8H21.5C20.6716 12.8 20 13.4716 20 14.3Z" fill="currentColor"/>`,
"brain": `<path d="M9 4C10.1046 4 11 4.89543 11 6V12.8271C10.1058 12.1373 8.96602 11.7305 7.6644 11.5136L7.3356 13.4864C8.71622 13.7165 9.59743 14.1528 10.1402 14.7408C10.67 15.3147 11 16.167 11 17.5C11 18.8807 9.88071 20 8.5 20C7.11929 20 6 18.8807 6 17.5V17.1493C6.43007 17.2926 6.87634 17.4099 7.3356 17.4864L7.6644 15.5136C6.92149 15.3898 6.1752 15.1144 5.42909 14.7599C4.58157 14.3573 4 13.499 4 12.5C4 11.6653 4.20761 11.0085 4.55874 10.5257C4.90441 10.0504 5.4419 9.6703 6.24254 9.47014L7 9.28078V6C7 4.89543 7.89543 4 9 4ZM12 3.35418C11.2671 2.52376 10.1947 2 9 2C6.79086 2 5 3.79086 5 6V7.77422C4.14895 8.11644 3.45143 8.64785 2.94126 9.34933C2.29239 10.2415 2 11.3347 2 12.5C2 14.0652 2.79565 15.4367 4 16.2422V17.5C4 19.9853 6.01472 22 8.5 22C9.91363 22 11.175 21.3482 12 20.3287C12.825 21.3482 14.0864 22 15.5 22C17.9853 22 20 19.9853 20 17.5V16.2422C21.2044 15.4367 22 14.0652 22 12.5C22 11.3347 21.7076 10.2415 21.0587 9.34933C20.5486 8.64785 19.8511 8.11644 19 7.77422V6C19 3.79086 17.2091 2 15 2C13.8053 2 12.7329 2.52376 12 3.35418ZM18 17.1493V17.5C18 18.8807 16.8807 20 15.5 20C14.1193 20 13 18.8807 13 17.5C13 16.167 13.33 15.3147 13.8598 14.7408C14.4026 14.1528 15.2838 13.7165 16.6644 13.4864L16.3356 11.5136C15.034 11.7305 13.8942 12.1373 13 12.8271V6C13 4.89543 13.8954 4 15 4C16.1046 4 17 4.89543 17 6V9.28078L17.7575 9.47014C18.5581 9.6703 19.0956 10.0504 19.4413 10.5257C19.7924 11.0085 20 11.6653 20 12.5C20 13.499 19.4184 14.3573 18.5709 14.7599C17.8248 15.1144 17.0785 15.3898 16.3356 15.5136L16.6644 17.4864C17.1237 17.4099 17.5699 17.2926 18 17.1493Z" fill="currentColor"/>`,
"brain-4": `<path d="M19.5 4.7832V7.6709L22 9.11426V14.8867L19.499 16.3311L19.5 19.2178L14.5 22.1045L12 20.6611L9.5 22.1045L4.5 19.2178V16.3311L2 14.8877L2.00098 9.11328L4.5 7.66992V4.78418L9.5 1.89746L11.999 3.34082L14.501 1.89648L19.5 4.7832ZM13 5.07227L12.999 8.42285L15.9639 10.1338L14.9639 11.8662L11 9.57715V5.07324L9.5 4.20703L6.49902 5.93848V8.8252L4 10.2676V13.7334L6.5 15.1768V18.0635L9.5 19.7959L11 18.9287L11.001 15.5771L8.03613 13.8652L9.03613 12.1338L13.001 14.4229V18.9297L14.5 19.7959L17.5 18.0625V15.1768L20 13.7324V10.2695L17.499 8.8252L17.5 5.9375L14.501 4.20605L13 5.07227Z" fill="currentColor"/>`,
"brain-ai-3": `<path d="M19.5 4.7832V7.6709L22 9.11426V14.8867L19.499 16.3311L19.5 19.2178L14.5 22.1045L12 20.6611L9.5 22.1045L4.5 19.2178V16.3311L2 14.8877L2.00098 9.11328L4.5 7.66992V4.78418L9.5 1.89746L11.999 3.34082L14.501 1.89648L19.5 4.7832ZM13 5.07227V7H11V5.07324L9.5 4.20703L6.49902 5.93848V8.8252L4 10.2676V13.7334L6.5 15.1768V18.0635L9.5 19.7959L11 18.9287V17H13V18.9297L14.5 19.7959L17.5 18.0625V15.1768L20 13.7324V10.2695L17.499 8.8252L17.5 5.9375L14.501 4.20605L13 5.07227ZM14.2646 13.1602C14.3529 12.9473 14.6472 12.9473 14.7354 13.1602L14.8623 13.4648C15.0783 13.986 15.4807 14.4027 15.9873 14.6279L16.3457 14.7871C16.5511 14.8784 16.5511 15.1773 16.3457 15.2686L15.9658 15.4375C15.4721 15.6571 15.0761 16.0586 14.8564 16.5625L14.7334 16.8447C14.6432 17.0517 14.3569 17.0517 14.2666 16.8447L14.1436 16.5625C13.9239 16.0586 13.5279 15.6571 13.0342 15.4375L12.6543 15.2686C12.4489 15.1773 12.4489 14.8784 12.6543 14.7871L13.0127 14.6279C13.5193 14.4027 13.9217 13.986 14.1377 13.4648L14.2646 13.1602ZM9.58789 7.7793C9.74239 7.40671 10.2577 7.4067 10.4121 7.7793L10.6338 8.31445C11.0118 9.22695 11.7161 9.95624 12.6025 10.3506L13.2305 10.6289C13.5899 10.7887 13.5897 11.3117 13.2305 11.4717L12.5654 11.7676C11.7013 12.152 11.0086 12.8548 10.624 13.7373L10.4082 14.2324C10.2504 14.5948 9.74973 14.5948 9.5918 14.2324L9.37598 13.7373C8.99143 12.8548 8.29875 12.152 7.43457 11.7676L6.76953 11.4717C6.41033 11.3117 6.41022 10.7887 6.76953 10.6289L7.39746 10.3506C8.2839 9.95624 8.98832 9.22697 9.36621 8.31445L9.58789 7.7793Z" fill="currentColor"/>`,
"briefcase": `<path d="M7 5V2C7 1.44772 7.44772 1 8 1H16C16.5523 1 17 1.44772 17 2V5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V6C2 5.44772 2.44772 5 3 5H7ZM4 16V19H20V16H4ZM4 14H20V7H4V14ZM9 3V5H15V3H9ZM11 11H13V13H11V11Z" fill="currentColor"/>`,
"bug": `<path d="M13 19.9C15.2822 19.4367 17 17.419 17 15V12C17 11.299 16.8564 10.6219 16.5846 10H7.41538C7.14358 10.6219 7 11.299 7 12V15C7 17.419 8.71776 19.4367 11 19.9V14H13V19.9ZM5.5358 17.6907C5.19061 16.8623 5 15.9534 5 15H2V13H5V12C5 11.3573 5.08661 10.7348 5.2488 10.1436L3.0359 8.86602L4.0359 7.13397L6.05636 8.30049C6.11995 8.19854 6.18609 8.09835 6.25469 8H17.7453C17.8139 8.09835 17.88 8.19854 17.9436 8.30049L19.9641 7.13397L20.9641 8.86602L18.7512 10.1436C18.9134 10.7348 19 11.3573 19 12V13H22V15H19C19 15.9534 18.8094 16.8623 18.4642 17.6907L20.9641 19.134L19.9641 20.866L17.4383 19.4077C16.1549 20.9893 14.1955 22 12 22C9.80453 22 7.84512 20.9893 6.56171 19.4077L4.0359 20.866L3.0359 19.134L5.5358 17.6907ZM8 6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6H8Z" fill="currentColor"/>`,
@@ -51,6 +53,7 @@ export const iconSpriteData = {
"checkbox-blank-circle-fill": `<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="currentColor"/>`,
"checkbox-circle": `<path d="M4 12C4 7.58172 7.58172 4 12 4C16.4183 4 20 7.58172 20 12C20 16.4183 16.4183 20 12 20C7.58172 20 4 16.4183 4 12ZM12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM17.4571 9.45711L16.0429 8.04289L11 13.0858L8.20711 10.2929L6.79289 11.7071L11 15.9142L17.4571 9.45711Z" fill="currentColor"/>`,
"checkbox-multiple": `<path d="M6.99979 7V3C6.99979 2.44772 7.4475 2 7.99979 2H20.9998C21.5521 2 21.9998 2.44772 21.9998 3V16C21.9998 16.5523 21.5521 17 20.9998 17H17V20.9925C17 21.5489 16.551 22 15.9925 22H3.00728C2.45086 22 2 21.5511 2 20.9925L2.00276 8.00748C2.00288 7.45107 2.4518 7 3.01025 7H6.99979ZM8.99979 7H15.9927C16.549 7 17 7.44892 17 8.00748V15H19.9998V4H8.99979V7ZM15 9H4.00255L4.00021 20H15V9ZM8.50242 18L4.96689 14.4645L6.3811 13.0503L8.50242 15.1716L12.7451 10.9289L14.1593 12.3431L8.50242 18Z" fill="currentColor"/>`,
"claude-code": `<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z" fill="currentColor"/>`,
"clipboard": `<path d="M7 4V2H17V4H20.0066C20.5552 4 21 4.44495 21 4.9934V21.0066C21 21.5552 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5551 3 21.0066V4.9934C3 4.44476 3.44495 4 3.9934 4H7ZM7 6H5V20H19V6H17V8H7V6ZM9 4V6H15V4H9Z" fill="currentColor"/>`,
"close": `<path d="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637 16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855 12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z" fill="currentColor"/>`,
"close-circle": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 10.5858L14.8284 7.75736L16.2426 9.17157L13.4142 12L16.2426 14.8284L14.8284 16.2426L12 13.4142L9.17157 16.2426L7.75736 14.8284L10.5858 12L7.75736 9.17157L9.17157 7.75736L12 10.5858Z" fill="currentColor"/>`,
@@ -62,11 +65,12 @@ export const iconSpriteData = {
"code-sslash": `<path d="M24 12L18.3431 17.6569L16.9289 16.2426L21.1716 12L16.9289 7.75736L18.3431 6.34315L24 12ZM2.82843 12L7.07107 16.2426L5.65685 17.6569L0 12L5.65685 6.34315L7.07107 7.75736L2.82843 12ZM9.78845 21H7.66009L14.2116 3H16.3399L9.78845 21Z" fill="currentColor"/>`,
"collapse-vertical": `<path d="M11.9995 13.4995 16.9492 18.4493 15.535 19.8635 12.9995 17.3279 12.9995 22.9995H10.9995L10.9995 17.3279 8.46643 19.861 7.05222 18.4468 11.9995 13.4995ZM10.9995.999512 10.9995 6.67035 8.46448 4.13535 7.05026 5.54956 12 10.4995 16.9497 5.54977 15.5355 4.13555 12.9995 6.67157V.999512L10.9995.999512Z" fill="currentColor"/>`,
"command": `<path d="M10 8H14V6.5C14 4.567 15.567 3 17.5 3C19.433 3 21 4.567 21 6.5C21 8.433 19.433 10 17.5 10H16V14H17.5C19.433 14 21 15.567 21 17.5C21 19.433 19.433 21 17.5 21C15.567 21 14 19.433 14 17.5V16H10V17.5C10 19.433 8.433 21 6.5 21C4.567 21 3 19.433 3 17.5C3 15.567 4.567 14 6.5 14H8V10H6.5C4.567 10 3 8.433 3 6.5C3 4.567 4.567 3 6.5 3C8.433 3 10 4.567 10 6.5V8ZM8 8V6.5C8 5.67157 7.32843 5 6.5 5C5.67157 5 5 5.67157 5 6.5C5 7.32843 5.67157 8 6.5 8H8ZM8 16H6.5C5.67157 16 5 16.6716 5 17.5C5 18.3284 5.67157 19 6.5 19C7.32843 19 8 18.3284 8 17.5V16ZM16 8H17.5C18.3284 8 19 7.32843 19 6.5C19 5.67157 18.3284 5 17.5 5C16.6716 5 16 5.67157 16 6.5V8ZM16 16V17.5C16 18.3284 16.6716 19 17.5 19C18.3284 19 19 18.3284 19 17.5C19 16.6716 18.3284 16 17.5 16H16ZM10 10V14H14V10H10Z" fill="currentColor"/>`,
"command-code": `<path fill="currentColor" d="M5.8 5.8h4.8v4.8h-4.8Z M13.4 5.8h4.8v4.8h-4.8Z M10.6 10.6h2.8v2.8h-2.8Z M5.8 13.4h4.8v4.8h-4.8Z M13.4 13.4h4.8v4.8h-4.8Z"/>`,
"compass-3": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM16.5 7.5L14 14L7.5 16.5L10 10L16.5 7.5ZM12 13C12.5523 13 13 12.5523 13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13Z" fill="currentColor"/>`,
"computer": `<path d="M4 16H20V5H4V16ZM13 18V20H17V22H7V20H11V18H2.9918C2.44405 18 2 17.5511 2 16.9925V4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V16.9925C22 17.5489 21.5447 18 21.0082 18H13Z" fill="currentColor"/>`,
"contract-up-down": `<path d="M5.79285 5.20718 12 11.4143 18.2071 5.20718 16.7928 3.79297 12 8.58586 7.20706 3.79297 5.79285 5.20718ZM18.2072 18.7928 12.0001 12.5857 5.793 18.7928 7.20721 20.207 12.0001 15.4141 16.793 20.207 18.2072 18.7928Z" fill="currentColor"/>`,
"corner-down-left": `<path d="M19.0001 13.9999L19.0002 5L17.0002 4.99997L17.0001 11.9999L6.8283 12L10.778 8.05024L9.36382 6.63603L2.99986 13L9.36382 19.364L10.778 17.9497L6.82826 14L19.0001 13.9999Z" fill="currentColor"/>`,
"cursor": `<path d="M15.3873 13.4975L17.9403 20.5117L13.2418 22.2218L10.6889 15.2076L6.79004 17.6529L8.4086 1.63318L19.9457 12.8646L15.3873 13.4975ZM15.3768 19.3163L12.6618 11.8568L15.6212 11.4459L9.98201 5.9561L9.19088 13.7863L11.7221 12.1988L14.4371 19.6583L15.3768 19.3163Z" fill="currentColor"/>`,
"cursor": `<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" fill="currentColor"/>`,
"database-2": `<path d="M5 12.5C5 12.8134 5.46101 13.3584 6.53047 13.8931C7.91405 14.5849 9.87677 15 12 15C14.1232 15 16.0859 14.5849 17.4695 13.8931C18.539 13.3584 19 12.8134 19 12.5V10.3287C17.35 11.3482 14.8273 12 12 12C9.17273 12 6.64996 11.3482 5 10.3287V12.5ZM19 15.3287C17.35 16.3482 14.8273 17 12 17C9.17273 17 6.64996 16.3482 5 15.3287V17.5C5 17.8134 5.46101 18.3584 6.53047 18.8931C7.91405 19.5849 9.87677 20 12 20C14.1232 20 16.0859 19.5849 17.4695 18.8931C18.539 18.3584 19 17.8134 19 17.5V15.3287ZM3 17.5V7.5C3 5.01472 7.02944 3 12 3C16.9706 3 21 5.01472 21 7.5V17.5C21 19.9853 16.9706 22 12 22C7.02944 22 3 19.9853 3 17.5ZM12 10C14.1232 10 16.0859 9.58492 17.4695 8.89313C18.539 8.3584 19 7.81342 19 7.5C19 7.18658 18.539 6.6416 17.4695 6.10687C16.0859 5.41508 14.1232 5 12 5C9.87677 5 7.91405 5.41508 6.53047 6.10687C5.46101 6.6416 5 7.18658 5 7.5C5 7.81342 5.46101 8.3584 6.53047 8.89313C7.91405 9.58492 9.87677 10 12 10Z" fill="currentColor"/>`,
"delete-bin": `<path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 11H11V17H9V11ZM13 11H15V17H13V11ZM9 4V6H15V4H9Z" fill="currentColor"/>`,
"discord-fill": `<path d="M19.3034 5.33716C17.9344 4.71103 16.4805 4.2547 14.9629 4C14.7719 4.32899 14.5596 4.77471 14.411 5.12492C12.7969 4.89144 11.1944 4.89144 9.60255 5.12492C9.45397 4.77471 9.2311 4.32899 9.05068 4C7.52251 4.2547 6.06861 4.71103 4.70915 5.33716C1.96053 9.39111 1.21766 13.3495 1.5891 17.2549C3.41443 18.5815 5.17612 19.388 6.90701 19.9187C7.33151 19.3456 7.71356 18.73 8.04255 18.0827C7.41641 17.8492 6.82211 17.5627 6.24904 17.2231C6.39762 17.117 6.5462 17.0003 6.68416 16.8835C10.1438 18.4648 13.8911 18.4648 17.3082 16.8835C17.4568 17.0003 17.5948 17.117 17.7434 17.2231C17.1703 17.5627 16.576 17.8492 15.9499 18.0827C16.2789 18.73 16.6609 19.3456 17.0854 19.9187C18.8152 19.388 20.5875 18.5815 22.4033 17.2549C22.8596 12.7341 21.6806 8.80747 19.3034 5.33716ZM8.5201 14.8459C7.48007 14.8459 6.63107 13.9014 6.63107 12.7447C6.63107 11.5879 7.45884 10.6434 8.5201 10.6434C9.57071 10.6434 10.4303 11.5879 10.4091 12.7447C10.4091 13.9014 9.57071 14.8459 8.5201 14.8459ZM15.4936 14.8459C14.4535 14.8459 13.6034 13.9014 13.6034 12.7447C13.6034 11.5879 14.4323 10.6434 15.4936 10.6434C16.5442 10.6434 17.4038 11.5879 17.3825 12.7447C17.3825 13.9014 16.5548 14.8459 15.4936 14.8459Z" fill="currentColor"/>`,
@@ -159,6 +163,7 @@ export const iconSpriteData = {
"lock-unlock": `<path d="M7 10H20C20.5523 10 21 10.4477 21 11V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V11C3 10.4477 3.44772 10 4 10H5V9C5 5.13401 8.13401 2 12 2C14.7405 2 17.1131 3.5748 18.2624 5.86882L16.4731 6.76344C15.6522 5.12486 13.9575 4 12 4C9.23858 4 7 6.23858 7 9V10ZM5 12V20H19V12H5ZM10 15H14V17H10V15Z" fill="currentColor"/>`,
"loop-right-ai": `<path d="M22 12C22 17.5228 17.5228 22 12 22C8.72774 22 5.82382 20.4286 4 18.001V20.5H2V14.5H8V16.5H5.38477C6.82543 18.6137 9.25151 20 12 20C16.4183 20 20 16.4183 20 12H22ZM11.5293 8.31934C11.7059 7.8935 12.2943 7.89349 12.4707 8.31934L12.7236 8.93066C13.1556 9.97346 13.9615 10.8062 14.9746 11.2568L15.6924 11.5762C16.1026 11.759 16.1026 12.3562 15.6924 12.5391L14.9326 12.877C13.9449 13.3162 13.1534 14.1194 12.7139 15.1279L12.4668 15.6934C12.2864 16.1075 11.7137 16.1075 11.5332 15.6934L11.2871 15.1279C10.8476 14.1193 10.0552 13.3163 9.06738 12.877L8.30762 12.5391C7.89744 12.3562 7.89741 11.759 8.30762 11.5762L9.02539 11.2568C10.0385 10.8062 10.8445 9.97348 11.2764 8.93066L11.5293 8.31934ZM12 2C15.2723 2 18.1762 3.57144 20 5.99902V3.5H22V9.5H16V7.5H18.6152C17.1746 5.38634 14.7485 4 12 4C7.58172 4 4 7.58172 4 12H2C2 6.47715 6.47715 2 12 2Z" fill="currentColor"/>`,
"macbook": `<path d="M4 5V16H20V5H4ZM2 4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V18H2V4.00748ZM1 19H23V21H1V19Z" fill="currentColor"/>`,
"markup": `<path d="M10 10.4967L11.0385 6.86204C11.1902 6.331 11.7437 6.02351 12.2747 6.17523C12.6069 6.27015 12.8666 6.52983 12.9615 6.86204L14 10.4967V11.9967H14.7192C15.1781 11.9967 15.5781 12.309 15.6894 12.7542L17.051 18.2008C18.8507 16.7339 20 14.4995 20 11.9967C20 7.57843 16.4183 3.9967 12 3.9967C7.58172 3.9967 4 7.57843 4 11.9967C4 14.4995 5.14932 16.7339 6.94897 18.2008L8.31063 12.7542C8.42193 12.309 8.82191 11.9967 9.28078 11.9967H10V10.4967ZM12 19.9967C12.2415 19.9967 12.4813 19.986 12.7189 19.9649C13.6187 19.8847 14.4756 19.6556 15.2649 19.3024L13.9384 13.9967H10.0616L8.73514 19.3024C9.52438 19.6556 10.3813 19.8847 11.2811 19.9648C11.5187 19.986 11.7585 19.9967 12 19.9967ZM12 21.9967C6.47715 21.9967 2 17.5196 2 11.9967C2 6.47386 6.47715 1.9967 12 1.9967C17.5228 1.9967 22 6.47386 22 11.9967C22 17.5196 17.5228 21.9967 12 21.9967Z" fill="currentColor"/>`,
"menu-2": `<path d="M3 4H21V6H3V4ZM3 11H15V13H3V11ZM3 18H21V20H3V18Z" fill="currentColor"/>`,
"menu-fold-2": `<path d="M4.40347 3.90332L2.98926 5.31753L6.17124 8.49951L2.98926 11.6815L4.40347 13.0957L8.99967 8.49951L4.40347 3.90332ZM20.9997 19.9995V17.9995H2.99967V19.9995H20.9997ZM20.9997 12.9995V10.9995H11.9997V12.9995H20.9997ZM20.9997 5.99951V3.99951H11.9997V5.99951H20.9997Z" fill="currentColor"/>`,
"menu-search": `<path d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z" fill="currentColor"/>`,
@@ -225,6 +230,7 @@ export const iconSpriteData = {
"target": `<path d="M12 1.99999C12.5523 1.99999 13 2.4477 13 2.99999C12.9999 3.55224 12.5522 3.99999 12 3.99999C7.58172 3.99999 4 7.58171 4 12C4.00004 16.4182 7.58174 20 12 20C16.4182 20 19.9999 16.4182 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C21.9999 17.5228 17.5228 22 12 22C6.47717 22 2.00004 17.5228 2 12C2 6.47714 6.47715 1.99999 12 1.99999ZM12 5.99999C12.5523 5.99999 13 6.4477 13 6.99999C12.9999 7.55224 12.5522 7.99999 12 7.99999C9.79085 7.99999 7.99999 9.79085 7.99999 12C8.00004 14.2091 9.79088 16 12 16C14.2091 16 15.9999 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C17.9999 15.3137 15.3137 18 12 18C8.68631 18 6.00004 15.3137 6 12C6 8.68628 8.68629 5.99999 12 5.99999ZM17.6562 2.10057C18.0468 1.71005 18.6807 1.71005 19.0713 2.10057C19.4614 2.49105 19.4615 3.12419 19.0713 3.51463L18.3633 4.22069L18.3642 4.22167C17.9737 4.61219 17.9737 5.2452 18.3642 5.63573C18.7548 6.02612 19.3878 6.02621 19.7783 5.63573L20.4853 4.9287C20.8759 4.53839 21.5089 4.53826 21.8994 4.9287C22.2899 5.31915 22.2897 5.95222 21.8994 6.34276L19.7783 8.46483C19.5909 8.65223 19.3363 8.75671 19.0713 8.75682H16.6572L12.707 12.707C12.3165 13.0974 11.6834 13.0974 11.293 12.707C10.9025 12.3165 10.9026 11.6835 11.293 11.293L15.2422 7.34374V4.9287C15.2422 4.66356 15.3477 4.40916 15.5351 4.22167L17.6562 2.10057Z" fill="currentColor"/>`,
"target-fill": `<path d="M12 2C12.5523 2 13 2.44772 13 3C13 3.55228 12.5523 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 6C12.5523 6 13 6.44772 13 7C13 7.55228 12.5523 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6ZM18.5713 2.10059C18.8474 2.1006 19.0712 2.32449 19.0713 2.60059V4.42969C19.0716 4.70553 19.2954 4.92866 19.5713 4.92871H21.3994C21.6754 4.92871 21.8992 5.15275 21.8994 5.42871V6.34375L20.0107 8.23242C19.6358 8.60719 19.1268 8.81824 18.5967 8.81836H16.5967L12.707 12.707C12.3165 13.0974 11.6835 13.0975 11.293 12.707C10.9027 12.3165 10.9026 11.6834 11.293 11.293L15.1826 7.4043V5.4043C15.1826 4.87411 15.3928 4.36526 15.7676 3.99023L17.6572 2.10059H18.5713Z" fill="currentColor"/>`,
"task": `<path d="M19 4H5V20H19V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H19.9997C20.5519 2 20.9996 2.44772 20.9997 3L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11.2929 13.1213L15.5355 8.87868L16.9497 10.2929L11.2929 15.9497L7.40381 12.0607L8.81802 10.6464L11.2929 13.1213Z" fill="currentColor"/>`,
"telegram-fill": `<path d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM12.3584 9.38246C11.3857 9.78702 9.4418 10.6244 6.5266 11.8945C6.05321 12.0827 5.80524 12.2669 5.78266 12.4469C5.74451 12.7513 6.12561 12.8711 6.64458 13.0343C6.71517 13.0565 6.78832 13.0795 6.8633 13.1039C7.37388 13.2698 8.06071 13.464 8.41776 13.4717C8.74164 13.4787 9.10313 13.3452 9.50222 13.0711C12.226 11.2325 13.632 10.3032 13.7203 10.2832C13.7826 10.269 13.8689 10.2513 13.9273 10.3032C13.9858 10.3552 13.98 10.4536 13.9739 10.48C13.9361 10.641 12.4401 12.0318 11.666 12.7515C11.4351 12.9661 11.2101 13.1853 10.9833 13.4039C10.509 13.8611 10.1533 14.204 11.003 14.764C11.8644 15.3317 12.7323 15.8982 13.5724 16.4971C13.9867 16.7925 14.359 17.0579 14.8188 17.0156C15.0861 16.991 15.3621 16.7397 15.5022 15.9903C15.8335 14.2193 16.4847 10.3821 16.6352 8.80083C16.6484 8.6623 16.6318 8.485 16.6185 8.40717C16.6052 8.32934 16.5773 8.21844 16.4762 8.13635C16.3563 8.03913 16.1714 8.01863 16.0887 8.02009C15.7125 8.02672 15.1355 8.22737 12.3584 9.38246Z" fill="currentColor"/>`,
"terminal": `<path d="M10.9999 12L3.92886 19.0711L2.51465 17.6569L8.1715 12L2.51465 6.34317L3.92886 4.92896L10.9999 12ZM10.9999 19H20.9999V21H10.9999V19Z" fill="currentColor"/>`,
"terminal-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM12 15H18V17H12V15ZM8.66685 12L5.83842 9.17157L7.25264 7.75736L11.4953 12L7.25264 16.2426L5.83842 14.8284L8.66685 12Z" fill="currentColor"/>`,
"terminal-window": `<path d="M20 9V5H4V9H20ZM20 11H4V19H20V11ZM3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM5 12H8V17H5V12ZM5 6H7V8H5V6ZM9 6H11V8H9V6Z" fill="currentColor"/>`,
@@ -38,6 +38,8 @@ import { Icon } from "@/components/icon/Icon";
import {
EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST,
EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE,
EMBEDDED_VISIBILITY_REQUEST,
EMBEDDED_VISIBILITY_UPDATE,
getActiveEmbeddedSessionChatTab,
getOrCreateEmbeddedSessionChatURL,
type EmbeddedSessionChatURLCacheEntry,
@@ -803,27 +805,34 @@ export const ContextPanel: React.FC = () => {
}
}, [allowPromptingSubagentSessions]);
const postEmbeddedVisibilityToChat = React.useCallback((
tabID: string,
frame: HTMLIFrameElement,
targetOrigin: string,
) => {
const frameWindow = frame.contentWindow;
if (!frameWindow) {
return;
}
frameWindow.postMessage(
{
type: EMBEDDED_VISIBILITY_UPDATE,
payload: { visible: activeChatTabID === tabID },
},
targetOrigin,
);
}, [activeChatTabID]);
const postEmbeddedVisibilityToChats = React.useCallback(() => {
if (typeof window === 'undefined') {
return;
}
for (const [tabID, frame] of chatFrameRefs.current.entries()) {
const frameWindow = frame.contentWindow;
if (!frameWindow) {
continue;
}
const payload = { visible: activeChatTabID === tabID };
frameWindow.postMessage(
{
type: 'openchamber:embedded-visibility',
payload,
},
window.location.origin,
);
postEmbeddedVisibilityToChat(tabID, frame, window.location.origin);
}
}, [activeChatTabID]);
}, [postEmbeddedVisibilityToChat]);
React.useEffect(() => {
if (typeof window === 'undefined') {
@@ -835,13 +844,18 @@ export const ContextPanel: React.FC = () => {
return;
}
const isKnownChatFrame = Array.from(chatFrameRefs.current.values())
.some((frame) => frame.contentWindow === event.source);
if (!isKnownChatFrame) {
const sourceChatFrame = Array.from(chatFrameRefs.current.entries())
.find(([, frame]) => frame.contentWindow === event.source);
if (!sourceChatFrame) {
return;
}
const data = event.data as { type?: unknown; requestId?: unknown };
if (data?.type === EMBEDDED_VISIBILITY_REQUEST) {
const [tabID, frame] = sourceChatFrame;
postEmbeddedVisibilityToChat(tabID, frame, event.origin);
return;
}
if (data?.type === EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST) {
if (typeof data.requestId !== 'string' || !data.requestId) return;
const runtimeKey = getRuntimeKey();
@@ -882,7 +896,7 @@ export const ContextPanel: React.FC = () => {
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [postChatSettingsSyncToEmbeddedChat, postThemeSyncToEmbeddedChat, setThemeMode, themeMode]);
}, [postChatSettingsSyncToEmbeddedChat, postEmbeddedVisibilityToChat, postThemeSyncToEmbeddedChat, setThemeMode, themeMode]);
React.useLayoutEffect(() => {
const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat');
@@ -926,7 +940,7 @@ export const ContextPanel: React.FC = () => {
: activeTab?.mode === 'notes'
? <ProjectContextPanel />
: activeTab?.mode === 'plan'
? <React.Suspense fallback={null}><PlanView targetPath={activeTab.targetPath} /></React.Suspense>
? <React.Suspense fallback={null}><PlanView targetPath={activeTab.targetPath} projectPlanId={activeTab.projectPlanId} /></React.Suspense>
: null;
const browserTabs = React.useMemo(
@@ -11,6 +11,7 @@ import { computeCacheHitRate } from '@/stores/utils/tokenUtils';
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
import { copyTextToClipboard } from '@/lib/clipboard';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { formatMoney } from '@/lib/money';
import {
derivePartsLabel,
deriveUserSnippet,
@@ -92,6 +93,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
}
const breakdown = source as {
total?: unknown;
input?: unknown;
output?: unknown;
reasoning?: unknown;
@@ -103,6 +105,10 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
const reasoning = toNonNegativeNumber(breakdown.reasoning);
const cacheRead = toNonNegativeNumber(breakdown.cache?.read);
const cacheWrite = toNonNegativeNumber(breakdown.cache?.write);
// Multi-step turns accumulate the fields across API round-trips (every tool
// call re-reads the whole cached prompt), so summing them overstates the
// window. The server-reported total is the final round-trip's window.
const reportedTotal = toNonNegativeNumber(breakdown.total);
return {
input,
@@ -110,7 +116,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
reasoning,
cacheRead,
cacheWrite,
total: input + output + reasoning + cacheRead + cacheWrite,
total: reportedTotal > 0 ? reportedTotal : input + output + reasoning + cacheRead + cacheWrite,
};
};
@@ -231,16 +237,6 @@ const computeContextBreakdown = (
const formatNumber = (value: number): string => value.toLocaleString(getCurrentIntlLocale());
const formatMoney = (value: number): string => {
if (!Number.isFinite(value) || value <= 0) return new Intl.NumberFormat(getCurrentIntlLocale(), { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(0);
return new Intl.NumberFormat(getCurrentIntlLocale(), {
style: 'currency',
currency: 'USD',
minimumFractionDigits: value < 0.01 ? 4 : 2,
maximumFractionDigits: value < 0.01 ? 4 : 2,
}).format(value);
};
const formatDateTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '-';
return formatDateTimeForPreference(timestamp, timeFormatPreference, {
@@ -1,6 +1,6 @@
import React from 'react';
import { ProjectNotesTodoPanel } from '@/components/session/ProjectNotesTodoPanel';
import { ProjectNotesTodoPanel } from '@/components/session/project-context/ProjectNotesTodoPanel';
import { useGitStore } from '@/stores/useGitStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -8,7 +8,7 @@ import { formatDirectoryName } from '@/lib/utils';
export const ProjectContextPanel: React.FC<{
onActionComplete?: () => void;
onOpenPlan?: (plan: { path: string; title: string }) => void;
onOpenPlan?: (plan: { id: string; title: string }) => void;
}> = ({ onActionComplete, onOpenPlan }) => {
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const projects = useProjectsStore((state) => state.projects);
@@ -49,7 +49,8 @@ export const ProjectContextPanel: React.FC<{
}, [activeProject, gitDirectories]);
return (
<div className="h-full min-h-0 overflow-auto bg-background">
/* The panel scrolls its own tab content; a scroller here would nest. */
<div className="h-full min-h-0 overflow-hidden bg-background">
<ProjectNotesTodoPanel
projectRef={projectRef}
projectLabel={projectLabel}
@@ -43,6 +43,8 @@ import { opencodeClient } from '@/lib/opencode/client';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Icon } from "@/components/icon/Icon";
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { isFilesystemError } from '@/lib/api/files-errors';
import { notifyFileContentInvalidated } from '@/lib/fileContentInvalidation';
import { isBrowserClientRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
@@ -54,6 +56,40 @@ type FileNode = {
relativePath?: string;
};
type UploadConflicts = {
directory: string;
files: File[];
runtimeKey: string;
workspaceRoot: string;
};
type UploadOutcome = 'uploaded' | 'conflict' | 'failed';
const MAX_PARALLEL_UPLOADS = 3;
const hasExternalFiles = (dataTransfer: DataTransfer): boolean => (
Array.from(dataTransfer.types).includes('Files')
);
const getExternalFiles = (dataTransfer: DataTransfer): File[] => {
const items = Array.from(dataTransfer.items);
if (items.length === 0) return Array.from(dataTransfer.files);
return items.flatMap((item) => {
if (item.kind !== 'file' || item.webkitGetAsEntry()?.isDirectory) return [];
const file = item.getAsFile();
return file ? [file] : [];
});
};
const getUploadName = (file: File): string | null => {
const name = file.name;
if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
return null;
}
return name;
};
const sortNodes = (items: FileNode[]) =>
items.slice().sort((a, b) => {
if (a.type !== b.type) {
@@ -93,6 +129,22 @@ const getRelativePath = (root: string, path: string): string => {
return normalizedPath.slice(normalizedRoot.length + 1);
};
const getDropTargetLabel = (root: string, target: string): string => {
const relativePath = getRelativePath(root, target);
if (relativePath !== '.') return relativePath;
const normalizedRoot = normalizePath(root);
return normalizedRoot.split('/').filter(Boolean).pop() ?? normalizedRoot;
};
const getParentPath = (value: string): string => {
const normalized = normalizePath(value);
const separatorIndex = normalized.lastIndexOf('/');
if (separatorIndex < 0) return '';
if (separatorIndex === 0) return '/';
return normalized.slice(0, separatorIndex);
};
const isAbsolutePath = (value: string): boolean => {
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
};
@@ -194,6 +246,8 @@ interface FileRowProps {
isBrowserClient: boolean;
status?: FileStatus | null;
badge?: { modified: number; added: number } | null;
isDropTarget: boolean;
canUpload: boolean;
permissions: {
canRename: boolean;
canCreateFile: boolean;
@@ -206,6 +260,8 @@ interface FileRowProps {
onToggle: (path: string) => void;
onRevealPath: (path: string) => void;
onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void;
onSetDropTarget: (path: string | null) => void;
onDropFiles: (directory: string, dataTransfer: DataTransfer) => void;
}
const FileRow: React.FC<FileRowProps> = ({
@@ -216,15 +272,20 @@ const FileRow: React.FC<FileRowProps> = ({
isBrowserClient,
status,
badge,
isDropTarget,
canUpload,
permissions,
downloadFile,
onSelect,
onToggle,
onRevealPath,
onOpenDialog,
onSetDropTarget,
onDropFiles,
}) => {
const { t } = useI18n();
const isDir = node.type === 'directory';
const uploadDirectory = isDir ? node.path : getParentPath(node.path);
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions;
const canDownload = !isDir && Boolean(downloadFile);
const canRevealPath = canReveal && !isBrowserClient;
@@ -333,9 +394,40 @@ const FileRow: React.FC<FileRowProps> = ({
e.dataTransfer.effectAllowed = 'copy';
}, [node.path, root]);
const handleExternalDragOver = React.useCallback((event: React.DragEvent) => {
if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'copy';
onSetDropTarget(uploadDirectory);
}, [canUpload, onSetDropTarget, uploadDirectory]);
const handleExternalDragLeave = React.useCallback((event: React.DragEvent) => {
if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return;
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
event.stopPropagation();
onSetDropTarget(null);
}, [canUpload, onSetDropTarget, uploadDirectory]);
const handleExternalDrop = React.useCallback((event: React.DragEvent) => {
if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
onDropFiles(uploadDirectory, event.dataTransfer);
}, [canUpload, onDropFiles, uploadDirectory]);
return (
<ContextMenu open={rightClickOpen} onOpenChange={setRightClickOpen}>
<ContextMenuTrigger render={<div className="group relative flex items-center" onContextMenu={handleContextMenu} />}>
<ContextMenuTrigger render={(
<div
className="group relative flex items-center"
onContextMenu={handleContextMenu}
onDragEnter={handleExternalDragOver}
onDragOver={handleExternalDragOver}
onDragLeave={handleExternalDragLeave}
onDrop={handleExternalDrop}
/>
)}>
<button
type="button"
onClick={handleInteraction}
@@ -344,7 +436,9 @@ const FileRow: React.FC<FileRowProps> = ({
onDragStart={handleDragStart}
className={cn(
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none',
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40',
isDropTarget
? 'bg-interactive-selection ring-2 ring-inset ring-primary'
: (isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'),
'cursor-grab active:cursor-grabbing'
)}
>
@@ -415,12 +509,16 @@ const areFileRowPropsEqual = (prev: FileRowProps, next: FileRowProps): boolean =
&& prev.isBrowserClient === next.isBrowserClient
&& prev.status === next.status
&& prev.badge === next.badge
&& prev.isDropTarget === next.isDropTarget
&& prev.canUpload === next.canUpload
&& prev.permissions === next.permissions
&& prev.downloadFile === next.downloadFile
&& prev.onSelect === next.onSelect
&& prev.onToggle === next.onToggle
&& prev.onRevealPath === next.onRevealPath
&& prev.onOpenDialog === next.onOpenDialog
&& prev.onSetDropTarget === next.onSetDropTarget
&& prev.onDropFiles === next.onDropFiles
);
const MemoizedFileRow = React.memo(FileRow, areFileRowPropsEqual);
@@ -444,6 +542,12 @@ export const SidebarFilesTree: React.FC = () => {
const searchInputRef = React.useRef<HTMLInputElement>(null);
const [searchResults, setSearchResults] = React.useState<FileNode[]>([]);
const [searching, setSearching] = React.useState(false);
const [dropTarget, setDropTarget] = React.useState<string | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const [uploadConflicts, setUploadConflicts] = React.useState<UploadConflicts | null>(null);
const uploadingRef = React.useRef(false);
const rootRef = React.useRef(root);
rootRef.current = root;
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
const [loadErrorsByDir, setLoadErrorsByDir] = React.useState<Record<string, string>>({});
@@ -457,6 +561,8 @@ export const SidebarFilesTree: React.FC = () => {
// combining the two means the tree re-paints with cached data instead
// of blanking out and re-listing every directory.
React.useEffect(() => {
setDropTarget(null);
setUploadConflicts(null);
if (!root) {
setChildrenByDir({});
setLoadErrorsByDir({});
@@ -544,6 +650,7 @@ export const SidebarFilesTree: React.FC = () => {
const canRename = Boolean(files.rename);
const canDelete = Boolean(files.delete);
const canReveal = Boolean(files.revealPath);
const canUpload = Boolean(files.uploadFile);
const fileRowPermissions = React.useMemo(
() => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }),
@@ -897,6 +1004,110 @@ export const SidebarFilesTree: React.FC = () => {
}
}, [loadDirectory, root, toggleExpandedPath]);
const uploadDroppedFiles = React.useCallback(async (
directory: string,
droppedFiles: File[],
overwrite = false,
) => {
const uploadFile = files.uploadFile;
if (!uploadFile || droppedFiles.length === 0 || uploadingRef.current || !root) return;
const operationRoot = root;
const operationRuntime = getRuntimeKey();
uploadingRef.current = true;
setIsUploading(true);
setDropTarget(directory);
if (overwrite) setUploadConflicts(null);
const outcomes: UploadOutcome[] = [];
for (let index = 0; index < droppedFiles.length; index += MAX_PARALLEL_UPLOADS) {
const batch = droppedFiles.slice(index, index + MAX_PARALLEL_UPLOADS);
const batchOutcomes = await Promise.all(batch.map(async (file): Promise<UploadOutcome> => {
const name = getUploadName(file);
if (!name || getRuntimeKey() !== operationRuntime) return 'failed';
try {
const result = await uploadFile(normalizePath(`${directory}/${name}`), file, {
directory: operationRoot,
overwrite,
});
return result.success ? 'uploaded' : 'failed';
} catch (error) {
if (!overwrite && isFilesystemError(error) && error.reason === 'already-exists') {
return 'conflict';
}
return 'failed';
}
}));
outcomes.push(...batchOutcomes);
}
const uploadedCount = outcomes.filter((outcome) => outcome === 'uploaded').length;
const failedCount = outcomes.filter((outcome) => outcome === 'failed').length;
const conflictingFiles = droppedFiles.filter((_, index) => outcomes[index] === 'conflict');
const uploadedPaths = droppedFiles.flatMap((file, index) => {
const name = getUploadName(file);
return outcomes[index] === 'uploaded' && name
? [normalizePath(`${directory}/${name}`)]
: [];
});
const isCurrentDestination = rootRef.current === operationRoot && getRuntimeKey() === operationRuntime;
try {
if (uploadedPaths.length > 0) {
notifyFileContentInvalidated({ runtimeKey: operationRuntime, paths: uploadedPaths });
}
if (uploadedCount > 0 && isCurrentDestination) {
await refreshDirectory(directory);
}
if (uploadedCount > 0) {
toast.success(t(conflictingFiles.length > 0
? 'sidebarFilesTree.toast.uploadedWithoutConflicts'
: 'sidebarFilesTree.toast.uploaded'));
}
if (failedCount > 0) {
toast.error(t('sidebarFilesTree.toast.uploadFailed'));
}
if (conflictingFiles.length > 0 && isCurrentDestination) {
setUploadConflicts({
directory,
files: conflictingFiles,
runtimeKey: operationRuntime,
workspaceRoot: operationRoot,
});
}
} finally {
uploadingRef.current = false;
setIsUploading(false);
setDropTarget(null);
}
}, [files.uploadFile, refreshDirectory, root, t]);
const handleDropFiles = React.useCallback((directory: string, dataTransfer: DataTransfer) => {
const droppedFiles = getExternalFiles(dataTransfer);
if (droppedFiles.length === 0) return;
void uploadDroppedFiles(directory, droppedFiles);
}, [uploadDroppedFiles]);
const handleRootDragOver = React.useCallback((event: React.DragEvent) => {
if (!canUpload || uploadingRef.current || !root || !hasExternalFiles(event.dataTransfer)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
setDropTarget(root);
}, [canUpload, root]);
const handleRootDragLeave = React.useCallback((event: React.DragEvent) => {
if (!hasExternalFiles(event.dataTransfer)) return;
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
setDropTarget(null);
}, []);
const handleRootDrop = React.useCallback((event: React.DragEvent) => {
if (!canUpload || uploadingRef.current || !root || !hasExternalFiles(event.dataTransfer)) return;
event.preventDefault();
handleDropFiles(root, event.dataTransfer);
}, [canUpload, handleDropFiles, root]);
// --- Dialog submit (matching FilesView) ---
const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => {
@@ -1056,12 +1267,16 @@ export const SidebarFilesTree: React.FC = () => {
isBrowserClient={isBrowserClient}
status={!isDir ? getFileStatus(node.path) : undefined}
badge={isDir ? getFolderBadge(node.path) : undefined}
isDropTarget={isDir && dropTarget === node.path}
canUpload={canUpload && !isUploading}
permissions={fileRowPermissions}
downloadFile={files.downloadFile}
onSelect={handleOpenFile}
onToggle={toggleDirectory}
onRevealPath={handleRevealPath}
onOpenDialog={handleOpenDialog}
onSetDropTarget={setDropTarget}
onDropFiles={handleDropFiles}
/>
{isDir && isExpanded && (
<ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative">
@@ -1084,6 +1299,7 @@ export const SidebarFilesTree: React.FC = () => {
const hasTree = Boolean(root && childrenByDir[root]);
const rootLoadError = root ? loadErrorsByDir[root] : null;
const dropTargetLabel = dropTarget ? getDropTargetLabel(root, dropTarget) : '';
return (
<section className="flex h-full min-h-0 flex-col overflow-hidden">
@@ -1182,7 +1398,15 @@ export const SidebarFilesTree: React.FC = () => {
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-2">
<div className="relative flex-1 min-h-0">
<ScrollableOverlay
outerClassName="h-full min-h-0"
className={cn('p-2', dropTarget === root && 'bg-interactive-selection/10')}
onDragEnter={handleRootDragOver}
onDragOver={handleRootDragOver}
onDragLeave={handleRootDragLeave}
onDrop={handleRootDrop}
>
<ul className="flex flex-col">
{searching ? (
<li className="flex items-center gap-1.5 px-2 py-1 typography-meta text-muted-foreground">
@@ -1235,7 +1459,52 @@ export const SidebarFilesTree: React.FC = () => {
<li className="px-2 py-1 typography-meta text-muted-foreground">{t('sidebarFilesTree.state.loading')}</li>
)}
</ul>
</ScrollableOverlay>
</ScrollableOverlay>
{dropTarget ? (
<div className="pointer-events-none absolute left-2 right-2 top-2 z-50 flex items-center gap-2 rounded-md border border-primary bg-background/95 px-2 py-1.5 shadow-sm">
<Icon name={isUploading ? 'loader-4' : 'folder-received'} className={cn('size-4 flex-shrink-0', isUploading && 'animate-spin')} />
<span className="min-w-0 truncate typography-meta" title={dropTargetLabel}>
{t(isUploading ? 'sidebarFilesTree.drop.uploading' : 'sidebarFilesTree.drop.target', { path: dropTargetLabel })}
</span>
</div>
) : null}
</div>
<Dialog open={Boolean(uploadConflicts)} onOpenChange={(open: boolean) => !open && setUploadConflicts(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('sidebarFilesTree.dialog.uploadConflicts.title')}</DialogTitle>
<DialogDescription>
{t('sidebarFilesTree.dialog.uploadConflicts.description', { path: uploadConflicts?.directory ?? '' })}
</DialogDescription>
</DialogHeader>
<ScrollableOverlay outerClassName="max-h-52" className="flex flex-col gap-1 pr-2">
{uploadConflicts?.files.map((file, index) => (
<div key={`${file.name}-${file.size}-${index}`} className="truncate rounded-md bg-muted px-2 py-1 typography-meta" title={file.name}>
{file.name}
</div>
))}
</ScrollableOverlay>
<DialogFooter>
<Button variant="outline" onClick={() => setUploadConflicts(null)} disabled={isUploading}>
{t('sidebarFilesTree.dialog.cancel')}
</Button>
<Button
onClick={() => {
if (!uploadConflicts) return;
if (uploadConflicts.runtimeKey !== getRuntimeKey() || uploadConflicts.workspaceRoot !== root) {
setUploadConflicts(null);
return;
}
void uploadDroppedFiles(uploadConflicts.directory, uploadConflicts.files, true);
}}
disabled={isUploading}
>
{t('sidebarFilesTree.dialog.uploadConflicts.replace')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* CRUD dialogs (matching FilesView) */}
<Dialog open={!!activeDialog} onOpenChange={(open) => !open && setActiveDialog(null)}>
@@ -5,9 +5,10 @@ import { SessionDialogs } from '@/components/session/SessionDialogs';
import { ChatView } from '@/components/views/ChatView';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useViewportStore } from '@/sync/viewport-store';
import { useSessions, useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
import { useSessions, useDirectorySync, useSession, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { McpDropdown } from '@/components/mcp/McpDropdown';
import { ArchiveAllDropdown } from '@/components/session/ArchiveAllDropdown';
@@ -665,6 +666,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
const providers = useConfigStore((state) => state.providers);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const currentSession = useSession(currentSessionId ?? '');
const currentSessionMessages = useSessionMessages(currentSessionId ?? '');
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
const quotaResults = useQuotaStore((state) => state.results);
@@ -702,7 +704,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}
if (!lastTokens && message.tokens) {
const total = message.tokens.input + message.tokens.output + message.tokens.reasoning + (message.tokens.cache?.read ?? 0) + (message.tokens.cache?.write ?? 0);
const total = contextTokensFromBreakdown(message.tokens);
if (total > 0) {
lastTokens = message.tokens;
lastMessageId = (currentSessionMessages[i] as { id?: string }).id;
@@ -730,7 +732,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}
const lastTokens = headerMessageSummary.lastTokens;
const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0);
const totalTokens = contextTokensFromBreakdown(lastTokens);
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000;
const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0;
const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined;
@@ -1021,6 +1023,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
percentage={stableContextUsage.percentage}
contextLimit={stableContextUsage.contextLimit}
outputLimit={stableContextUsage.outputLimit ?? 0}
cost={(currentSession?.cost ?? 0) > 0 ? currentSession?.cost : null}
className="h-9 shrink-0 pl-1 pr-1 typography-ui-label"
valueClassName="font-semibold leading-none"
hideIcon
@@ -19,6 +19,7 @@ import {
} from '../contextPanelEmbeddedChat';
const __dirname = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(join(__dirname, '..', '..', '..', 'App.tsx'), 'utf-8');
const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8');
type FixtureTab = {
@@ -117,6 +118,7 @@ describe('issue #2815 active-only chat iframe source guard', () => {
expect(block).toContain('<iframe');
expect(block).toContain('key={activeChatTab.id}');
expect(block).toContain('src={activeChatSrc}');
expect(block).toContain('postEmbeddedVisibilityToChats();');
expect(block).not.toContain("'block' : 'hidden'");
});
@@ -128,6 +130,33 @@ describe('issue #2815 active-only chat iframe source guard', () => {
"const activeChatSessionID = isOpen && activeTab?.mode === 'chat'",
);
});
test('answers the mounted iframe visibility handshake from the active tab', () => {
expect(contextPanelSource).toContain('data?.type === EMBEDDED_VISIBILITY_REQUEST');
expect(contextPanelSource).toContain('frame.contentWindow === event.source');
expect(contextPanelSource).toContain('payload: { visible: activeChatTabID === tabID }');
});
test('requests authoritative visibility after installing the iframe listener', () => {
const effectStart = appSource.indexOf('const applyVisibility = (payload?: EmbeddedVisibilityPayload) => {');
const listenerIndex = appSource.indexOf("window.addEventListener('message', handleMessage);", effectStart);
const requestIndex = appSource.indexOf('requestEmbeddedSessionVisibility();', effectStart);
expect(effectStart).toBeGreaterThan(-1);
expect(listenerIndex).toBeGreaterThan(effectStart);
expect(requestIndex).toBeGreaterThan(listenerIndex);
});
test('gates embedded chat background work on visibility but keeps message history enabled', () => {
expect(appSource).toContain(
'const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;',
);
expect(appSource).toContain('active={embeddedBackgroundWorkEnabled}');
expect(appSource).toContain('messagesEnabled={true}');
expect(appSource).toContain(
'useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled });',
);
});
});
describe('issue #2815 persisted scenario', () => {
@@ -5,11 +5,13 @@ import {
buildEmbeddedSessionChatURL,
EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST,
EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE,
EMBEDDED_VISIBILITY_REQUEST,
getOrCreateEmbeddedSessionChatURL,
getActiveEmbeddedSessionChatTab,
getEmbeddedSessionChatOriginSessionId,
isEmbeddedSessionChat,
requestEmbeddedSessionRuntimeBootstrap,
requestEmbeddedSessionVisibility,
resetEmbeddedSessionChatCache,
type EmbeddedSessionChatURLCacheEntry,
} from './contextPanelEmbeddedChat';
@@ -171,6 +173,22 @@ describe('active embedded session chat', () => {
expect(getActiveEmbeddedSessionChatTab(tabs, null)).toBeNull();
expect(getActiveEmbeddedSessionChatTab(tabs, 'missing-chat')).toBeNull();
});
test('requests authoritative visibility from the same-origin parent', () => {
installWindowLocation('http://127.0.0.1:5173/app?ocPanel=session-chat&sessionId=ses_1');
resetEmbeddedSessionChatCache();
const calls: Array<{ message: unknown; origin: string }> = [];
(window as unknown as { parent: { postMessage: (message: unknown, origin: string) => void } }).parent = {
postMessage: (message, origin) => calls.push({ message, origin }),
};
requestEmbeddedSessionVisibility();
expect(calls).toEqual([{
message: { type: EMBEDDED_VISIBILITY_REQUEST },
origin: 'http://127.0.0.1:5173',
}]);
});
});
describe('isEmbeddedSessionChat', () => {
@@ -28,6 +28,8 @@ export type EmbeddedSessionRuntimeBootstrap = {
export const EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST = 'openchamber:embedded-runtime-bootstrap-request';
export const EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE = 'openchamber:embedded-runtime-bootstrap-response';
export const EMBEDDED_VISIBILITY_REQUEST = 'openchamber:embedded-visibility-request';
export const EMBEDDED_VISIBILITY_UPDATE = 'openchamber:embedded-visibility';
const EMBEDDED_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 5_000;
const EMBEDDED_RUNTIME_BOOTSTRAP_RETRY_MS = 100;
@@ -104,6 +106,13 @@ export const requestEmbeddedSessionRuntimeBootstrap = (): Promise<EmbeddedSessio
});
};
export const requestEmbeddedSessionVisibility = (): void => {
if (!isEmbeddedSessionChat() || typeof window === 'undefined' || !window.parent || window.parent === window) {
return;
}
window.parent.postMessage({ type: EMBEDDED_VISIBILITY_REQUEST }, window.location.origin);
};
const buildEmbeddedSessionChatURLSignature = (
sessionID: string,
directory: string | null,
@@ -18,6 +18,7 @@ import { useGitBranchLabel, useGitStore } from '@/stores/useGitStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { Icon } from "@/components/icon/Icon";
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
type MiniChatMode = 'session' | 'draft';
@@ -157,7 +158,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
return null;
}
type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } };
type AssistantTokens = { total?: number; input: number; output: number; reasoning: number; cache: { read: number; write: number } };
let lastTokens: AssistantTokens | undefined;
let lastMessageId: string | undefined;
@@ -166,7 +167,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
if (message.role !== 'assistant') continue;
const tokens = (message as { tokens?: AssistantTokens }).tokens;
if (!tokens) continue;
const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0);
const total = contextTokensFromBreakdown(tokens);
if (total > 0) {
lastTokens = tokens;
lastMessageId = message.id;
@@ -178,7 +179,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
return null;
}
const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0);
const totalTokens = contextTokensFromBreakdown(lastTokens);
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000;
const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0;
const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined;
@@ -0,0 +1,74 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import type { IconName } from '@/components/icon/icons';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type ComingSoonMessenger = {
id: 'discord' | 'telegram';
icon: IconName;
brandClassName: string;
nameKey: I18nKey;
descriptionKey: I18nKey;
};
const COMING_SOON_MESSENGERS: readonly ComingSoonMessenger[] = [
{
id: 'discord',
icon: 'discord-fill',
brandClassName: 'text-[#5865F2]',
nameKey: 'settings.integrations.messengers.discord.name',
descriptionKey: 'settings.integrations.messengers.discord.description',
},
{
id: 'telegram',
icon: 'telegram-fill',
brandClassName: 'text-[#2AABEE]',
nameKey: 'settings.integrations.messengers.telegram.name',
descriptionKey: 'settings.integrations.messengers.telegram.description',
},
] as const;
/**
* Non-interactive Discord/Telegram placeholders same card chrome as live
* integrations, greyed out, with a Coming soon badge and no expandable body.
*/
export const ComingSoonMessengersSection: React.FC = () => {
const { t } = useI18n();
return (
<SettingsSection
title={t('settings.integrations.messengers.title')}
info={t('settings.integrations.messengers.info')}
divider={false}
settingsItem="integrations.messengers"
contentClassName="space-y-3"
>
{COMING_SOON_MESSENGERS.map((messenger) => (
<div
key={messenger.id}
data-settings-item={`integrations.messengers.${messenger.id}`}
aria-disabled="true"
className={cn(
'flex min-w-0 items-center gap-3 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-4 py-3',
'pointer-events-none opacity-60',
)}
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name={messenger.icon} className={cn('size-5', messenger.brandClassName)} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{t(messenger.nameKey)}</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t(messenger.descriptionKey)}
</p>
</div>
<span className="max-w-36 shrink-0 truncate rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{t('settings.common.state.comingSoon')}
</span>
</div>
))}
</SettingsSection>
);
};
@@ -0,0 +1,30 @@
import React from 'react';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { useI18n } from '@/lib/i18n';
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
interface IntegrationsPageProps {
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
onOpenPluginManager: () => void;
}
export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
onOpenProviderSetup,
onOpenPluginManager,
}) => {
const { t } = useI18n();
return (
<SettingsPageLayout
title={t('settings.page.integrations.title')}
description={t('settings.page.integrations.description')}
showSaveStatus={false}
>
<ThirdPartyIntegrationsSection
divider={false}
onOpenProviderSetup={onOpenProviderSetup}
onOpenPluginManager={onOpenPluginManager}
/>
</SettingsPageLayout>
);
};
@@ -0,0 +1,447 @@
import React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useI18n } from '@/lib/i18n';
import { openExternalUrl } from '@/lib/url';
import { cn } from '@/lib/utils';
import {
usePluginsStore,
type PluginMutationResult,
} from '@/stores/usePluginsStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import {
getCatalogPluginPrimaryAction,
getCatalogPluginPresentation,
getCatalogPluginState,
getLatestNpmSpec,
THIRD_PARTY_PLUGINS,
type ThirdPartyPluginDefinition,
} from './thirdPartyPlugins';
type PendingAction = 'install' | 'update' | 'setup' | 'remove';
type RemoveTarget = ThirdPartyPluginDefinition | null;
interface ThirdPartyIntegrationsSectionProps {
divider?: boolean;
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
onOpenPluginManager: () => void;
}
const requiresRestart = (result: PluginMutationResult): boolean =>
result.restartDeferred === true
|| result.requiresManualRestart === true
|| result.reloadFailed === true;
export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSectionProps> = ({
divider = true,
onOpenProviderSetup,
onOpenPluginManager,
}) => {
const { t } = useI18n();
const {
entries,
registryInfo,
loadPlugins,
loadRegistryInfo,
createEntry,
updateEntry,
deleteEntry,
} = usePluginsStore(
useShallow((state) => ({
entries: state.entries,
registryInfo: state.registryInfo,
loadPlugins: state.loadPlugins,
loadRegistryInfo: state.loadRegistryInfo,
createEntry: state.createEntry,
updateEntry: state.updateEntry,
deleteEntry: state.deleteEntry,
})),
);
const [registryLoadFailed, setRegistryLoadFailed] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<{
pluginId: string;
action: PendingAction;
} | null>(null);
const [restartRequiredIds, setRestartRequiredIds] = React.useState<ReadonlySet<string>>(
() => new Set(),
);
const [providerUnavailableIds, setProviderUnavailableIds] = React.useState<ReadonlySet<string>>(
() => new Set(),
);
const [removeTarget, setRemoveTarget] = React.useState<RemoveTarget>(null);
const [openPluginIds, setOpenPluginIds] = React.useState<ReadonlySet<string>>(() => new Set());
const refresh = React.useCallback(async () => {
const pluginsLoaded = await loadPlugins({ force: true });
if (!pluginsLoaded) {
setRegistryLoadFailed(true);
return;
}
const latestEntries = usePluginsStore.getState().entries;
const specs = new Set(THIRD_PARTY_PLUGINS.map((plugin) => plugin.packageName));
for (const entry of latestEntries) {
if (THIRD_PARTY_PLUGINS.some((plugin) => entry.spec === plugin.packageName || entry.spec.startsWith(`${plugin.packageName}@`))) {
specs.add(entry.spec);
}
}
const registryLoaded = await loadRegistryInfo({ specs: [...specs], force: true });
setRegistryLoadFailed(!registryLoaded);
}, [loadPlugins, loadRegistryInfo]);
React.useEffect(() => {
void refresh();
}, [refresh]);
const pendingPluginRestartCount = usePendingOpenCodeRestartStore(
(state) => state.changes.filter((change) => change.scope === 'plugins').length,
);
const isApplyingRestart = usePendingOpenCodeRestartStore((state) => state.isApplying);
const previousPluginRestartCountRef = React.useRef(pendingPluginRestartCount);
// When deferred plugin restarts are applied (pending plugins scope clears), drop
// local restart/unavailable flags and reload so statuses update immediately.
React.useEffect(() => {
const previousCount = previousPluginRestartCountRef.current;
previousPluginRestartCountRef.current = pendingPluginRestartCount;
if (isApplyingRestart) {
return;
}
if (previousCount <= 0 || pendingPluginRestartCount > 0) {
return;
}
setRestartRequiredIds(new Set());
setProviderUnavailableIds(new Set());
void refresh();
}, [isApplyingRestart, pendingPluginRestartCount, refresh]);
const setRestartRequired = React.useCallback((pluginId: string, required: boolean) => {
setRestartRequiredIds((current) => {
const next = new Set(current);
if (required) next.add(pluginId);
else next.delete(pluginId);
return next;
});
}, []);
const setProviderUnavailable = React.useCallback((pluginId: string, unavailable: boolean) => {
setProviderUnavailableIds((current) => {
const next = new Set(current);
if (unavailable) next.add(pluginId);
else next.delete(pluginId);
return next;
});
}, []);
const runMutation = React.useCallback(async (
plugin: ThirdPartyPluginDefinition,
action: Exclude<PendingAction, 'setup'>,
run: () => Promise<PluginMutationResult>,
) => {
setPendingAction({ pluginId: plugin.id, action });
try {
const result = await run();
if (!result.ok) {
toast.error(t('settings.integrations.thirdParty.toast.actionFailed'));
return;
}
setProviderUnavailable(plugin.id, false);
const restartNeeded = requiresRestart(result);
setRestartRequired(plugin.id, restartNeeded);
const toastOptions = restartNeeded
? { description: t('settings.integrations.thirdParty.toast.restartRequired') }
: undefined;
if (action === 'install') {
toast.success(t('settings.integrations.thirdParty.toast.installed', { name: t(plugin.nameKey) }), toastOptions);
} else if (action === 'update') {
toast.success(t('settings.integrations.thirdParty.toast.updated', { name: t(plugin.nameKey) }), toastOptions);
} else {
toast.success(t('settings.integrations.thirdParty.toast.removed', { name: t(plugin.nameKey) }), toastOptions);
}
await refresh();
} finally {
setPendingAction(null);
}
}, [refresh, setProviderUnavailable, setRestartRequired, t]);
const handlePrimaryAction = React.useCallback(async (plugin: ThirdPartyPluginDefinition) => {
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
const action = getCatalogPluginPrimaryAction(state, plugin.packageName);
if (action === 'manage') {
onOpenPluginManager();
return;
}
if (action === 'setup') {
setPendingAction({ pluginId: plugin.id, action });
try {
const opened = await onOpenProviderSetup(plugin.providerId);
setProviderUnavailable(plugin.id, !opened);
if (!opened) {
toast.error(t('settings.integrations.thirdParty.toast.providerUnavailable'));
}
} finally {
setPendingAction(null);
}
return;
}
const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry);
if (!latestSpec) {
setRegistryLoadFailed(true);
return;
}
if (action === 'install') {
await runMutation(plugin, 'install', () => createEntry({ spec: latestSpec, scope: 'user' }));
return;
}
if (state.userEntry) {
await runMutation(plugin, 'update', () => updateEntry(state.userEntry!.id, { spec: latestSpec }));
}
}, [createEntry, entries, onOpenPluginManager, onOpenProviderSetup, registryInfo, runMutation, setProviderUnavailable, t, updateEntry]);
const handleRemove = React.useCallback(async () => {
const plugin = removeTarget;
if (!plugin) return;
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
if (!state.userEntry || state.userEntryIsAmbiguous) {
setRemoveTarget(null);
onOpenPluginManager();
return;
}
setRemoveTarget(null);
await runMutation(plugin, 'remove', () => deleteEntry(state.userEntry!.id));
}, [deleteEntry, entries, onOpenPluginManager, registryInfo, removeTarget, runMutation]);
const setPluginOpen = React.useCallback((pluginId: string, open: boolean) => {
setOpenPluginIds((current) => {
const next = new Set(current);
if (open) next.add(pluginId);
else next.delete(pluginId);
return next;
});
}, []);
const renderPlugin = (plugin: ThirdPartyPluginDefinition) => {
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
const primaryAction = getCatalogPluginPrimaryAction(state, plugin.packageName);
const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry);
const isPending = pendingAction?.pluginId === plugin.id;
const isRestartRequired = restartRequiredIds.has(plugin.id);
const isProviderUnavailable = providerUnavailableIds.has(plugin.id);
const registryUnavailable = registryLoadFailed || state.registry?.kind === 'npm-network';
const actionDisabled = isPending
|| isRestartRequired
|| ((primaryAction === 'install' || primaryAction === 'update') && (registryUnavailable || !latestSpec));
const presentation = getCatalogPluginPresentation(state, {
registryUnavailable,
restartRequired: isRestartRequired,
providerUnavailable: isProviderUnavailable,
});
let status: string;
switch (presentation.status) {
case 'installed-version':
status = presentation.latestVersion
? t('settings.integrations.thirdParty.status.installedVersion', {
version: presentation.latestVersion,
})
: t('settings.integrations.thirdParty.status.installed');
break;
case 'update-available':
status = presentation.latestVersion
? t('settings.integrations.thirdParty.status.updateAvailable', {
version: presentation.latestVersion,
})
: t('settings.integrations.thirdParty.status.unpinned');
break;
case 'not-installed':
status = t('settings.integrations.thirdParty.status.notInstalled');
break;
case 'installed':
status = t('settings.integrations.thirdParty.status.installed');
break;
case 'unpinned':
status = t('settings.integrations.thirdParty.status.unpinned');
break;
case 'ambiguous':
status = t('settings.integrations.thirdParty.status.ambiguous');
break;
case 'restart-required':
status = t('settings.integrations.thirdParty.status.restartRequired');
break;
case 'registry-unavailable':
status = t('settings.integrations.thirdParty.status.registryUnavailable');
break;
case 'provider-unavailable':
status = t('settings.integrations.thirdParty.status.providerUnavailable');
break;
}
const statusClassName = presentation.status === 'installed-version'
? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
: presentation.status === 'update-available'
|| presentation.status === 'ambiguous'
|| presentation.status === 'restart-required'
|| presentation.status === 'registry-unavailable'
|| presentation.status === 'provider-unavailable'
? 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]'
: 'bg-[var(--surface-muted)] text-muted-foreground';
const primaryLabel = {
install: t('settings.integrations.thirdParty.actions.install'),
update: t('settings.integrations.thirdParty.actions.update'),
setup: t('settings.integrations.thirdParty.actions.setup'),
manage: t('settings.integrations.thirdParty.actions.managePlugins'),
}[primaryAction];
const open = openPluginIds.has(plugin.id);
return (
<Collapsible
key={plugin.id}
open={open}
onOpenChange={(nextOpen) => setPluginOpen(plugin.id, nextOpen)}
>
<div
data-settings-item={`integrations.third-party.${plugin.id}`}
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
>
<CollapsibleTrigger
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
{plugin.providerId === 'command-code' ? (
<ProviderLogo providerId={plugin.providerId} className="size-5" />
) : (
<Icon name={plugin.icon} className={cn('size-5', plugin.brandClassName)} />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{t(plugin.nameKey)}</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t(plugin.descriptionKey)}
</p>
</div>
<span
aria-live="polite"
className={cn(
'max-w-36 shrink-0 truncate rounded-full px-2 py-0.5 text-[10px] font-medium',
statusClassName,
)}
>
{status}
</span>
<Icon
name="arrow-down-s"
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out motion-reduce:transition-none',
open && 'rotate-180',
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
<div className="space-y-3">
{state.projectEntries.length > 0 ? (
<p className="text-xs text-muted-foreground">
{t('settings.integrations.thirdParty.status.projectInstalled')}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant={primaryAction === 'manage' ? 'outline' : 'default'}
onClick={() => void handlePrimaryAction(plugin)}
disabled={actionDisabled}
>
{isPending ? (
<Icon name="loader-4" className="size-3.5 animate-spin" />
) : primaryAction === 'setup' ? (
<Icon name="plug-2" className="size-3.5" />
) : null}
{primaryLabel}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void openExternalUrl(plugin.homepage)}
>
<Icon name="external-link" className="size-3.5" />
{t('settings.integrations.thirdParty.actions.docs')}
</Button>
{state.userEntry && !state.userEntryIsAmbiguous ? (
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => setRemoveTarget(plugin)}
disabled={isPending}
>
<Icon name="delete-bin" className="size-3.5" />
{t('settings.integrations.thirdParty.actions.remove')}
</Button>
) : null}
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
};
return (
<>
<SettingsSection
title={t('settings.integrations.thirdParty.title')}
info={t('settings.integrations.thirdParty.info')}
divider={divider}
settingsItem="integrations.third-party"
contentClassName="space-y-3"
>
{THIRD_PARTY_PLUGINS.map(renderPlugin)}
</SettingsSection>
<Dialog open={removeTarget !== null} onOpenChange={(open) => !open && setRemoveTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('settings.integrations.thirdParty.dialog.remove.title')}</DialogTitle>
<DialogDescription>
{t('settings.integrations.thirdParty.dialog.remove.description', {
name: removeTarget ? t(removeTarget.nameKey) : '',
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" size="sm" variant="ghost" onClick={() => setRemoveTarget(null)}>
{t('settings.common.actions.cancel')}
</Button>
<Button type="button" size="sm" variant="destructive" onClick={() => void handleRemove()}>
{t('settings.integrations.thirdParty.actions.remove')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,218 @@
import { describe, expect, test } from 'bun:test';
import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore';
import * as thirdPartyCatalog from './thirdPartyPlugins';
import {
getCatalogPluginState,
getCatalogPluginPrimaryAction,
getLatestNpmSpec,
specMatchesPackage,
} from './thirdPartyPlugins';
type CatalogPresentationStatus =
| 'not-installed'
| 'installed'
| 'installed-version'
| 'update-available'
| 'unpinned'
| 'ambiguous'
| 'restart-required'
| 'registry-unavailable'
| 'provider-unavailable';
type GetCatalogPluginPresentation = (
state: ReturnType<typeof getCatalogPluginState>,
options?: {
registryUnavailable?: boolean;
restartRequired?: boolean;
providerUnavailable?: boolean;
},
) => {
status: CatalogPresentationStatus;
latestVersion: string | null;
};
const getCatalogPluginPresentation = (
thirdPartyCatalog as unknown as {
getCatalogPluginPresentation?: GetCatalogPluginPresentation;
}
).getCatalogPluginPresentation;
const claudePackage = '@openchamber/opencode-claude';
const entry = (spec: string, scope: PluginEntry['scope'] = 'user'): PluginEntry => ({
id: `config:${scope}:${spec}`,
spec,
scope,
kind: 'config',
parsedKind: 'npm',
});
const registry = (spec: string, currentVersion: string | null, latestVersion = '0.7.0'): RegistryResult => ({
kind: 'npm-ok',
spec,
name: claudePackage,
currentVersion,
latestVersion,
versions: ['0.6.0', latestVersion],
hasUpdate: currentVersion !== null && currentVersion !== latestVersion,
});
describe('third-party plugin catalog helpers', () => {
test('derives compact-card status with explicit transient-state priority', () => {
expect(typeof getCatalogPluginPresentation).toBe('function');
if (!getCatalogPluginPresentation) return;
const notInstalled = getCatalogPluginState([], claudePackage, {});
expect(getCatalogPluginPresentation(notInstalled)).toEqual({
status: 'not-installed',
latestVersion: null,
});
const current = getCatalogPluginState(
[entry(`${claudePackage}@0.7.0`)],
claudePackage,
{ [`${claudePackage}@0.7.0`]: registry(`${claudePackage}@0.7.0`, '0.7.0') },
);
expect(getCatalogPluginPresentation(current)).toEqual({
status: 'installed-version',
latestVersion: '0.7.0',
});
const outdated = getCatalogPluginState(
[entry(`${claudePackage}@0.6.0`)],
claudePackage,
{ [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') },
);
expect(getCatalogPluginPresentation(outdated)).toEqual({
status: 'update-available',
latestVersion: '0.7.0',
});
expect(getCatalogPluginPresentation(outdated, { registryUnavailable: true })).toEqual({
status: 'registry-unavailable',
latestVersion: '0.7.0',
});
expect(getCatalogPluginPresentation(outdated, { providerUnavailable: true })).toEqual({
status: 'provider-unavailable',
latestVersion: '0.7.0',
});
expect(getCatalogPluginPresentation(outdated, {
providerUnavailable: true,
restartRequired: true,
})).toEqual({
status: 'restart-required',
latestVersion: '0.7.0',
});
const ambiguous = getCatalogPluginState(
[entry(claudePackage), entry(`${claudePackage}@0.6.0`)],
claudePackage,
{},
);
expect(getCatalogPluginPresentation(ambiguous)).toEqual({
status: 'ambiguous',
latestVersion: null,
});
});
test('matches only a package or its versioned spec', () => {
expect(specMatchesPackage(claudePackage, claudePackage)).toBe(true);
expect(specMatchesPackage(`${claudePackage}@0.6.0`, claudePackage)).toBe(true);
expect(specMatchesPackage('@openchamber/opencode-claude-extra@0.6.0', claudePackage)).toBe(false);
});
test('points catalog plugins at the OpenChamber GitHub and npm packages', () => {
expect(thirdPartyCatalog.THIRD_PARTY_PLUGINS.map((plugin) => ({
id: plugin.id,
packageName: plugin.packageName,
homepage: plugin.homepage,
}))).toEqual([
{
id: 'opencode-claude',
packageName: '@openchamber/opencode-claude',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-commandcode',
packageName: '@openchamber/opencode-commandcode',
homepage: 'https://github.com/openchamber/opencode-commandcode',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
homepage: 'https://github.com/openchamber/opencode-cursor',
},
]);
});
test('uses the configured user entry and its registry result', () => {
const installed = entry(`${claudePackage}@0.6.0`);
const state = getCatalogPluginState(
[installed],
claudePackage,
{ [installed.spec]: registry(installed.spec, '0.6.0') },
);
expect(state.userEntry).toEqual(installed);
expect(state.userEntryIsAmbiguous).toBe(false);
expect(state.projectEntries).toEqual([]);
expect(state.registry).toEqual(registry(installed.spec, '0.6.0'));
});
test('does not choose an entry when multiple user specs would make a mutation ambiguous', () => {
const state = getCatalogPluginState(
[entry(claudePackage), entry(`${claudePackage}@0.6.0`), entry(claudePackage, 'project')],
claudePackage,
{},
);
expect(state.userEntry).toBeNull();
expect(state.userEntryIsAmbiguous).toBe(true);
expect(state.projectEntries).toHaveLength(1);
});
test('returns an exact latest spec only from a valid npm registry result', () => {
expect(getLatestNpmSpec(claudePackage, registry(claudePackage, null))).toBe(`${claudePackage}@0.7.0`);
expect(getLatestNpmSpec(claudePackage, {
kind: 'npm-network',
spec: claudePackage,
error: 'offline',
})).toBeNull();
});
test('chooses an update for a bare or outdated user-wide entry', () => {
const bare = getCatalogPluginState(
[entry(claudePackage)],
claudePackage,
{ [claudePackage]: registry(claudePackage, null) },
);
const outdated = getCatalogPluginState(
[entry(`${claudePackage}@0.6.0`)],
claudePackage,
{ [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') },
);
expect(getCatalogPluginPrimaryAction(bare, claudePackage)).toBe('update');
expect(getCatalogPluginPrimaryAction(outdated, claudePackage)).toBe('update');
});
test('keeps setup as the primary action once the exact latest spec is installed', () => {
const installed = entry(`${claudePackage}@0.7.0`);
const state = getCatalogPluginState(
[installed],
claudePackage,
{ [installed.spec]: registry(installed.spec, '0.7.0') },
);
expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('setup');
});
test('sends ambiguous entries to manual plugin management', () => {
const state = getCatalogPluginState(
[entry(claudePackage), entry(`${claudePackage}@0.6.0`)],
claudePackage,
{},
);
expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('manage');
});
});
@@ -0,0 +1,166 @@
import type { IconName } from '@/components/icon/icons';
import type { I18nKey } from '@/lib/i18n';
import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore';
export interface ThirdPartyPluginDefinition {
id: string;
packageName: string;
providerId: string;
icon: IconName;
/** Brand mark tint (e.g. Claude orange); neutral marks use text-foreground. */
brandClassName: string;
nameKey: I18nKey;
descriptionKey: I18nKey;
homepage: string;
}
export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [
{
id: 'opencode-claude',
packageName: '@openchamber/opencode-claude',
providerId: 'claude-code',
icon: 'claude-code',
brandClassName: 'text-[#D97757]',
nameKey: 'settings.integrations.thirdParty.opencodeClaude.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-commandcode',
packageName: '@openchamber/opencode-commandcode',
providerId: 'command-code',
icon: 'command-code',
brandClassName: 'text-foreground',
nameKey: 'settings.integrations.thirdParty.opencodeCommandcode.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description',
homepage: 'https://github.com/openchamber/opencode-commandcode',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
providerId: 'cursor',
icon: 'cursor',
brandClassName: 'text-foreground',
nameKey: 'settings.integrations.thirdParty.opencodeCursorOauth.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeCursorOauth.description',
homepage: 'https://github.com/openchamber/opencode-cursor',
},
] as const;
export interface CatalogPluginState {
userEntry: PluginEntry | null;
userEntryIsAmbiguous: boolean;
projectEntries: PluginEntry[];
registry: RegistryResult | null;
}
export type CatalogPluginPrimaryAction = 'install' | 'update' | 'setup' | 'manage';
type CatalogPluginPresentationStatus =
| 'not-installed'
| 'installed'
| 'installed-version'
| 'update-available'
| 'unpinned'
| 'ambiguous'
| 'restart-required'
| 'registry-unavailable'
| 'provider-unavailable';
interface CatalogPluginPresentationOptions {
registryUnavailable?: boolean;
restartRequired?: boolean;
providerUnavailable?: boolean;
}
interface CatalogPluginPresentation {
status: CatalogPluginPresentationStatus;
latestVersion: string | null;
}
export const specMatchesPackage = (spec: string, packageName: string): boolean =>
spec === packageName || spec.startsWith(`${packageName}@`);
export function getCatalogPluginState(
entries: PluginEntry[],
packageName: string,
registryInfo: Record<string, RegistryResult>,
): CatalogPluginState {
const matchingEntries = entries.filter((entry) => specMatchesPackage(entry.spec, packageName));
const userEntries = matchingEntries.filter((entry) => entry.scope === 'user');
const projectEntries = matchingEntries.filter((entry) => entry.scope === 'project');
const userEntry = userEntries.length === 1 ? userEntries[0] : null;
const registry = registryInfo[userEntry?.spec ?? packageName] ?? registryInfo[packageName] ?? null;
return {
userEntry,
userEntryIsAmbiguous: userEntries.length > 1,
projectEntries,
registry,
};
}
export function getLatestNpmSpec(
packageName: string,
registry: RegistryResult | null | undefined,
): string | null {
if (registry?.kind !== 'npm-ok' || registry.name !== packageName || !registry.latestVersion) {
return null;
}
return `${packageName}@${registry.latestVersion}`;
}
export function getCatalogPluginPrimaryAction(
state: CatalogPluginState,
packageName: string,
): CatalogPluginPrimaryAction {
if (state.userEntryIsAmbiguous) {
return 'manage';
}
if (!state.userEntry) {
return 'install';
}
const latestSpec = getLatestNpmSpec(packageName, state.registry);
return latestSpec && latestSpec !== state.userEntry.spec ? 'update' : 'setup';
}
/**
* Converts catalog and temporary mutation state into the one compact-card
* status. Transient states intentionally outrank installed/version metadata.
*/
export function getCatalogPluginPresentation(
state: CatalogPluginState,
options: CatalogPluginPresentationOptions = {},
): CatalogPluginPresentation {
const latestVersion = state.registry?.kind === 'npm-ok'
? state.registry.latestVersion
: null;
if (state.userEntryIsAmbiguous) {
return { status: 'ambiguous', latestVersion };
}
if (options.restartRequired) {
return { status: 'restart-required', latestVersion };
}
if (options.providerUnavailable) {
return { status: 'provider-unavailable', latestVersion };
}
if (options.registryUnavailable) {
return { status: 'registry-unavailable', latestVersion };
}
if (!state.userEntry) {
return { status: 'not-installed', latestVersion };
}
if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === state.registry.latestVersion) {
return { status: 'installed-version', latestVersion };
}
if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === null) {
return { status: 'unpinned', latestVersion };
}
if (state.registry?.kind === 'npm-ok' && latestVersion) {
return { status: 'update-available', latestVersion };
}
return { status: 'installed', latestVersion };
}
@@ -52,7 +52,7 @@ export const DefaultsSettings: React.FC = () => {
const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>();
const [smallModelUseDefault, setSmallModelUseDefault] = React.useState(true);
const [smallModelOverride, setSmallModelOverride] = React.useState<string | undefined>();
const [smallModelProviders, setSmallModelProviders] = React.useState<string[] | undefined>();
const [smallModelProviders, setSmallModelProviders] = React.useState<string[]>([]);
const [walkthroughModelOverride, setWalkthroughModelOverride] = React.useState<string | undefined>();
const [isLoading, setIsLoading] = React.useState(true);
@@ -274,13 +274,12 @@ export const DefaultsSettings: React.FC = () => {
() => getDisplayModel(walkthroughModelOverride),
[walkthroughModelOverride]
);
React.useEffect(() => {
// Both pickers filter by the same authenticated-provider list, so either
// one being open is reason enough to fetch it.
// Both pickers filter by the same authenticated-provider list, and the
// walkthrough picker is always visible, so this is always worth fetching.
if (smallModelProviders !== undefined) return;
// Both pickers offer the same providers — the walkthrough runs through the
// small model — and the walkthrough picker is always visible, so this is
// always worth fetching. The server answers with the providers it has a
// credential and an endpoint for, including plugin-registered ones that
// exist only inside the running OpenCode.
let cancelled = false;
(async () => {
try {
@@ -291,13 +290,13 @@ export const DefaultsSettings: React.FC = () => {
setSmallModelProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string'));
}
} catch {
// leave undefined — picker falls back to showing all providers
// Fail closed: never offer providers whose credentials were not verified.
}
})();
return () => {
cancelled = true;
};
}, [smallModelProviders]);
}, []);
const availableVariants = React.useMemo(() => {
if (!parsedModel.providerId || !parsedModel.modelId) return [];
@@ -7,6 +7,7 @@ import {
} from '@/components/sections/shared/SettingsSection';
import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { updateDesktopSettings } from '@/lib/persistence';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
@@ -27,6 +28,11 @@ export const OpenChamberToolsSettings: React.FC = () => {
const setAgentControlToolEnabled = useUIStore((state) => state.setAgentControlToolEnabled);
const agentWebToolEnabled = useUIStore((state) => state.agentWebToolEnabled);
const setAgentWebToolEnabled = useUIStore((state) => state.setAgentWebToolEnabled);
const agentMemoryToolEnabled = useUIStore((state) => state.agentMemoryToolEnabled);
// Absent, not merely off: the feature is finished but unreleased, and a
// visible switch invites turning on something that was never announced.
const agentMemoryAvailable = useUIStore((state) => state.agentMemoryFeatureAvailable);
const setAgentMemoryToolEnabled = useUIStore((state) => state.setAgentMemoryToolEnabled);
const handleAgentControlToolChange = React.useCallback((enabled: boolean) => {
setAgentControlToolEnabled(enabled);
@@ -40,6 +46,24 @@ export const OpenChamberToolsSettings: React.FC = () => {
recordDeferredOpenCodeRestart('cli', { id: 'agent-web-tool' });
}, [setAgentWebToolEnabled]);
// Turning memory off removes the whole feature, not just the tool: the panel
// tab goes with it and sessions stop being given the index. Showing the user
// what is stored would be pointless once the agent can no longer manage it.
const handleAgentMemoryToolChange = React.useCallback((enabled: boolean) => {
setAgentMemoryToolEnabled(enabled);
// Re-read after the write lands, not before. The switch flips the client
// immediately, which makes the panel ask the server straight away — and
// while the setting is still being written the server truthfully answers
// "disabled", which used to leave the tab hidden until a restart.
void updateDesktopSettings({ agentMemoryToolEnabled: enabled })
.finally(() => {
if (enabled) {
void useAgentMemoryStore.getState().refresh();
}
});
recordDeferredOpenCodeRestart('cli', { id: 'agent-memory-tool' });
}, [setAgentMemoryToolEnabled]);
return (
<SettingsSection title={t('settings.openchamber.tools.title')}>
<div className={SETTINGS_OPTION_STACK_CLASS}>
@@ -60,6 +84,17 @@ export const OpenChamberToolsSettings: React.FC = () => {
ariaLabel={t('settings.openchamber.tools.field.agentWebToolAria')}
info={t('settings.openchamber.tools.field.agentWebToolInfo')}
/>
{agentMemoryAvailable ? (
<SettingsCheckboxRow
settingsItem="sessions.agent-memory-tool"
checked={agentMemoryToolEnabled}
onChange={handleAgentMemoryToolChange}
label={t('settings.openchamber.tools.field.agentMemoryTool')}
ariaLabel={t('settings.openchamber.tools.field.agentMemoryToolAria')}
info={t('settings.openchamber.tools.field.agentMemoryToolInfo')}
/>
) : null}
</div>
</SettingsSection>
);
@@ -10,9 +10,11 @@ import {
} from '@/components/sections/shared/SettingsSection';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import {
CUSTOM_PROVIDER_PROTOCOLS,
createEmptyCustomProviderForm,
createHeaderRow,
createModelRow,
@@ -161,6 +163,31 @@ export const CustomProviderForm: React.FC<CustomProviderFormProps> = ({
{err.providerID ? <p className="mt-1 typography-meta text-[var(--status-error)]">{err.providerID}</p> : null}
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.protocol.label')}
info={t('settings.providers.page.custom.field.protocol.info')}
>
<Select
value={form.protocol}
onValueChange={(protocol) => {
if (!(protocol in CUSTOM_PROVIDER_PROTOCOLS)) {
return;
}
setForm((prev) => ({ ...prev, protocol }));
}}
disabled={busy}
>
<SelectTrigger aria-label={t('settings.providers.page.custom.field.protocol.label')} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai-chat">{t('settings.providers.page.custom.field.protocol.openaiChat')}</SelectItem>
<SelectItem value="openai-responses">{t('settings.providers.page.custom.field.protocol.openaiResponses')}</SelectItem>
<SelectItem value="anthropic-messages">{t('settings.providers.page.custom.field.protocol.anthropicMessages')}</SelectItem>
</SelectContent>
</Select>
</SettingsStackedField>
<SettingsStackedField
label={t('settings.providers.page.custom.field.name.label')}
info={t('settings.providers.page.custom.field.name.info')}
@@ -20,6 +20,7 @@ import {
firstUnansweredPrompt,
parseAuthPrompts,
parseAuthorization,
shouldOpenAuthorizationUrl,
visiblePrompts,
type AuthPrompt,
type OAuthAuthorization,
@@ -173,7 +174,10 @@ export const ProviderOAuthMethods: React.FC<ProviderOAuthMethodsProps> = ({
return;
}
if (authorization.url) {
// Claude Code CLI owns its OAuth flow and opens the browser itself. Its
// plugin URL is informational only; opening it creates a misleading docs
// tab alongside the real sign-in page.
if (authorization.url && shouldOpenAuthorizationUrl(providerId, authorization.url)) {
void openExternalUrl(authorization.url);
}
@@ -1,9 +1,10 @@
import { describe, expect, test } from 'bun:test';
import { shouldLoadAvailableProviders } from './providerAvailability';
import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAvailability';
import {
getOAuthAuthMethods,
normalizeAuthType,
parseAuthPayload,
requiresOpenCodeRestartAfterOAuth,
shouldShowApiKeyAuth,
} from './providerAuth';
@@ -14,6 +15,14 @@ describe('ProvidersPage available provider loading', () => {
});
});
describe('ProvidersPage provider authentication', () => {
test('does not require credentials for a custom provider defined in config', () => {
expect(requiresProviderAuth(true, false, true)).toBe(false);
expect(requiresProviderAuth(true, false, false)).toBe(true);
expect(requiresProviderAuth(true, true, false)).toBe(false);
});
});
describe('provider auth method helpers', () => {
test('normalizeAuthType recognizes oauth and api labels', () => {
expect(normalizeAuthType({ type: 'oauth', label: 'Login with Cursor' })).toBe('oauth');
@@ -57,4 +66,9 @@ describe('provider auth method helpers', () => {
{ method: { type: 'oauth', label: 'Cursor' }, methodIndex: 0 },
]);
});
test('Claude CLI OAuth does not require an OpenCode restart', () => {
expect(requiresOpenCodeRestartAfterOAuth('claude-code')).toBe(false);
expect(requiresOpenCodeRestartAfterOAuth('github-copilot')).toBe(true);
});
});
@@ -23,10 +23,11 @@ import type { ModelMetadata } from '@/types';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { opencodeClient } from '@/lib/opencode/client';
import { shouldLoadAvailableProviders } from './providerAvailability';
import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAvailability';
import {
getOAuthAuthMethods,
parseAuthPayload,
requiresOpenCodeRestartAfterOAuth,
shouldShowApiKeyAuth,
type AuthMethod,
type OAuthAuthMethodEntry,
@@ -323,7 +324,8 @@ export const ProvidersPage: React.FC = () => {
? provider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
: [];
const hasCreds = Boolean(sources.auth.exists) || envEntries.length > 0;
if (!hasCreds) {
const isCustomProvider = Boolean(provider && isConfigDefinedCustomProvider(provider, sources));
if (requiresProviderAuth(true, hasCreds, isCustomProvider)) {
setShowAuthPanel(true);
}
}, [selectedProviderId, providerSources, providers]);
@@ -471,7 +473,9 @@ export const ProvidersPage: React.FC = () => {
const handleOAuthConnected = (providerId: string) => {
setShowAuthPanel(false);
recordDeferredOpenCodeRestart('providers', { id: providerId });
if (requiresOpenCodeRestartAfterOAuth(providerId)) {
recordDeferredOpenCodeRestart('providers', { id: providerId });
}
setSelectedProvider(providerId);
};
@@ -770,8 +774,12 @@ export const ProvidersPage: React.FC = () => {
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
const hasEnvCredentials = providerEnv.length > 0;
const hasCredentials = hasStoredAuth || hasEnvCredentials;
const authStatusIncomplete = sourcesLoaded && !hasCredentials;
const showModelsSection = providerModels.length > 0 && (!sourcesLoaded || hasCredentials);
const authStatusIncomplete = requiresProviderAuth(
sourcesLoaded,
hasCredentials,
isEditableCustomProvider,
);
const showModelsSection = providerModels.length > 0 && !authStatusIncomplete;
const incompleteAuthHint = !showApiKeyAuth && oauthAuthMethods.length > 0
? t('settings.providers.page.auth.useReconnectHint')
: t('settings.providers.page.auth.incompleteHint');
@@ -16,6 +16,7 @@ const t = (key: string) => key;
const baseForm = (overrides: Partial<CustomProviderFormState> = {}): CustomProviderFormState => ({
providerID: 'custom-provider',
name: 'Custom Provider',
protocol: 'openai-chat',
baseURL: 'https://api.example.com/v1',
apiKey: 'sk-test',
models: [{ row: 'm0', id: 'model-a', name: 'Model A' }],
@@ -96,6 +97,16 @@ describe('validateCustomProvider', () => {
expect(result.result?.config.env).toEqual(['CUSTOM_PROVIDER_KEY']);
});
test('uses the selected OpenCode provider adapter', () => {
const result = validateCustomProvider({
form: baseForm({ protocol: 'openai-responses' }),
t,
existingProviderIDs: new Set(),
});
expect(result.result?.config.npm).toBe('@ai-sdk/openai');
});
test('rejects missing credentials', () => {
const result = validateCustomProvider({
form: baseForm({ apiKey: ' ' }),
@@ -300,10 +311,21 @@ describe('provider edit helpers', () => {
expect(state.name).toBe('Campus LLM');
expect(state.baseURL).toBe('https://llm.example.edu/v1');
expect(state.apiKey).toBe('{env:CAMPUS_KEY}');
expect(state.protocol).toBe('openai-chat');
expect(state.models[0]).toEqual({ row: state.models[0].row, id: 'fast', name: 'Fast' });
expect(state.headers[0]).toEqual({ row: state.headers[0].row, key: 'X-Campus', value: '1' });
});
test('prefills the protocol from a custom provider model', () => {
const state = providerToCustomFormState({
id: 'responses-api',
options: { baseURL: 'https://api.example.com/v1' },
models: [{ id: 'gpt', name: 'GPT', api: { npm: '@ai-sdk/openai' } }],
});
expect(state.protocol).toBe('openai-responses');
});
test('requires a config-layer source before treating a provider as editable custom', () => {
const catalogLike = {
id: 'openai',
@@ -1,10 +1,16 @@
/**
* Custom / Other OpenAI-compatible provider form helpers.
* Custom provider form helpers.
* Mirrors OpenCode web UI validation and request construction so a provider
* can be defined from Settings without code changes.
*/
export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible';
export const CUSTOM_PROVIDER_PROTOCOLS = {
'openai-chat': '@ai-sdk/openai-compatible',
'openai-responses': '@ai-sdk/openai',
'anthropic-messages': '@ai-sdk/anthropic',
} as const;
export type CustomProviderProtocol = keyof typeof CUSTOM_PROVIDER_PROTOCOLS;
export type CustomProviderNpm = (typeof CUSTOM_PROVIDER_PROTOCOLS)[CustomProviderProtocol];
export const CUSTOM_PROVIDER_ID = '__custom_provider__';
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
@@ -30,6 +36,7 @@ export type HeaderRow = {
export type CustomProviderFormState = {
providerID: string;
name: string;
protocol: CustomProviderProtocol;
baseURL: string;
apiKey: string;
models: ModelRow[];
@@ -54,7 +61,7 @@ export type HeaderFieldErrors = {
};
export type CustomProviderConfig = {
npm: typeof CUSTOM_PROVIDER_NPM;
npm: CustomProviderNpm;
name: string;
env?: string[];
options: {
@@ -120,12 +127,24 @@ export const createHeaderRow = (): HeaderRow => ({
export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({
providerID: '',
name: '',
protocol: 'openai-chat',
baseURL: '',
apiKey: '',
models: [createModelRow()],
headers: [createHeaderRow()],
});
function protocolFromNpm(npm: string | undefined): CustomProviderProtocol {
switch (npm) {
case '@ai-sdk/openai':
return 'openai-responses';
case '@ai-sdk/anthropic':
return 'anthropic-messages';
default:
return 'openai-chat';
}
}
function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
const trimmed = apiKey.trim();
if (!trimmed) {
@@ -159,7 +178,7 @@ export function isCustomOpenAICompatibleProvider(provider: ProviderLikeForCustom
const api = 'api' in model && model.api && typeof model.api === 'object'
? model.api as { npm?: unknown }
: null;
return typeof api?.npm === 'string' && api.npm === CUSTOM_PROVIDER_NPM;
return typeof api?.npm === 'string' && new Set<string>(Object.values(CUSTOM_PROVIDER_PROTOCOLS)).has(api.npm);
});
}
@@ -238,9 +257,14 @@ export function providerToCustomFormState(provider: ProviderLikeForCustomForm):
? provider.env.find((entry) => typeof entry === 'string' && entry.trim().length > 0)?.trim()
: undefined;
const modelWithApi = modelEntries.find(
(model): model is { id?: string; name?: string; api?: { npm?: string } } => 'api' in model,
);
return {
providerID: provider.id,
name: typeof provider.name === 'string' && provider.name.trim() ? provider.name : provider.id,
protocol: protocolFromNpm(modelWithApi?.api?.npm),
baseURL,
apiKey: envName ? `{env:${envName}}` : '',
models,
@@ -360,7 +384,7 @@ export function validateCustomProvider(input: ValidateCustomProviderInput): Vali
name,
apiKey: key,
config: {
npm: CUSTOM_PROVIDER_NPM,
npm: CUSTOM_PROVIDER_PROTOCOLS[input.form.protocol],
name,
...(env ? { env: [env] } : {}),
options: {
@@ -7,11 +7,19 @@ import {
isPromptVisible,
parseAuthPrompts,
parseAuthorization,
shouldOpenAuthorizationUrl,
visiblePrompts,
type AuthPrompt,
type ProviderOAuthTranslator,
} from './provider-oauth';
describe('shouldOpenAuthorizationUrl', () => {
test('lets Claude Code CLI own browser launch', () => {
expect(shouldOpenAuthorizationUrl('claude-code', 'https://docs.example')).toBe(false);
expect(shouldOpenAuthorizationUrl('github-copilot', 'https://github.com/login')).toBe(true);
});
});
/** Mirrors the github-copilot auth method shipped by OpenCode. */
const copilotPrompts = [
{
@@ -29,6 +29,9 @@ export interface OAuthAuthorization {
userCode?: string;
}
export const shouldOpenAuthorizationUrl = (providerId: string, url?: string): boolean =>
Boolean(url) && providerId !== 'claude-code';
export interface AuthPromptOption {
label: string;
value: string;
@@ -57,3 +57,6 @@ export const getOAuthAuthMethods = (methods: AuthMethod[]): OAuthAuthMethodEntry
methods
.map((method, methodIndex) => ({ method, methodIndex }))
.filter(({ method }) => normalizeAuthType(method) === 'oauth');
export const requiresOpenCodeRestartAfterOAuth = (providerId: string): boolean =>
providerId !== 'claude-code';
@@ -1 +1,7 @@
export const shouldLoadAvailableProviders = (isAddMode: boolean): boolean => isAddMode;
export const requiresProviderAuth = (
sourcesLoaded: boolean,
hasCredentials: boolean,
isConfigDefinedCustomProvider: boolean,
): boolean => sourcesLoaded && !hasCredentials && !isConfigDefinedCustomProvider;
@@ -12,9 +12,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
const formatProjectLabel = (label: string): string => {
return label.replace(/[-_]/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
};
const formatProjectLabel = (label: string): string => label.trim();
export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ className }) => {
const { t } = useI18n();
@@ -439,12 +439,6 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
}) => {
const { t } = useI18n();
const isMobile = isMobileDeviceViaCSS();
const sourceLabel = skill.source === 'claude'
? t('settings.skills.sidebar.badge.claude')
: skill.source === 'agents'
? t('settings.skills.sidebar.badge.agents')
: t('settings.skills.sidebar.badge.opencode');
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
const isBuiltIn = isBuiltInSkill(skill);
const canRename = isRenamableSkill(skill);
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
@@ -479,10 +473,6 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
<span className="typography-ui-label font-normal truncate text-foreground">
{skill.name}
</span>
<span className={badgeClassName}>
{skill.scope}
</span>
<span className={badgeClassName}>{sourceLabel}</span>
</div>
</button>
@@ -127,24 +127,13 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
directoryOverride?: string | null;
conflictDecisions?: Record<string, ConflictDecision>;
}) => {
// Build selection with clawdhub metadata if present
const selection: { skillDir: string; clawdhub?: { slug: string; version: string } } = {
skillDir: request.skillDir,
};
if (item?.clawdhub) {
selection.clawdhub = {
slug: item.clawdhub.slug,
version: item.clawdhub.version,
};
}
const result = await installSkills({
source: request.source,
subpath: request.subpath,
gitIdentityId: item?.gitIdentityId,
scope: request.scope,
targetSource: request.targetSource,
selections: [selection],
selections: [{ skillDir: request.skillDir }],
conflictPolicy: 'prompt',
conflictDecisions: request.conflictDecisions,
}, { directory: request.directoryOverride ?? null });
@@ -4,11 +4,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SETTINGS_SELECT_SIZE,
SETTINGS_SELECT_TRIGGER_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import {
Dialog,
@@ -18,24 +14,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import { Icon } from '@/components/icon/Icon';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import type { SkillsCatalogItem } from '@/lib/api/types';
import type { SkillsCatalogItem, SkillsCatalogSource } from '@/lib/api/types';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { updateDesktopSettings } from '@/lib/persistence';
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { AddCatalogDialog } from './AddCatalogDialog';
import { InstallSkillDialog } from './InstallSkillDialog';
@@ -48,6 +36,71 @@ interface SkillsCatalogPageProps {
showModeTabs?: boolean;
}
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const getRepoUrl = (source: string): string | null => {
const trimmed = source.trim();
if (!GITHUB_REPO_PATTERN.test(trimmed)) {
return null;
}
return `https://github.com/${trimmed}`;
};
const getSkillUrl = (item: SkillsCatalogItem): string | null => {
const repoUrl = getRepoUrl(item.repoSource);
if (!repoUrl) {
return null;
}
const skillPath = [item.repoSubpath, item.skillDir].filter(Boolean).join('/');
return skillPath ? `${repoUrl}/tree/HEAD/${skillPath}` : repoUrl;
};
let cachedStarsFormatter: { locale: string; formatter: Intl.NumberFormat } | null = null;
const formatStars = (stars: number): string => {
const locale = getCurrentIntlLocale();
if (!cachedStarsFormatter || cachedStarsFormatter.locale !== locale) {
cachedStarsFormatter = { locale, formatter: new Intl.NumberFormat(locale, { notation: 'compact' }) };
}
return cachedStarsFormatter.formatter.format(stars);
};
type RelativeTimeKey =
| 'common.relative.justNow'
| 'common.relative.minutesAgoShort'
| 'common.relative.hoursAgoShort'
| 'common.relative.daysAgoShort'
| 'common.relative.weeksAgoShort'
| 'common.relative.yearsAgoShort';
const formatRelativeShort = (isoDate: string): { key: RelativeTimeKey; count: number } | null => {
const timestamp = Date.parse(isoDate);
if (Number.isNaN(timestamp)) {
return null;
}
const diffMs = Date.now() - timestamp;
if (diffMs < 60_000) {
return { key: 'common.relative.justNow', count: 0 };
}
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) {
return { key: 'common.relative.minutesAgoShort', count: minutes };
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return { key: 'common.relative.hoursAgoShort', count: hours };
}
const days = Math.floor(hours / 24);
if (days < 7) {
return { key: 'common.relative.daysAgoShort', count: days };
}
const weeks = Math.floor(days / 7);
if (weeks < 52) {
return { key: 'common.relative.weeksAgoShort', count: weeks };
}
return { key: 'common.relative.yearsAgoShort', count: Math.floor(days / 365) };
};
const loadSettings = async (): Promise<DesktopSettings | null> => {
try {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
@@ -71,6 +124,67 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
}
};
const SourceCard: React.FC<{
source: SkillsCatalogSource;
isActive: boolean;
isLoading: boolean;
skillsCount: number | null;
onSelect: () => void;
t: ReturnType<typeof useI18n>['t'];
}> = ({ source, isActive, isLoading, skillsCount, onSelect, t }) => {
const stars = source.stars ?? null;
const updated = source.repoUpdatedAt ? formatRelativeShort(source.repoUpdatedAt) : null;
return (
<button
type="button"
onClick={onSelect}
aria-pressed={isActive}
className={cn(
'w-full min-h-24 text-left rounded-lg border bg-[var(--surface-elevated)] p-3.5 flex gap-3 items-start transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive
? 'border-primary'
: 'border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)]'
)}
>
<span className="min-w-0 flex-1 block">
<span className="flex items-center gap-2">
<span className="typography-ui-label font-medium text-foreground truncate">{source.label}</span>
{isLoading ? (
<Icon name="refresh" className="h-3 w-3 animate-spin text-muted-foreground shrink-0" />
) : (
skillsCount !== null && (
<span className="typography-micro text-muted-foreground shrink-0">
{t('settings.skills.catalog.page.source.skillsCount', { count: skillsCount })}
</span>
)
)}
</span>
<span className="typography-micro font-mono text-muted-foreground block mt-0.5 truncate">{source.source}</span>
<span className="flex items-center gap-3 mt-1">
{stars !== null && (
<span
className="typography-micro text-muted-foreground flex items-center gap-1"
title={t('settings.skills.catalog.page.source.stars', { count: stars })}
>
<Icon name="star" className="h-3 w-3" />
{formatStars(stars)}
</span>
)}
{updated && (
<span className="typography-micro text-muted-foreground">
{updated.key === 'common.relative.justNow'
? t(updated.key)
: t('settings.skills.catalog.page.source.updated', { time: t(updated.key, { count: updated.count }) })}
</span>
)}
</span>
</span>
</button>
);
};
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
const { t } = useI18n();
const {
@@ -80,12 +194,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
setSelectedSource,
loadCatalog,
loadSource,
loadMoreClawdHub,
isLoadingCatalog,
isLoadingSource,
isLoadingMore,
loadedSourceIds,
clawdhubHasMoreBySource,
lastCatalogError,
} = useSkillsCatalogStore(useShallow((s) => ({
sources: s.sources,
@@ -94,12 +205,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
setSelectedSource: s.setSelectedSource,
loadCatalog: s.loadCatalog,
loadSource: s.loadSource,
loadMoreClawdHub: s.loadMoreClawdHub,
isLoadingCatalog: s.isLoadingCatalog,
isLoadingSource: s.isLoadingSource,
isLoadingMore: s.isLoadingMore,
loadedSourceIds: s.loadedSourceIds,
clawdhubHasMoreBySource: s.clawdhubHasMoreBySource,
lastCatalogError: s.lastCatalogError,
})));
@@ -109,43 +217,72 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
const [installItem, setInstallItem] = React.useState<SkillsCatalogItem | null>(null);
const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false);
const [isRemoveCatalogDialogOpen, setIsRemoveCatalogDialogOpen] = React.useState(false);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
void loadCatalog();
}, [loadCatalog]);
// Load every source in the background so global search covers all of them.
React.useEffect(() => {
if (!selectedSourceId) {
const unloaded = sources.filter((src) => !loadedSourceIds[src.id]);
if (unloaded.length === 0) {
return;
}
if (!loadedSourceIds[selectedSourceId]) {
void loadSource(selectedSourceId);
let cancelled = false;
const loadRest = async () => {
for (const src of unloaded) {
if (cancelled) {
return;
}
await loadSource(src.id);
}
};
void loadRest();
return () => {
cancelled = true;
};
}, [sources, loadedSourceIds, loadSource]);
React.useEffect(() => {
if (!selectedSourceId || loadedSourceIds[selectedSourceId]) {
return;
}
void loadSource(selectedSourceId);
}, [selectedSourceId, loadedSourceIds, loadSource]);
const items = React.useMemo(() => {
if (!selectedSourceId) return [];
return itemsBySource[selectedSourceId] || [];
}, [itemsBySource, selectedSourceId]);
React.useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
const isSearching = search.trim().length > 0;
const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return items;
return items.filter((item) => {
const name = item.skillName.toLowerCase();
const desc = (item.description || '').toLowerCase();
const fm = (item.frontmatterName || '').toLowerCase();
return name.includes(q) || desc.includes(q) || fm.includes(q);
});
}, [items, search]);
const matches = (item: SkillsCatalogItem) =>
item.skillName.toLowerCase().includes(q)
|| (item.description || '').toLowerCase().includes(q)
|| (item.frontmatterName || '').toLowerCase().includes(q);
if (isSearching) {
return sources.flatMap((src) => (itemsBySource[src.id] || []).filter(matches));
}
if (!selectedSourceId) {
return [];
}
return itemsBySource[selectedSourceId] || [];
}, [sources, itemsBySource, selectedSourceId, search, isSearching]);
const selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]);
const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:'));
const isClawdHubSource = selectedSource?.source === 'clawdhub:registry' || selectedSource?.sourceType === 'clawdhub';
const hasMoreClawdHub = Boolean(
selectedSourceId && (clawdhubHasMoreBySource[selectedSourceId] ?? true)
);
const removeSelectedCatalog = async () => {
if (!selectedSourceId || !isCustomSource) {
@@ -165,6 +302,17 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
}
};
const listTitle = isSearching
? t('settings.skills.catalog.page.list.searchTitle')
: (selectedSource?.label ?? '');
// The selected source has no items yet and a load is in flight — show the
// loading state instead of a stale list from the previously selected source.
const isSelectedSourceLoading = !isSearching
&& selectedSourceId !== null
&& !loadedSourceIds[selectedSourceId]
&& (isLoadingSource || isLoadingCatalog);
return (
<>
<SettingsPageLayout
@@ -190,90 +338,74 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</div>
)}
<p className="typography-meta text-muted-foreground mb-4">
{t('settings.skills.catalog.page.subtitle')}
</p>
<div data-settings-item="skills.catalog.search" className="mb-5">
<div className="relative max-w-md">
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('settings.skills.catalog.page.searchAllPlaceholder')}
className={cn('h-8 pl-8 w-full', search && 'pr-8')}
/>
{search && (
<button
type="button"
onClick={() => {
setSearch('');
searchInputRef.current?.focus();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-4 w-4 rounded text-muted-foreground hover:text-foreground transition-colors"
title={t('settings.skills.catalog.page.search.clear')}
>
<Icon name="close" className="h-3 w-3" />
</button>
)}
</div>
</div>
<SettingsSection
title={t('settings.skills.catalog.page.section.sourceRepository')}
title={t('settings.skills.catalog.page.section.sources')}
divider={false}
settingsItem="skills.catalog.source"
contentClassName="space-y-0"
>
<div className="flex flex-wrap items-center gap-2 py-1.5">
<Select
value={selectedSourceId || ''}
onValueChange={(v) => setSelectedSource(v)}
>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'w-fit')}>
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')}>
{selectedSource?.label}
</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{sources.map((src) => (
<SelectItem key={src.id} value={src.id}>
{src.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 py-1.5">
{sources.map((src) => (
<SourceCard
key={src.id}
source={src}
isActive={src.id === selectedSourceId}
isLoading={isLoadingSource && !loadedSourceIds[src.id]}
skillsCount={loadedSourceIds[src.id] ? (itemsBySource[src.id] || []).length : null}
onSelect={() => setSelectedSource(src.id)}
t={t}
/>
))}
<Button
variant="outline"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => {
if (selectedSourceId) {
void loadSource(selectedSourceId, { refresh: true });
} else {
void loadCatalog({ refresh: true });
}
}}
disabled={isLoadingCatalog || isLoadingSource}
title={t('settings.skills.catalog.page.actions.refreshTitle')}
>
<Icon name="refresh" className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
</Button>
{isCustomSource && (
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setIsRemoveCatalogDialogOpen(true)}
disabled={isRemovingCatalog}
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</Button>
)}
<Button
data-settings-item="skills.catalog.add-catalog"
size="xs"
className="!font-normal gap-1"
onClick={() => setAddCatalogOpen(true)}
>
<Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.catalog.page.actions.addCatalog')}
</Button>
</div>
<div data-settings-item="skills.catalog.search" className="py-1.5">
<div className="relative">
<Icon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
className="h-7 pl-8 w-full sm:w-64"
/>
</div>
<span className="typography-meta text-muted-foreground mt-1 block">
{isLoadingCatalog
? t('settings.skills.catalog.page.loading.catalog')
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
<button
type="button"
data-settings-item="skills.catalog.add-catalog"
onClick={() => setAddCatalogOpen(true)}
className="min-h-24 text-left rounded-lg border border-dashed border-[var(--interactive-border)] hover:border-[var(--interactive-border-hover)] hover:bg-[var(--surface-muted)] p-3.5 flex gap-3 items-start transition-colors"
>
<span className="flex items-center justify-center rounded-md bg-transparent text-muted-foreground w-8 h-8 shrink-0">
<Icon name="add" className="h-4 w-4" />
</span>
</div>
<span className="min-w-0">
<span className="typography-ui-label text-muted-foreground block">
{t('settings.skills.catalog.page.source.addOwnTitle')}
</span>
<span className="typography-micro text-muted-foreground/70 block mt-0.5">
{t('settings.skills.catalog.page.source.addOwnDescription')}
</span>
</span>
</button>
</div>
</SettingsSection>
{lastCatalogError && (
@@ -286,21 +418,63 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
)}
<SettingsSection>
{filtered.length === 0 && !isLoadingSource ? (
<div className="py-8 text-center text-muted-foreground">
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
</div>
) : isLoadingSource ? (
<div className="flex items-center justify-between gap-2 pb-2">
<div className="flex items-center gap-2 min-w-0">
<span className="typography-micro font-medium uppercase tracking-wide text-muted-foreground truncate">
{listTitle}
</span>
<span className="typography-micro text-muted-foreground/70 shrink-0">
{t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => {
if (selectedSourceId && !isSearching) {
void loadSource(selectedSourceId, { refresh: true });
} else {
void loadCatalog({ refresh: true });
}
}}
disabled={isLoadingCatalog || isLoadingSource}
title={t('settings.skills.catalog.page.actions.refreshTitle')}
>
<Icon name="refresh" className={cn('h-3.5 w-3.5', (isLoadingCatalog || isLoadingSource) && 'animate-spin')} />
</Button>
{isCustomSource && !isSearching && (
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setIsRemoveCatalogDialogOpen(true)}
disabled={isRemovingCatalog}
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{isSelectedSourceLoading || (isLoadingSource && filtered.length === 0) ? (
<div className="py-8 text-center text-muted-foreground">
<Icon name="refresh" className="mx-auto mb-3 h-5 w-5 animate-spin opacity-50" />
<p className="typography-meta">{t('settings.skills.catalog.page.loading.skills')}</p>
</div>
) : filtered.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
</div>
) : (
<div className="divide-y divide-[var(--surface-subtle)]">
{filtered.map((item) => {
const installed = item.installed?.isInstalled;
const installedScope = item.installed?.scope;
const skillUrl = getSkillUrl(item);
return (
<div key={`${item.sourceId}:${item.skillDir}`} className="py-2">
@@ -326,24 +500,28 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">{t('settings.skills.catalog.shared.noDescription')}</div>
)}
{item.clawdhub && (
<div className="typography-micro text-muted-foreground mt-1.5 flex items-center gap-3">
{item.clawdhub.owner && (
<span>{t('settings.skills.catalog.page.byOwnerPrefix')} <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
)}
<span className="flex items-center gap-1">
<Icon name="download" className="h-3 w-3" />
{item.clawdhub.downloads?.toLocaleString() ?? 0}
</span>
{(item.clawdhub.stars ?? 0) > 0 && (
<span className="flex items-center gap-1">
<Icon name="star" className="h-3 w-3" />
{item.clawdhub.stars}
</span>
)}
<span className="bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">v{item.clawdhub.version}</span>
</div>
)}
<div className="typography-micro text-muted-foreground/80 mt-1 flex items-center gap-2 min-w-0">
{skillUrl ? (
<a
href={skillUrl}
target="_blank"
rel="noreferrer"
className="font-mono hover:underline truncate inline-flex items-center gap-1"
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
>
<Icon name="github" className="h-3 w-3 shrink-0" />
{item.repoSource}
</a>
) : (
<span className="font-mono truncate">{item.repoSource}</span>
)}
{item.skillDir && (
<>
<span className="opacity-40">·</span>
<span className="truncate">{item.skillDir}</span>
</>
)}
</div>
{item.warnings?.length ? (
<div className="typography-micro text-[var(--status-warning)] mt-1.5 bg-[var(--status-warning)]/10 px-2 py-1 rounded w-fit">
@@ -352,37 +530,43 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
) : null}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal shrink-0"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
{t('settings.skills.catalog.shared.actions.install')}
</Button>
<div className="flex items-center gap-1.5 shrink-0">
{skillUrl && (
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => window.open(skillUrl, '_blank', 'noreferrer')}
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
>
<Icon name="external-link" className="h-3.5 w-3.5" />
</Button>
)}
{installed ? (
<span className="text-[var(--status-success)] flex items-center justify-center w-7 h-7" title={t('settings.skills.catalog.page.badge.installed', { scope: installedScope || '' })}>
<Icon name="check" className="h-4 w-4" />
</span>
) : (
<Button
variant="outline"
size="xs"
className="!font-normal"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
{t('settings.skills.catalog.shared.actions.install')}
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
<div className="flex justify-center mt-2">
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void loadMoreClawdHub()}
disabled={isLoadingMore}
>
{isLoadingMore ? t('settings.skills.catalog.page.loading.more') : t('settings.skills.catalog.page.actions.loadMoreSkills')}
</Button>
</div>
)}
</SettingsSection>
</SettingsPageLayout>
@@ -162,6 +162,11 @@ export const UsagePage: React.FC = () => {
description={
isLoading ? (
<span className="animate-pulse typography-settings-description text-muted-foreground">{t('settings.usage.page.header.refreshing')}</span>
) : selectedResult?.planLabel ? (
t('settings.usage.page.header.lastUpdatedWithPlan', {
plan: selectedResult.planLabel,
time: formatTime(lastUpdated, timeFormatPreference),
})
) : (
t('settings.usage.page.header.lastUpdated', { time: formatTime(lastUpdated, timeFormatPreference) })
)
@@ -17,7 +17,7 @@ const ArchiveAllDropdown: React.FC<ArchiveAllDropdownProps> = ({ onArchiveAll })
const { t } = useI18n();
return (
<DropdownMenu>
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
@@ -98,20 +98,6 @@ const normalizeBranchName = (value: string): string => {
.replace(/^\/+|\/+$/g, '');
};
const slugifyWorktreeName = (value: string): string => {
return value
.trim()
.replace(/^refs\/heads\//, '')
.replace(/^heads\//, '')
.replace(/\s+/g, '-')
.replace(/^\/+|\/+$/g, '')
.split('/').join('-')
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
};
const sanitizeRemoteName = (value: string): string => {
const normalized = String(value || '')
.trim()
@@ -165,10 +151,14 @@ const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: st
const ownerFromLabel = String(pr.headLabel || '').split(':')[0]?.trim();
const remoteSeed = pr.headRepo?.owner || ownerFromLabel || 'pr-head';
const remoteName = `pr-${sanitizeRemoteName(remoteSeed)}`;
const remoteUrl = pr.headRepo?.sshUrl || pr.headRepo?.cloneUrl || '';
// Prefer HTTPS so anonymous public fetches do not require SSH agent setup.
const remoteUrl = pr.headRepo?.cloneUrl || pr.headRepo?.sshUrl || '';
if (!remoteUrl) {
throw new Error('PR head repository URL is unavailable');
throw new Error(
'PR head repository URL is unavailable. The fork may have been deleted; '
+ 'push the branch to a reachable repository and try again.'
);
}
return {
@@ -182,6 +172,20 @@ const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: st
};
};
const slugifyWorktreeName = (value: string): string => {
return value
.trim()
.replace(/^refs\/heads\//, '')
.replace(/^heads\//, '')
.replace(/\s+/g, '-')
.replace(/^\/+|\/+$/g, '')
.split('/').join('-')
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
};
interface NewWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -898,7 +902,7 @@ export function NewWorktreeDialog({
...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}),
};
})();
const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args);
const metadata = await createWorktree(projectRef, resolvedArgs);
File diff suppressed because it is too large Load Diff
@@ -1471,12 +1471,16 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
}
const key = getGitHubPrStatusKey(directory, branch);
const entry = useGitHubPrStatusStore.getState().entries[key];
const hasPr = Boolean(entry?.status?.pr);
const prState = entry?.status?.pr?.state;
const isTerminalPr = prState === 'closed' || prState === 'merged';
// Closed/merged associations are not live branch status — retry them on
// the same cadence as missing PRs so a newer open PR can appear.
const hasLivePr = Boolean(entry?.status?.pr) && !isTerminalPr;
const retryKey = `${directory}::${branch}`;
const noPrLastCheckedAt = Math.max(entry?.lastRefreshAt ?? 0, entry?.lastDiscoveryPollAt ?? 0);
const shouldRetryNoPr = Boolean(
entry?.isInitialStatusResolved
&& !hasPr
&& !hasLivePr
&& (
!retriedNoPrStatusKeysRef.current.has(retryKey)
|| now - noPrLastCheckedAt >= SIDEBAR_PR_NO_PR_RETRY_MS
@@ -0,0 +1,221 @@
# Project Context Panel
Notes, todos, saved plans, and agent memory for the active project. Rendered by
the `notes` surface in the desktop context rail and by the mobile workspace
drawer.
## Files
| File | Owns |
|---|---|
| `ProjectNotesTodoPanel.tsx` | container: store subscription, load, failure toast, section sidebar, search query, the todo write |
| `NotesSection.tsx` | note composer, note list, per-note edit/pin/delete |
| `TodosSection.tsx` | todo list, add/toggle/delete/clear, drag reorder, list resize |
| `PlansSection.tsx` | plan list, import, pin, delete, open |
| `MemorySection.tsx` | agent memory list, project/global scope switch, new/changed badges, edit, forget |
| `KnowledgeCard.tsx` | the shared card shell and expand interaction every entry list uses |
| `useProjectTodoSend.ts` | sending a todo to a current/new/worktree session |
## Layout
Content on the left, a section sidebar on the right with a drag-to-resize edge —
the same arrangement the files surface uses, so the two panels do not disagree
about where navigation lives. The sections were a horizontal tab strip until four of them stopped
fitting: a strip has one line of width to divide, and each section added took
width from the rest, while a vertical list grows downwards where there is room.
The surface's default width matches the files surface for the same reason; at a
third of the window the content column is too narrow to read a note in.
Search shares the title row rather than owning one of its own: it filters what
is already on screen, and a full-width field read as the panel's primary control.
It stays above both columns. Sections divide, and search is the one thing
that division would hurt — you do not always remember whether something was
written as a note or lives in a plan — so each sidebar entry carries its own
match count.
## One card, one interaction
Every entry list renders `KnowledgeCard`. Notes and memories had drifted into
two different-looking rows in the same panel — one a bare block of text opened by
clicking the text, the other a bordered card opened by a chevron — which is the
kind of split that makes a panel feel unfinished regardless of how either half
behaves.
A collapsed card opens on a click anywhere on it. An expanded card closes only
through its collapse action, because its body is editable and a stray click in
the text must not throw the editor away.
## Plans open in place
Clicking a plan replaces the list with its editor, and the back control appears
in the panel header beside the project name — PlanView titles the plan itself, so
a title row above it would say the same thing twice. A plan belongs to the project this
panel is about, and sending the reader to another tab to read it made them leave
the surface they were browsing.
The editor is `PlanView`, lazily imported — it is a large view and most panel
visits never open one. It scrolls itself, so the content column stops scrolling
while a plan is open; two scrollbars for one document is what nesting them gives.
Leaving the section or the project closes it, so its editor never sits over a
list it no longer matches. Hosts that own a fullscreen plan surface (mobile)
still pass `onOpenPlan` and keep theirs.
## Pins belong to one session
Notes and plans are project data, but attaching one writes its id to the current
session metadata. Other sessions in the project do not inherit it. A pin made
while a new-session draft is open lives on that draft and transfers only to the
session created by its first message. Work status lists and detaches draft pins
before that first message, then reads them from the created session metadata.
## Memory is not a fifth kind of note
The first four tabs hold what the user wrote. Memory holds what the **agent**
wrote for itself, in its own store (`packages/web/server/lib/agent-memory`) and
through its own client (`useAgentMemoryStore`). They share the panel and nothing
else — keeping the stores apart is what stops an agent mistake from landing in
the user's notes.
Two consequences shape this tab:
- **Entries are editable.** A memory worded badly enough to mislead should be
fixable where it is read; deleting it and hoping the agent learns it again,
better, is not a repair. The agent rewrites by saving the same memory again,
so `PATCH` exists for the panel alone.
- **Nothing gates the agent, and nothing asks the user to click.** An earlier
version had a confirm button. It was theatre: the agent already had the
memory whether or not the button was pressed, so the click bought the user
nothing. Entries now carry `new` and `changed` badges derived from
`createdAt` / `updatedAt` against a per-scope "last looked" mark, and looking
at the tab is the acknowledgement. Nothing about review is stored server-side.
- **The scopes are a switch, never one merged list.** A claim about the user
reaches every project, so which store an entry sits in is the most important
thing about it and must not be something the reader has to infer. The switch
is a chip group, not a tab strip: it picks which store you are reading, not
which view you are in, and the pressed state reads plainly against the
panel background.
The mark is frozen while the tab is open and advanced on the way out, or every
badge would clear the instant the tab appeared — the one moment the user is
trying to read them. Each project keeps its own mark, so opening one project
cannot silently clear another's badges.
The store is loaded by `useAgentMemorySync` in `App.tsx` and reloads on
`openchamber:agent-memory-changed`, because the agent writes mid-turn through
its own tool. It feeds this panel only — what a session is told about memory is
decided server-side by `packages/web/server/lib/session-knowledge`, so it
reaches sessions that have no UI at all and survives compaction.
Both sides resolve a worktree to its project before touching the store — the
client through `resolveProjectForSessionDirectory`, the server through
`agent-memory/project-resolution`. Keying by the session directory instead filed
a worktree's memories under a project nothing reads.
Turning the switch back on re-reads the store only after the setting has
finished being written. The switch flips the client immediately, which makes the
panel ask the server straight away — and mid-write the server truthfully answers
"disabled", which used to latch the tab hidden until a restart. Loads are also
sequenced, so that stale answer cannot land after the good one.
`agentMemoryToolEnabled` is one switch for the whole feature: it removes the
tool from the agent, this tab from the panel, and the index from new sessions.
The tab also hides when the server reports the surface disabled, so a stale
client cannot keep showing memory that is off. A persisted `memory` tab
selection falls back to `notes` rather than opening a tab that no longer exists.
## Data flow
Storage is server-owned; see
`packages/web/server/lib/project-context/DOCUMENTATION.md`. The panel never
touches `/api/fs/*` and never handles a plan path — plans are addressed by id.
```
useProjectContextStore -> ProjectNotesTodoPanel -> sections
(server cache) (load + shared write)
```
There is deliberately no cross-panel event. An earlier version broadcast
`openchamber:project-notes-updated` / `openchamber:project-plan-saved` on the
window and every mounted panel re-read the whole config in response. Writers now
mutate the store and readers re-render from it.
## Where writes live
Notes, todos, and plans each have their own routes, so each section owns its
writes end to end and no section has to persist a neighbour's state alongside
its own. `NotesSection` and `PlansSection` call the store directly. Todos still
route through the container only because the container already holds the list it
sorts for display.
An earlier version wrote notes and todos together in one request. That forced
the container to own the notes draft, because otherwise a todo toggle would
persist whatever notes were last committed and discard unsaved typing. Splitting
the routes removed the coupling rather than managing it.
## Layout
The three lists are tabs, not one stacked column. Stacking gave each list its
own scroller inside the panel's scroller, and it only got worse as lists grew —
the todo list had to carry a manual resize handle just to stay usable. With
tabs there is exactly one scroller: the panel's. The resize handle and its
persisted `todoPanelHeight` are gone with it, and each section renders its list
at natural height.
The host (`RightSidebarTabs`) therefore sets `overflow-hidden`; putting a
scroller there again would nest one inside the other.
Section headers no longer repeat their own name or count — the tab carries both.
The active tab persists in `useUIStore` so switching surfaces or remounting the
panel returns to where the user was.
## Search
One query in the container filters all three tabs, and the tab bar doubles as
the result summary: each tab shows its match count. Tabs divide, and search is
the one thing division would hurt — you do not always remember whether
something was written as a note or lives in a plan — so search deliberately
stays above the tabs rather than becoming per-tab.
If the active tab has no matches and another does, the panel follows the search
there. Without that, typing a query whose hits live elsewhere shows an empty
list and the user has to guess which tab to try.
Filtering is display-only: every mutation still acts on the full list, so
reordering or clearing completed todos while a filter is active cannot drop
hidden items. The query resets when the project changes, since a query that
matched the old project would silently hide everything in the new one.
## Invariants
- **Each note row keeps a local, debounced draft.** Writing on every keystroke
would put a request behind every character, and re-reading the store each
render would fight the caret.
- **An external note change is adopted only while that row is untouched** since
its last save. "Add to notes" from a chat selection must reach an open panel,
but must never overwrite what the user is typing.
- **Only one note is expanded at a time, and collapsed notes are clamped.**
Notes run to 3000 characters each; with the panel owning the only scroller,
unbounded rows turn the tab into one unbroken wall of text. A collapsed note
shows a three-line preview and expands into its editor on click.
- **A blanked note body is never persisted.** The server rejects it, so the row
restores its last saved text on blur rather than showing a phantom failure.
Deleting is an explicit action.
- **A load failure never blanks the panel.** The store keeps the last good
snapshot; the panel toasts once, and only when nothing had loaded yet.
- **Completed todos sink to the bottom for display only.** Stored order is what
the user dragged.
- **Plan creation is not optimistic.** The id and file name come from the
server, and a row that cannot be opened is worse than a brief wait.
## Pinned context
The pin toggle on a note or plan attaches it to the current session or draft.
Assembly and delivery live in `packages/web/server/lib/session-knowledge`.
## Related
- Store: `packages/ui/src/stores/useProjectContextStore.ts`
- HTTP client: `packages/ui/src/lib/projectContextApi.ts`
- Plan viewer/editor: `packages/ui/src/components/views/PlanView.tsx`
- User docs: `packages/docs/content/docs/notes-todos-plans.mdx`
@@ -0,0 +1,82 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
/**
* One entry in any project knowledge list.
*
* Notes and memories drifted into two different-looking rows in the same panel:
* one a bare block of text opened by clicking the text, the other a bordered
* card opened by a chevron. They hold different content but they are the same
* kind of thing to read, so they share this shell and this interaction.
*
* A collapsed card opens on a click anywhere on it the whole card is the
* target, not a chevron the user has to aim at. An expanded card closes only
* through its collapse action, because its body is editable and a stray click
* in the text must not throw the editor away.
*/
export const KnowledgeCard: React.FC<{
expanded: boolean;
onToggleExpanded: () => void;
/** Shown above the body: a badge, a title, whatever the section needs. */
header?: React.ReactNode;
/** The preview or the editor, depending on `expanded`. */
children: React.ReactNode;
/** Stacked to the right, so the text keeps the full row width. */
actions?: React.ReactNode;
footer?: React.ReactNode;
expandLabel: string;
}> = ({ expanded, onToggleExpanded, header, children, actions, footer, expandLabel }) => {
const { t } = useI18n();
return (
<li
className={cn(
'flex flex-col gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-1.5',
!expanded && 'cursor-pointer hover:border-[var(--interactive-border)] hover:bg-interactive-hover/30',
)}
onClick={expanded ? undefined : onToggleExpanded}
onKeyDown={expanded ? undefined : (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleExpanded();
}
}}
role={expanded ? undefined : 'button'}
tabIndex={expanded ? undefined : 0}
aria-label={expanded ? undefined : expandLabel}
>
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1">
{header}
{children}
</div>
{/* Stopped here rather than on each control: every action is a click on
the card too, and without this each one would also toggle it. */}
<div
className="flex flex-shrink-0 flex-col items-center gap-0.5"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{expanded ? (
<button
type="button"
onClick={onToggleExpanded}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.notes.actions.collapse')}
title={t('rightSidebar.contextNotesTodo.notes.actions.collapse')}
>
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
</button>
) : null}
{actions}
</div>
</div>
{footer ? <div className="min-w-0">{footer}</div> : null}
</li>
);
};
@@ -0,0 +1,277 @@
import React from 'react';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { KnowledgeCard } from './KnowledgeCard';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { AGENT_MEMORY_BODY_MAX_LENGTH, AGENT_MEMORY_TITLE_MAX_LENGTH, type AgentMemoryEntry, type AgentMemoryScope } from '@/lib/agentMemoryApi';
import { classifyMemory, memoryViewKey, type MemoryBadge } from '@/lib/agentMemoryBadges';
import { cn } from '@/lib/utils';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useUIStore } from '@/stores/useUIStore';
/**
* One stored memory.
*
* Read-only text on purpose: this is what the agent wrote, and the useful
* action on someone else's claim is to remove it, not to quietly rewrite it
* into something the agent will contradict next session.
*
* There is no confirm button. A badge that the user has to dismiss by hand asks
* them to do work that tells the agent nothing the agent already has the
* memory either way so the badge clears itself once they have looked.
*/
const MemoryRow: React.FC<{
entry: AgentMemoryEntry;
badge: MemoryBadge;
expanded: boolean;
onToggleExpanded: () => void;
onSave: (patch: { title?: string; body?: string }) => void;
onDelete: () => void;
}> = ({ entry, badge, expanded, onToggleExpanded, onSave, onDelete }) => {
const { t } = useI18n();
const [titleDraft, setTitleDraft] = React.useState(entry.title);
const [bodyDraft, setBodyDraft] = React.useState(entry.body);
// Adopt an external rewrite only while this row is not being edited, so the
// agent saving mid-edit cannot swallow what the user is typing.
React.useEffect(() => {
if (expanded) return;
setTitleDraft(entry.title);
setBodyDraft(entry.body);
}, [entry.body, entry.title, expanded]);
const commit = React.useCallback(() => {
const title = titleDraft.trim();
const body = bodyDraft.trim();
// An emptied field is a rejected write, not a delete: restore it rather
// than sending something the server will refuse.
if (!title || !body) {
setTitleDraft(entry.title);
setBodyDraft(entry.body);
return;
}
if (title === entry.title && body === entry.body) {
return;
}
onSave({ title, body });
}, [bodyDraft, entry.body, entry.title, onSave, titleDraft]);
const typeLabel = t(`rightSidebar.contextNotesTodo.memory.type.${entry.type}` as Parameters<typeof t>[0]);
return (
<KnowledgeCard
expanded={expanded}
onToggleExpanded={() => {
if (expanded) commit();
onToggleExpanded();
}}
expandLabel={entry.title}
footer={(
<span className="flex flex-wrap items-center gap-x-2 typography-micro text-muted-foreground">
{typeLabel}
{entry.flagged ? (
// Shown rather than hidden: an entry withheld from the agent is
// exactly the one the user needs to look at.
<span className="flex items-center gap-1 text-[var(--status-error)]">
<Icon name="error-warning" className="h-3 w-3 flex-shrink-0" />
{t('rightSidebar.contextNotesTodo.memory.flagged')}
</span>
) : null}
</span>
)}
header={badge ? (
<span
className={cn(
'mb-0.5 mr-1.5 inline-block rounded-full px-1.5 py-px typography-micro font-medium',
badge === 'new'
? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
: 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]',
)}
>
{t(badge === 'new'
? 'rightSidebar.contextNotesTodo.memory.badge.new'
: 'rightSidebar.contextNotesTodo.memory.badge.changed')}
</span>
) : null}
actions={(
<button
type="button"
onClick={onDelete}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.memory.actions.delete')}
title={t('rightSidebar.contextNotesTodo.memory.actions.delete')}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
)}
>
{expanded ? (
// Editable on purpose. A memory worded badly enough to mislead should
// be fixable where it is read; deleting it and hoping the agent learns
// it again, better, is not a repair.
<div className="flex flex-col gap-1">
<Input
value={titleDraft}
onChange={(event) => setTitleDraft(event.target.value.slice(0, AGENT_MEMORY_TITLE_MAX_LENGTH))}
onBlur={commit}
aria-label={t('rightSidebar.contextNotesTodo.memory.actions.editTitle')}
className="h-7 typography-ui-label"
/>
<Textarea
simple
rows={Math.min(20, Math.max(3, bodyDraft.split('\n').length + 1))}
value={bodyDraft}
onChange={(event) => setBodyDraft(event.target.value.slice(0, AGENT_MEMORY_BODY_MAX_LENGTH))}
onBlur={commit}
aria-label={t('rightSidebar.contextNotesTodo.memory.actions.editBody')}
className="min-h-0 w-full resize-none bg-transparent p-0 typography-meta leading-normal text-muted-foreground focus-visible:outline-none focus-visible:ring-0"
/>
</div>
) : (
<>
<span className="block min-w-0 truncate typography-ui-label text-foreground">{entry.title}</span>
<p className="line-clamp-2 whitespace-pre-wrap break-words typography-meta text-muted-foreground">
{entry.body}
</p>
</>
)}
</KnowledgeCard>
);
};
/**
* What the agent has chosen to remember, in the two scopes it writes to.
*
* The scopes are a switch rather than one merged list: a claim about the user
* reaches every project, so which store a memory sits in is the most important
* thing about it and must never be something the reader has to infer.
*/
export const MemorySection: React.FC<{
projectPath: string | null;
query: string;
}> = ({ projectPath, query }) => {
const { t } = useI18n();
const [scope, setScope] = React.useState<AgentMemoryScope>('project');
const [expandedId, setExpandedId] = React.useState<string | null>(null);
const globalEntries = useAgentMemoryStore((state) => state.global);
const projectEntries = useAgentMemoryStore((state) => state.project);
const globalFailed = useAgentMemoryStore((state) => state.globalFailed);
const projectFailed = useAgentMemoryStore((state) => state.projectFailed);
const deleteEntry = useAgentMemoryStore((state) => state.deleteEntry);
const saveEntry = useAgentMemoryStore((state) => state.saveEntry);
const markViewed = useUIStore((state) => state.markAgentMemoryViewed);
const entries = scope === 'global' ? globalEntries : projectEntries;
const scopeFailed = scope === 'global' ? globalFailed : projectFailed;
const viewKey = memoryViewKey(scope, projectPath);
const storedViewedAt = useUIStore((state) => state.agentMemoryViewedAt[viewKey] ?? 0);
/**
* The mark is frozen for the length of the visit and only advanced on the way
* out. Reading the live value would clear every badge the instant the tab
* opened, which is the one moment the user is trying to read them.
*/
const baselineRef = React.useRef(storedViewedAt);
const [baseline, setBaseline] = React.useState(storedViewedAt);
React.useEffect(() => {
baselineRef.current = useUIStore.getState().agentMemoryViewedAt[viewKey] ?? 0;
setBaseline(baselineRef.current);
return () => {
markViewed(viewKey, Date.now());
};
}, [markViewed, viewKey]);
const visibleEntries = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return entries;
return entries.filter((entry) => (
entry.title.toLowerCase().includes(needle) || entry.body.toLowerCase().includes(needle)
));
}, [entries, query]);
const handleDelete = React.useCallback(async (memoryId: string) => {
if (!await deleteEntry(scope, memoryId)) {
const detail = useAgentMemoryStore.getState().error;
toast.error(
t('rightSidebar.contextNotesTodo.memory.toast.deleteFailed'),
detail ? { description: detail } : undefined,
);
}
}, [deleteEntry, scope, t]);
const handleSave = React.useCallback(async (memoryId: string, patch: { title?: string; body?: string }) => {
if (!await saveEntry(scope, memoryId, patch)) {
const detail = useAgentMemoryStore.getState().error;
toast.error(
t('rightSidebar.contextNotesTodo.memory.toast.saveFailed'),
detail ? { description: detail } : undefined,
);
}
}, [saveEntry, scope, t]);
const scopeOptions: Array<{ id: AgentMemoryScope; label: string; count: number }> = [
{ id: 'project', label: t('rightSidebar.contextNotesTodo.memory.scope.project'), count: projectEntries.length },
{ id: 'global', label: t('rightSidebar.contextNotesTodo.memory.scope.global'), count: globalEntries.length },
];
return (
<div className="flex flex-col gap-2">
{/* Chips rather than a tab strip: these pick which store you are reading,
not which view you are in, and the chip's pressed state says which one
is selected far more plainly than a pill sitting on a matching
background did. */}
<div role="group" aria-label={t('rightSidebar.contextNotesTodo.memory.scope.label')} className="flex items-center gap-1">
{scopeOptions.map((option) => (
<Button
key={option.id}
type="button"
variant="chip"
size="xs"
aria-pressed={scope === option.id}
className="!font-normal"
onClick={() => setScope(option.id)}
>
{`${option.label} ${option.count}`}
</Button>
))}
</div>
{scope === 'project' && !projectPath ? (
<p className="typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.memory.empty.noProject')}
</p>
) : scopeFailed ? (
// Said plainly rather than shown as an empty list: an empty tab would
// read as the agent having forgotten everything it knew.
<p className="typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.memory.empty.unavailable')}
</p>
) : visibleEntries.length === 0 ? (
<p className="typography-meta text-muted-foreground">
{query.trim()
? t('rightSidebar.contextNotesTodo.memory.empty.noMatches')
: t('rightSidebar.contextNotesTodo.memory.empty.nothing')}
</p>
) : (
<ul className="flex flex-col gap-1.5">
{visibleEntries.map((entry) => (
<MemoryRow
key={entry.id}
entry={entry}
badge={classifyMemory(entry, baseline)}
expanded={expandedId === entry.id}
onToggleExpanded={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
onSave={(patch) => void handleSave(entry.id, patch)}
onDelete={() => void handleDelete(entry.id)}
/>
))}
</ul>
)}
</div>
);
};
@@ -0,0 +1,299 @@
import React from 'react';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { Textarea } from '@/components/ui/textarea';
import { KnowledgeCard } from './KnowledgeCard';
import { useI18n } from '@/lib/i18n';
import { PROJECT_NOTE_BODY_MAX_LENGTH, type ProjectNote, type ProjectRef } from '@/lib/projectContextApi';
import { cn } from '@/lib/utils';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useUIStore } from '@/stores/useUIStore';
const NOTE_SAVE_DEBOUNCE_MS = 400;
/**
* One note, edited in place.
*
* The draft is local and debounced: writing straight through on every keystroke
* would put a request behind every character, and re-reading the store on every
* render would fight the caret. The stored body is adopted only while the
* editor is untouched since its last save, so a concurrent write from another
* surface reaches an idle row without eating an active one.
*/
const NoteRow: React.FC<{
note: ProjectNote;
pinned: boolean;
expanded: boolean;
onToggleExpanded: () => void;
onSaveBody: (body: string) => void;
onTogglePinned: () => void;
onDelete: () => void;
}> = ({ note, pinned, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
const { t } = useI18n();
const [draft, setDraft] = React.useState(note.body);
const lastSavedRef = React.useRef(note.body);
const debounceRef = React.useRef<number | null>(null);
const cancelDebounce = React.useCallback(() => {
if (debounceRef.current !== null) {
window.clearTimeout(debounceRef.current);
debounceRef.current = null;
}
}, []);
React.useEffect(() => {
if (note.body === lastSavedRef.current) {
return;
}
if (draft !== lastSavedRef.current) {
return;
}
lastSavedRef.current = note.body;
setDraft(note.body);
}, [draft, note.body]);
React.useEffect(() => {
if (draft === lastSavedRef.current) {
return;
}
debounceRef.current = window.setTimeout(() => {
debounceRef.current = null;
// An empty body is a rejected write, not a delete. Leave it unsaved so
// the row stays visible and the user can either restore it or delete it.
if (!draft.trim()) {
return;
}
lastSavedRef.current = draft;
onSaveBody(draft);
}, NOTE_SAVE_DEBOUNCE_MS);
return cancelDebounce;
}, [cancelDebounce, draft, onSaveBody]);
React.useEffect(() => cancelDebounce, [cancelDebounce]);
const handleBlur = React.useCallback(() => {
cancelDebounce();
if (draft === lastSavedRef.current) {
return;
}
if (!draft.trim()) {
// Restore rather than persist a blank: the server rejects it anyway.
setDraft(lastSavedRef.current);
return;
}
lastSavedRef.current = draft;
onSaveBody(draft);
}, [cancelDebounce, draft, onSaveBody]);
const sourceLabel = note.source === 'selection'
? t('rightSidebar.contextNotesTodo.notes.source.selection')
: note.source === 'agent'
? t('rightSidebar.contextNotesTodo.notes.source.agent')
: null;
return (
<KnowledgeCard
expanded={expanded}
onToggleExpanded={onToggleExpanded}
expandLabel={t('rightSidebar.contextNotesTodo.notes.actions.expand')}
footer={sourceLabel ? (
<span className="typography-micro text-muted-foreground">{sourceLabel}</span>
) : null}
actions={(
<>
<button
type="button"
onClick={onTogglePinned}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={pinned}
aria-label={pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
title={pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
>
{/* Filled means pinned, outline means "pin this" the same
language the work status panel uses. */}
<Icon name={pinned ? 'pushpin-2-fill' : 'pushpin'} className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onDelete}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.notes.actions.delete')}
title={t('rightSidebar.contextNotesTodo.notes.actions.delete')}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
</>
)}
>
{expanded ? (
<Textarea
simple
autoFocus
rows={Math.min(20, Math.max(3, draft.split('\n').length + 1))}
value={draft}
onChange={(event) => setDraft(event.target.value.slice(0, PROJECT_NOTE_BODY_MAX_LENGTH))}
onBlur={handleBlur}
className="min-h-0 w-full resize-none bg-transparent p-0 typography-ui-label leading-normal text-foreground focus-visible:outline-none focus-visible:ring-0"
/>
) : (
<p className="line-clamp-3 whitespace-pre-wrap break-words typography-ui-label leading-normal text-foreground" title={draft}>
{draft}
</p>
)}
</KnowledgeCard>
);
};
/**
* Free-form project notes, one entry per note.
*
* Notes are written through their own routes, so this section owns its writes
* end to end nothing here has to be persisted alongside todos.
*/
export const NotesSection: React.FC<{
projectRef: ProjectRef;
notes: ProjectNote[];
disabled: boolean;
query: string;
pinnedNoteIds: ReadonlySet<string>;
onTogglePinned: (noteId: string, pinned: boolean) => Promise<boolean>;
}> = ({ projectRef, notes, disabled, query, pinnedNoteIds, onTogglePinned }) => {
const { t } = useI18n();
const [composerText, setComposerText] = React.useState('');
// One at a time on purpose: notes can run to 3000 characters each, and
// letting several stand open turns the tab into one unbroken wall of text.
const [expandedNoteId, setExpandedNoteId] = React.useState<string | null>(null);
const notesPanelHeight = useUIStore((state) => state.notesPanelHeight);
const setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight);
const createNote = useProjectContextStore((state) => state.createNote);
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
const deleteNote = useProjectContextStore((state) => state.deleteNote);
const visibleNotes = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return notes;
return notes.filter((note) => note.body.toLowerCase().includes(needle));
}, [notes, query]);
// The store keeps the failure reason; without passing it through, every
// failure looks identical to the user and tells them nothing about the cause.
const reportFailure = React.useCallback((message: string) => {
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
toast.error(message, detail ? { description: detail } : undefined);
}, [projectRef]);
const handleAdd = React.useCallback(async () => {
const body = composerText.trim();
if (!body) {
return;
}
const created = await createNote(projectRef, { body });
if (!created) {
reportFailure(t('rightSidebar.contextNotesTodo.toast.createNoteFailed'));
return;
}
setComposerText('');
}, [composerText, createNote, projectRef, reportFailure, t]);
const handleDelete = React.useCallback(
async (noteId: string) => {
const ok = await deleteNote(projectRef, noteId);
if (!ok) {
reportFailure(t('rightSidebar.contextNotesTodo.toast.deleteNoteFailed'));
}
},
[deleteNote, projectRef, reportFailure, t]
);
const handleTogglePinned = React.useCallback(
async (noteId: string, pinned: boolean) => {
const ok = await onTogglePinned(noteId, pinned);
if (!ok) {
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
},
[onTogglePinned, reportFailure, t]
);
const handleSaveBody = React.useCallback(
(noteId: string, body: string) => {
void saveNoteBody(projectRef, noteId, body).then((ok: boolean) => {
if (!ok) {
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
});
},
[projectRef, reportFailure, saveNoteBody, t]
);
return (
<div className="space-y-2">
{/* Counter and add live in the textarea's own footer slot: beside it they
cost width the panel does not have and leave the button floating
against a tall field. */}
<Textarea
value={composerText}
onChange={(event) => setComposerText(event.target.value.slice(0, PROJECT_NOTE_BODY_MAX_LENGTH))}
placeholder={t('rightSidebar.contextNotesTodo.notes.placeholder')}
resizedHeight={notesPanelHeight}
onResizeHeightChange={setNotesPanelHeight}
useScrollShadow
scrollShadowSize={56}
disabled={disabled}
endSlot={(
<>
<span className="typography-meta text-muted-foreground">
{composerText.length}/{PROJECT_NOTE_BODY_MAX_LENGTH}
</span>
<button
type="button"
onClick={() => void handleAdd()}
disabled={disabled || composerText.trim().length === 0}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('rightSidebar.contextNotesTodo.notes.addAria')}
title={t('rightSidebar.contextNotesTodo.notes.addAria')}
>
<Icon name="add" className="h-4 w-4" />
</button>
</>
)}
/>
{/* No frame around the list: each note is a bordered card, and an outer
border sitting flush against them read as lines joining the cards. */}
<div>
{visibleNotes.length === 0 ? (
<p className="typography-meta text-muted-foreground">
{query.trim()
? t('rightSidebar.contextNotesTodo.search.noResults', { query: query.trim() })
: t('rightSidebar.contextNotesTodo.notes.empty')}
</p>
) : (
<ul className="flex flex-col gap-1.5">
{visibleNotes.map((note) => (
<NoteRow
key={note.id}
note={note}
pinned={pinnedNoteIds.has(note.id)}
expanded={expandedNoteId === note.id}
onToggleExpanded={() => setExpandedNoteId((current) => (current === note.id ? null : note.id))}
onSaveBody={(body) => handleSaveBody(note.id, body)}
onTogglePinned={() => void handleTogglePinned(note.id, !pinnedNoteIds.has(note.id))}
onDelete={() => void handleDelete(note.id)}
/>
))}
</ul>
)}
</div>
</div>
);
};
@@ -0,0 +1,256 @@
import React from 'react';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { requestFileAccess } from '@/lib/desktop';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { parsePlanMarkdown, type ProjectPlanLink, type ProjectRef } from '@/lib/projectContextApi';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { cn } from '@/lib/utils';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useUIStore } from '@/stores/useUIStore';
/**
* Saved plan markdown for the project.
*
* Plan mutations touch neither notes nor todos, so this section talks to the
* store directly instead of routing writes through the container.
*/
export const PlansSection: React.FC<{
projectRef: ProjectRef;
plans: ProjectPlanLink[];
/** Panel-wide filter, matched against plan titles. */
query: string;
/** Hosts without a ContextPanel (mobile) render their own plan viewer. */
onOpenPlan?: (plan: { id: string; title: string }) => void;
pinnedPlanIds: ReadonlySet<string>;
onTogglePinned: (planId: string, pinned: boolean) => Promise<boolean>;
}> = ({ projectRef, plans, query, onOpenPlan, pinnedPlanIds, onTogglePinned }) => {
const { t } = useI18n();
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [isImporting, setIsImporting] = React.useState(false);
const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(null);
const createPlan = useProjectContextStore((state) => state.createPlan);
const removePlan = useProjectContextStore((state) => state.deletePlan);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const handleDeletePlan = React.useCallback(
async (planId: string) => {
if (deletingPlanId) {
return;
}
setDeletingPlanId(planId);
try {
const ok = await removePlan(projectRef, planId);
if (!ok) {
toast.error(t('rightSidebar.contextNotesTodo.toast.deletePlanFailed'));
}
} finally {
setDeletingPlanId(null);
}
},
[deletingPlanId, projectRef, removePlan, t]
);
// Imported files arrive as a whole markdown document; split it the same way
// the server would so the stored plan keeps the author's heading.
const importPlanFromText = React.useCallback(
async (text: string, fallbackTitle: string) => {
if (!text.trim()) {
toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty'));
return;
}
const parsed = parsePlanMarkdown(text, fallbackTitle || t('rightSidebar.contextNotesTodo.plan.defaultTitle'));
const created = await createPlan(projectRef, { title: parsed.title, body: parsed.body });
if (!created) {
toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed'));
return;
}
toast.success(t('rightSidebar.contextNotesTodo.toast.planImported'));
},
[createPlan, projectRef, t]
);
const handleTriggerImport = React.useCallback(async () => {
if (isImporting) {
return;
}
const result = await requestFileAccess({
defaultPath: projectRef.path,
filters: [
{ name: 'Plan files', extensions: ['md', 'markdown', 'txt'] },
{ name: 'All files', extensions: ['*'] },
],
});
if (result.success && result.path) {
setIsImporting(true);
try {
const params = new URLSearchParams({ path: result.path, allowOutsideWorkspace: 'true' });
if (result.outsideFileGrant) {
params.set('outsideFileGrant', result.outsideFileGrant);
}
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' });
if (!response.ok) {
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'));
return;
}
const text = await response.text();
const fallbackTitle = result.path.split('/').pop()?.replace(/\.(md|markdown|txt)$/i, '').trim() || '';
await importPlanFromText(text, fallbackTitle);
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined);
} finally {
setIsImporting(false);
}
return;
}
if (result.error === 'Native file picker not available') {
// Fall back to the HTML file input for web/non-desktop runtimes.
fileInputRef.current?.click();
}
}, [importPlanFromText, isImporting, projectRef.path, t]);
const handleUploadFile = React.useCallback(
async (file: File | null) => {
if (!file) {
return;
}
setIsImporting(true);
try {
const text = await file.text();
const fallbackTitle = file.name.replace(/\.(md|markdown|txt)$/i, '').trim();
await importPlanFromText(text, fallbackTitle);
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined);
} finally {
setIsImporting(false);
}
},
[importPlanFromText, t]
);
const handleTogglePinned = React.useCallback(
async (planId: string, pinned: boolean) => {
const ok = await onTogglePinned(planId, pinned);
if (!ok) {
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined);
}
},
[onTogglePinned, projectRef, t]
);
const visiblePlans = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return plans;
return plans.filter((plan) => plan.title.toLowerCase().includes(needle));
}, [plans, query]);
const handleOpenPlan = React.useCallback(
(plan: ProjectPlanLink) => {
if (onOpenPlan) {
onOpenPlan({ id: plan.id, title: plan.title });
return;
}
const panelDirectory = currentDirectory?.trim() || projectRef.path.trim();
if (!panelDirectory) {
return;
}
openContextPanelTab(panelDirectory, {
mode: 'plan',
projectPlanId: plan.id,
dedupeKey: `plan:${plan.id}`,
label: plan.title,
});
},
[currentDirectory, onOpenPlan, openContextPanelTab, projectRef.path]
);
return (
<div className="space-y-2">
<div className="flex items-center justify-end gap-2">
<input
ref={fileInputRef}
type="file"
accept=".md,.markdown,.txt,text/markdown,text/plain"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
void handleUploadFile(file);
event.currentTarget.value = '';
}}
/>
<button
type="button"
onClick={handleTriggerImport}
disabled={isImporting}
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={t('rightSidebar.contextNotesTodo.plans.importFromFile')}
title={t('rightSidebar.contextNotesTodo.plans.importFromFile')}
>
<Icon name="add" className="h-3.5 w-3.5" />
</button>
</div>
<div className="rounded-lg border border-border/60 bg-background/40">
{visiblePlans.length === 0 ? (
<p className="px-3 py-3 typography-meta text-muted-foreground">
{query.trim()
? t('rightSidebar.contextNotesTodo.search.noResults', { query: query.trim() })
: t('rightSidebar.contextNotesTodo.plans.empty')}
</p>
) : (
<ul className="divide-y divide-border/50">
{visiblePlans.map((plan) => (
<li key={plan.id} className="flex items-center gap-1.5 px-2.5 py-1.5">
<button
type="button"
onClick={() => handleOpenPlan(plan)}
className="flex min-w-0 flex-1 items-center justify-between gap-3 rounded-md px-1.5 py-1 text-left hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
<span className="min-w-0 truncate typography-ui-label text-foreground">{plan.title}</span>
<span className="flex-shrink-0 typography-micro text-muted-foreground">
{new Date(plan.createdAt).toLocaleDateString(getCurrentIntlLocale())}
</span>
</button>
<button
type="button"
onClick={() => void handleTogglePinned(plan.id, !pinnedPlanIds.has(plan.id))}
className={cn(
'inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
pinnedPlanIds.has(plan.id) ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={pinnedPlanIds.has(plan.id)}
aria-label={pinnedPlanIds.has(plan.id)
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
title={pinnedPlanIds.has(plan.id)
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
>
<Icon name="pushpin" className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => void handleDeletePlan(plan.id)}
disabled={deletingPlanId === plan.id}
className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
title={t('rightSidebar.contextNotesTodo.plans.deletePlan')}
aria-label={t('rightSidebar.contextNotesTodo.plans.deletePlanWithTitle', { title: plan.title })}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
};
@@ -0,0 +1,533 @@
import React from 'react';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import type { IconName } from '@/components/icon/icons';
import { Input } from '@/components/ui/input';
import { useI18n } from '@/lib/i18n';
import { resolveProjectContextId, type ProjectRef, type ProjectTodoItem } from '@/lib/projectContextApi';
import { cn } from '@/lib/utils';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { countHighlightedMemories, memoryViewKey } from '@/lib/agentMemoryBadges';
import { EMPTY_PROJECT_CONTEXT_ENTRY, useProjectContextStore } from '@/stores/useProjectContextStore';
import { useUIStore } from '@/stores/useUIStore';
import { TodoSendDialog } from '../TodoSendDialog';
import { MemorySection } from './MemorySection';
import { NotesSection } from './NotesSection';
import { PlansSection } from './PlansSection';
import { TodosSection } from './TodosSection';
import { useProjectTodoSend } from './useProjectTodoSend';
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionProjectContextPins } from '@/lib/sessionKnowledgeApi';
import { useSessionUIStore } from '@/sync/session-ui-store';
/** Lazy: the plan editor is a large view, and most panel visits never open it. */
const PlanView = React.lazy(() => import('@/components/views/PlanView').then((module) => ({ default: module.PlanView })));
interface ProjectNotesTodoPanelProps {
projectRef: ProjectRef | null;
projectLabel?: string | null;
canCreateWorktree?: boolean;
onActionComplete?: () => void;
/** When provided, opening a plan calls this instead of the desktop context
panel tab hosts without ContextPanel (mobile) render their own viewer. */
onOpenPlan?: (plan: { id: string; title: string }) => void;
className?: string;
}
type ProjectContextTab = 'notes' | 'todos' | 'plans' | 'memory';
const TAB_ORDER: ProjectContextTab[] = ['notes', 'todos', 'plans', 'memory'];
/** Wide enough for the longest section label, narrow enough to leave the
content column usable in a half-width panel. */
const SIDEBAR_MIN_WIDTH = 120;
const SIDEBAR_MAX_WIDTH = 320;
const clampSidebarWidth = (width: number): number => (
Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(width)))
);
const sortTodosWithCompletedLast = (items: ProjectTodoItem[]): ProjectTodoItem[] => [
...items.filter((todo) => !todo.completed),
...items.filter((todo) => todo.completed),
];
const matches = (haystack: string, needle: string): boolean => (
haystack.toLowerCase().includes(needle)
);
/**
* Notes, todos, and plans for the active project.
*
* The three lists are tabs rather than one stacked column: stacking gave each
* list its own scroller inside the panel's scroller, which only got worse as
* lists grew and forced the todo list to carry a manual resize handle just to
* stay usable.
*
* Search sits above the tabs and stays panel-wide. Tabs divide, and search is
* the one thing that division would hurt you do not always remember whether
* something was written as a note or lives in a plan so the tab bar doubles
* as the result summary by showing per-tab match counts.
*
* Storage is server-owned and reached through `useProjectContextStore`.
*/
export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
projectRef,
projectLabel,
canCreateWorktree = false,
onActionComplete,
onOpenPlan,
className,
}) => {
const { t } = useI18n();
const projectContextId = React.useMemo(() => resolveProjectContextId(projectRef), [projectRef]);
const contextEntry = useProjectContextStore(
(state) => (projectContextId ? state.entries[projectContextId] : undefined) ?? EMPTY_PROJECT_CONTEXT_ENTRY,
);
const loadProjectContext = useProjectContextStore((state) => state.load);
const saveTodos = useProjectContextStore((state) => state.saveTodos);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin);
const [sessionPins, setSessionPins] = React.useState<SessionProjectContextPins>({ notes: [], plans: [] });
React.useEffect(() => {
if (newSessionDraft.open) {
setSessionPins(newSessionDraft.projectContextPins ?? { notes: [], plans: [] });
return;
}
let cancelled = false;
void fetchSessionKnowledgeSummary(currentSessionDirectory, currentSessionId).then((summary) => {
if (!cancelled) {
setSessionPins({
notes: summary.notes.map((note) => note.id),
plans: summary.plans.map((plan) => plan.id),
});
}
});
return () => { cancelled = true; };
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, newSessionDraft.projectContextPins]);
const toggleSessionPin = React.useCallback(async (kind: 'note' | 'plan', id: string, pinned: boolean) => {
if (newSessionDraft.open) {
setDraftProjectContextPin(kind, id, pinned);
return true;
}
if (!currentSessionId || !currentSessionDirectory) return false;
const next = await setSessionProjectContextPin(currentSessionDirectory, currentSessionId, kind, id, pinned);
if (!next) return false;
setSessionPins(next);
return true;
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, setDraftProjectContextPin]);
const pinnedNoteIds = React.useMemo(() => new Set(sessionPins.notes), [sessionPins.notes]);
const pinnedPlanIds = React.useMemo(() => new Set(sessionPins.plans), [sessionPins.plans]);
// The whole feature is one switch: with memory off there is nothing for the
// agent to manage, so showing the user what is stored would be pointless.
const memoryEnabled = useUIStore((state) => (
state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled
));
const memoryDisabledByServer = useAgentMemoryStore((state) => state.disabled);
const memoryVisible = memoryEnabled && !memoryDisabledByServer;
const globalMemory = useAgentMemoryStore((state) => state.global);
const projectMemory = useAgentMemoryStore((state) => state.project);
const storedTab = useUIStore((state) => state.projectContextTab);
const setStoredTab = useUIStore((state) => state.setProjectContextTab);
const requestedTab = TAB_ORDER.includes(storedTab as ProjectContextTab)
? storedTab as ProjectContextTab
: 'notes';
// A persisted 'memory' must not survive the feature being turned off, or the
// panel would open on a tab that no longer exists.
const activeTab: ProjectContextTab = requestedTab === 'memory' && !memoryVisible
? 'notes'
: requestedTab;
const [query, setQuery] = React.useState('');
/**
* The plan being read, shown in place of the list. Plans used to open as a
* separate context-panel tab, which pushed the user out of the panel they
* were browsing to read something that belongs to it.
*/
const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string } | null>(null);
const trimmedQuery = query.trim().toLowerCase();
// Completed items sink to the bottom in the list; storage order is untouched.
const todos = React.useMemo(
() => sortTodosWithCompletedLast(contextEntry.todos),
[contextEntry.todos],
);
const isLoading = contextEntry.loading && !contextEntry.loaded;
const memoryEntries = React.useMemo(
() => [...globalMemory, ...projectMemory],
[globalMemory, projectMemory],
);
const counts = React.useMemo(() => {
if (!trimmedQuery) {
return {
notes: contextEntry.notes.length,
todos: todos.length,
plans: contextEntry.plans.length,
memory: memoryEntries.length,
};
}
return {
notes: contextEntry.notes.filter((note) => matches(note.body, trimmedQuery)).length,
todos: todos.filter((todo) => matches(todo.text, trimmedQuery)).length,
plans: contextEntry.plans.filter((plan) => matches(plan.title, trimmedQuery)).length,
memory: memoryEntries.filter((entry) => (
matches(entry.title, trimmedQuery) || matches(entry.body, trimmedQuery)
)).length,
};
}, [contextEntry.notes, contextEntry.plans, memoryEntries, todos, trimmedQuery]);
// Counted across both scopes against their own marks: a new global memory is
// the one the user most needs to see, and it would be invisible behind the
// project scope.
const globalViewedAt = useUIStore((state) => state.agentMemoryViewedAt[memoryViewKey('global', null)] ?? 0);
const projectViewedAt = useUIStore(
(state) => state.agentMemoryViewedAt[memoryViewKey('project', projectRef?.path ?? null)] ?? 0,
);
const highlightedMemoryCount = React.useMemo(
() => countHighlightedMemories(globalMemory, globalViewedAt)
+ countHighlightedMemories(projectMemory, projectViewedAt),
[globalMemory, globalViewedAt, projectMemory, projectViewedAt],
);
const storedSidebarWidth = useUIStore((state) => state.projectContextSidebarWidth);
const setSidebarWidth = useUIStore((state) => state.setProjectContextSidebarWidth);
const [isResizing, setIsResizing] = React.useState(false);
// Held locally while dragging so every pointer move does not write through
// the persisted store, then committed once on release.
const [draggedWidth, setDraggedWidth] = React.useState<number | null>(null);
const sidebarWidth = clampSidebarWidth(draggedWidth ?? storedSidebarWidth);
const handleResizeStart = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
setIsResizing(true);
setDraggedWidth(sidebarWidth);
}, [sidebarWidth]);
const handleResizeMove = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
return;
}
// The sidebar is on the right, so dragging its left edge leftwards widens
// it: the width is the distance from the pointer to the panel's edge.
const panelRight = event.currentTarget.closest('nav')?.getBoundingClientRect().right ?? 0;
setDraggedWidth(clampSidebarWidth(panelRight - event.clientX));
}, []);
const handleResizeEnd = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setIsResizing(false);
setDraggedWidth((current) => {
if (current !== null) {
setSidebarWidth(clampSidebarWidth(current));
}
return null;
});
}, [setSidebarWidth]);
const send = useProjectTodoSend({ projectRef, canCreateWorktree, onActionComplete });
React.useEffect(() => {
if (!projectRef) {
return;
}
void loadProjectContext(projectRef);
}, [loadProjectContext, projectRef]);
// Surface a load failure once. The store keeps whatever it already had, so
// the panel never blanks out over an unreachable server.
const reportedErrorRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!contextEntry.error) {
reportedErrorRef.current = null;
return;
}
if (reportedErrorRef.current === contextEntry.error) {
return;
}
reportedErrorRef.current = contextEntry.error;
if (!contextEntry.loaded) {
toast.error(t('rightSidebar.contextNotesTodo.toast.loadNotesFailed'));
}
}, [contextEntry.error, contextEntry.loaded, t]);
// A plan belongs to its project and to its section; leaving either must not
// leave its editor open over a list it no longer matches.
React.useEffect(() => {
setOpenPlan(null);
}, [projectContextId]);
React.useEffect(() => {
if (activeTab !== 'plans') {
setOpenPlan(null);
}
}, [activeTab]);
// Reset the filter when the project changes: a query that matched the old
// project would silently hide everything in the new one.
React.useEffect(() => {
setQuery('');
}, [projectContextId]);
// Follow the search to where the matches are. Without this, typing a query
// whose hits are all in another tab shows an empty list and the user has to
// guess which tab to try. Only moves off a tab that has nothing.
React.useEffect(() => {
if (!trimmedQuery || counts[activeTab] > 0) {
return;
}
const withMatches = TAB_ORDER.find((tab) => counts[tab] > 0);
if (withMatches) {
setStoredTab(withMatches);
}
}, [activeTab, counts, setStoredTab, trimmedQuery]);
const handlePersistTodos = React.useCallback(
(nextTodos: ProjectTodoItem[]) => {
if (!projectRef) {
return;
}
// The store owns per-project write serialization and rollback; the panel
// only decides what to persist and how to report a failure.
void saveTodos(projectRef, nextTodos).then((saved) => {
if (!saved) {
toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
});
},
[projectRef, saveTodos, t]
);
/**
* The sidebar entries. Icons are worth their width here: a vertical list has
* the room a horizontal strip did not, and they make the sections scannable
* without reading.
*/
const sections: Array<{ id: ProjectContextTab; icon: IconName; label: string; count: string }> = React.useMemo(() => ([
{
id: 'notes',
icon: 'sticky-note',
label: t('rightSidebar.contextNotesTodo.tabs.notes'),
count: String(counts.notes),
},
{
id: 'todos',
icon: 'checkbox-circle',
label: t('rightSidebar.contextNotesTodo.tabs.todos'),
count: String(counts.todos),
},
{
id: 'plans',
icon: 'file-text',
label: t('rightSidebar.contextNotesTodo.tabs.plans'),
count: String(counts.plans),
},
...(memoryVisible ? [{
id: 'memory' as const,
icon: 'brain-4' as IconName,
label: t('rightSidebar.contextNotesTodo.tabs.memory'),
// The new/changed count replaces the total when there is anything the
// user has not seen: what the agent stored without asking is the number
// that deserves the glance.
count: highlightedMemoryCount > 0
? `${highlightedMemoryCount}/${counts.memory}`
: String(counts.memory),
}] : []),
]), [counts, highlightedMemoryCount, memoryVisible, t]);
if (!projectRef) {
return (
<div className={cn('w-full min-w-0 p-3', className)}>
<p className="typography-meta text-muted-foreground">
{t('rightSidebar.contextNotesTodo.empty.selectProject')}
</p>
</div>
);
}
const projectTitle = projectLabel?.trim()
|| projectRef.path.split('/').filter(Boolean).pop()
|| projectRef.path;
return (
<div className={cn('flex h-full min-h-0 w-full min-w-0 flex-col', className)}>
{/* Title and search share a row: search is a filter over what is already
on screen, not a heading, and a full-width field read as the panel's
primary control. */}
<div className="flex flex-shrink-0 items-center gap-2 p-3 pb-2">
{/* Back sits here, beside the project name, rather than above the
editor: PlanView already titles the plan, and a second title row
said the same thing twice. */}
{openPlan ? (
<button
type="button"
onClick={() => setOpenPlan(null)}
className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.plans.actions.back')}
title={t('rightSidebar.contextNotesTodo.plans.actions.back')}
>
<Icon name="arrow-left-s" className="h-4 w-4" />
</button>
) : null}
<h3
className="min-w-0 flex-1 truncate typography-ui-label font-semibold text-foreground"
title={projectRef.path}
>
{projectTitle}
</h3>
<div className="relative w-40 flex-shrink-0">
<Icon
name="search"
className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('rightSidebar.contextNotesTodo.search.placeholder')}
className="h-8 pl-7 pr-7"
/>
{query ? (
<button
type="button"
onClick={() => setQuery('')}
className="absolute right-1.5 top-1/2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.search.clear')}
title={t('rightSidebar.contextNotesTodo.search.clear')}
>
<Icon name="close" className="h-3.5 w-3.5" />
</button>
) : null}
</div>
</div>
{/* Content first, sidebar on the right the same order and the same
drag-to-resize edge the files surface uses, so the two panels do not
disagree about where navigation lives. */}
<div className="flex min-h-0 flex-1">
{/* The plan editor scrolls itself; nesting it in this scroller would
give the panel two scrollbars for one document. */}
<div className={cn('min-h-0 min-w-0 flex-1 p-3', openPlan ? 'overflow-hidden' : 'overflow-y-auto')}>
{activeTab === 'notes' ? (
<NotesSection
projectRef={projectRef}
notes={contextEntry.notes}
disabled={isLoading}
query={query}
pinnedNoteIds={pinnedNoteIds}
onTogglePinned={(noteId, pinned) => toggleSessionPin('note', noteId, pinned)}
/>
) : null}
{activeTab === 'todos' ? (
<TodosSection
todos={todos}
query={query}
disabled={isLoading}
canCreateWorktree={canCreateWorktree}
sendingTodoId={send.sendingTodoId}
onPersistTodos={handlePersistTodos}
onSendToCurrentSession={send.sendToCurrentSession}
onSendToNewSession={send.sendToNewSession}
onSendToNewWorktreeSession={send.sendToNewWorktreeSession}
/>
) : null}
{activeTab === 'memory' && memoryVisible ? (
<MemorySection projectPath={projectRef.path} query={query} />
) : null}
{activeTab === 'plans' && !openPlan ? (
<PlansSection
projectRef={projectRef}
plans={contextEntry.plans}
query={query}
pinnedPlanIds={pinnedPlanIds}
onTogglePinned={(planId, pinned) => toggleSessionPin('plan', planId, pinned)}
// Hosts that own a fullscreen plan surface (mobile) keep it; on the
// desktop panel the plan opens here, in place of the list.
onOpenPlan={onOpenPlan ?? setOpenPlan}
/>
) : null}
{activeTab === 'plans' && openPlan ? (
<React.Suspense fallback={null}>
<PlanView
projectPlanId={openPlan.id}
onNavigatedToChat={() => setOpenPlan(null)}
/>
</React.Suspense>
) : null}
</div>
<nav
className="relative flex flex-shrink-0 flex-col gap-0.5 overflow-y-auto border-l border-[var(--interactive-border)] p-2"
style={{ width: `${sidebarWidth}px` }}
aria-label={t('rightSidebar.contextNotesTodo.sections.label')}
>
<div
className={cn(
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize transition-colors hover:bg-[var(--interactive-border)]/80',
isResizing && 'bg-[var(--interactive-border)]',
)}
onPointerDown={handleResizeStart}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
onPointerCancel={handleResizeEnd}
role="separator"
aria-orientation="vertical"
aria-label={t('rightSidebar.contextNotesTodo.sections.resize')}
/>
{sections.map((section) => {
const isActive = activeTab === section.id;
return (
<button
key={section.id}
type="button"
onClick={() => setStoredTab(section.id)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
isActive
? 'bg-interactive-active text-foreground'
: 'text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground',
)}
style={{ minHeight: 0 }}
>
<Icon name={section.icon} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="min-w-0 flex-1 truncate typography-meta">{section.label}</span>
<span className="flex-shrink-0 typography-micro text-muted-foreground">{section.count}</span>
</button>
);
})}
</nav>
</div>
<TodoSendDialog
open={send.pendingSendTarget !== null}
onOpenChange={(open) => {
if (!open) {
send.closeDialog();
}
}}
target={send.pendingSendTarget?.kind ?? 'session'}
projectDirectory={projectRef.path}
submitting={send.isSubmitting}
onConfirm={send.confirmSend}
/>
</div>
);
};
@@ -0,0 +1,348 @@
import React from 'react';
import {
DndContext,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
import { CSS as DndCSS } from '@dnd-kit/utilities';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { PROJECT_TODO_TEXT_MAX_LENGTH, type ProjectTodoItem } from '@/lib/projectContextApi';
import { cn } from '@/lib/utils';
const createTodoId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `todo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
const sortTodosWithCompletedLast = (items: ProjectTodoItem[]): ProjectTodoItem[] => [
...items.filter((todo) => !todo.completed),
...items.filter((todo) => todo.completed),
];
const insertTodoBeforeCompleted = (items: ProjectTodoItem[], item: ProjectTodoItem): ProjectTodoItem[] => {
const firstCompletedIndex = items.findIndex((todo) => todo.completed);
if (firstCompletedIndex === -1) {
return [...items, item];
}
return [...items.slice(0, firstCompletedIndex), item, ...items.slice(firstCompletedIndex)];
};
type SortableTodoHandleProps = {
attributes: ReturnType<typeof useSortable>['attributes'];
listeners: ReturnType<typeof useSortable>['listeners'];
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
isDragging: boolean;
};
const SortableTodoItem: React.FC<{
id: string;
children: (dragHandleProps: SortableTodoHandleProps) => React.ReactNode;
}> = ({ id, children }) => {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<li
ref={setNodeRef}
style={{
transform: DndCSS.Transform.toString(transform),
transition,
}}
className={cn(isDragging && 'opacity-60')}
>
{children({ attributes, listeners, setActivatorNodeRef, isDragging })}
</li>
);
};
export const TodosSection: React.FC<{
todos: ProjectTodoItem[];
/** Panel-wide filter. Mutations still act on the full list. */
query: string;
disabled: boolean;
canCreateWorktree: boolean;
sendingTodoId: string | null;
/** Persists the whole list through the container's store write. */
onPersistTodos: (next: ProjectTodoItem[]) => void;
onSendToCurrentSession: (todoText: string) => void;
onSendToNewSession: (todoId: string, todoText: string) => void;
onSendToNewWorktreeSession: (todoId: string, todoText: string) => void;
}> = ({
todos,
query,
disabled,
canCreateWorktree,
sendingTodoId,
onPersistTodos,
onSendToCurrentSession,
onSendToNewSession,
onSendToNewWorktreeSession,
}) => {
const { t } = useI18n();
const [newTodoText, setNewTodoText] = React.useState('');
const [expandedTodoIds, setExpandedTodoIds] = React.useState<Set<string>>(() => new Set());
const handleAddTodo = React.useCallback(() => {
const trimmed = newTodoText.trim();
if (!trimmed) {
return;
}
onPersistTodos(insertTodoBeforeCompleted(todos, {
id: createTodoId(),
text: trimmed.slice(0, PROJECT_TODO_TEXT_MAX_LENGTH),
completed: false,
createdAt: Date.now(),
}));
setNewTodoText('');
}, [newTodoText, onPersistTodos, todos]);
const handleToggleTodoExpanded = React.useCallback((id: string) => {
setExpandedTodoIds((previous) => {
const next = new Set(previous);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const handleToggleTodo = React.useCallback(
(id: string, completed: boolean) => {
const todo = todos.find((item) => item.id === id);
if (!todo || todo.completed === completed) {
return;
}
const remaining = todos.filter((item) => item.id !== id);
const updated = { ...todo, completed };
onPersistTodos(completed ? [...remaining, updated] : insertTodoBeforeCompleted(remaining, updated));
},
[onPersistTodos, todos]
);
const handleDeleteTodo = React.useCallback(
(id: string) => {
onPersistTodos(todos.filter((todo) => todo.id !== id));
},
[onPersistTodos, todos]
);
const handleClearCompletedTodos = React.useCallback(() => {
const next = todos.filter((todo) => !todo.completed);
if (next.length === todos.length) {
return;
}
onPersistTodos(next);
}, [onPersistTodos, todos]);
const handleTodoReorder = React.useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const oldIndex = todos.findIndex((todo) => todo.id === active.id);
const newIndex = todos.findIndex((todo) => todo.id === over.id);
if (oldIndex === -1 || newIndex === -1) {
return;
}
onPersistTodos(sortTodosWithCompletedLast(arrayMove(todos, oldIndex, newIndex)));
},
[onPersistTodos, todos]
);
const todoSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
);
const todoInputValue = newTodoText.slice(0, PROJECT_TODO_TEXT_MAX_LENGTH);
const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0);
// Filtering is display-only: every handler above still edits the full list,
// so reordering or clearing while a filter is active cannot drop hidden items.
const visibleTodos = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return todos;
return todos.filter((todo) => todo.text.toLowerCase().includes(needle));
}, [query, todos]);
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleClearCompletedTodos}
disabled={disabled || completedTodoCount === 0}
className="typography-meta rounded-md px-1.5 py-0.5 text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('rightSidebar.contextNotesTodo.todo.clearCompleted')}
</button>
</div>
<span className="typography-meta text-muted-foreground">{todoInputValue.length}/{PROJECT_TODO_TEXT_MAX_LENGTH}</span>
</div>
<div className="flex items-center gap-1.5">
<Input
value={todoInputValue}
onChange={(event) => setNewTodoText(event.target.value.slice(0, PROJECT_TODO_TEXT_MAX_LENGTH))}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddTodo();
}
}}
placeholder={t('rightSidebar.contextNotesTodo.todo.inputPlaceholder')}
disabled={disabled}
className="h-8"
/>
<button
type="button"
onClick={handleAddTodo}
disabled={disabled || todoInputValue.trim().length === 0}
className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={t('rightSidebar.contextNotesTodo.todo.addAria')}
title={t('rightSidebar.contextNotesTodo.todo.addAria')}
>
<Icon name="add" className="h-4 w-4" />
</button>
</div>
<div className="rounded-lg border border-border/60 bg-background/40">
{visibleTodos.length === 0 ? (
<p className="px-3 py-3 typography-meta text-muted-foreground">
{query.trim()
? t('rightSidebar.contextNotesTodo.search.noResults', { query: query.trim() })
: t('rightSidebar.contextNotesTodo.todo.empty')}
</p>
) : (
<DndContext
sensors={todoSensors}
collisionDetection={closestCenter}
onDragEnd={handleTodoReorder}
>
<SortableContext
items={visibleTodos.map((todo) => todo.id)}
strategy={verticalListSortingStrategy}
>
<ul className="divide-y divide-border/50">
{visibleTodos.map((todo) => {
const isExpandedTodo = expandedTodoIds.has(todo.id);
return (
<SortableTodoItem key={todo.id} id={todo.id}>
{(dragHandleProps) => (
<div className={cn('flex gap-1.5 px-2.5 py-1.5', isExpandedTodo ? 'items-start' : 'items-center')}>
<button
type="button"
ref={dragHandleProps.setActivatorNodeRef}
{...dragHandleProps.attributes}
{...dragHandleProps.listeners}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
className="flex h-6 w-4 flex-shrink-0 touch-none items-center justify-center text-muted-foreground hover:text-foreground"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.reorder', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.reorder', { text: todo.text })}
>
<Icon name="draggable" className="h-3.5 w-3.5" />
</button>
<div className="flex h-6 items-center">
<Checkbox
checked={todo.completed}
onChange={(checked) => handleToggleTodo(todo.id, checked)}
ariaLabel={t('rightSidebar.contextNotesTodo.todo.actions.markComplete', { text: todo.text })}
/>
</div>
<button
type="button"
onClick={() => handleToggleTodoExpanded(todo.id)}
className={cn(
'block min-h-6 min-w-0 flex-1 bg-transparent p-0 text-left typography-ui-label leading-normal text-foreground',
isExpandedTodo ? 'whitespace-normal break-words' : 'overflow-hidden text-ellipsis whitespace-nowrap',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
todo.completed && 'text-muted-foreground line-through'
)}
title={isExpandedTodo ? undefined : todo.text}
aria-label={
isExpandedTodo
? t('rightSidebar.contextNotesTodo.todo.actions.collapse', { text: todo.text })
: t('rightSidebar.contextNotesTodo.todo.actions.expand', { text: todo.text })
}
>
{todo.text}
</button>
<div className="flex h-6 items-center gap-0.5">
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={sendingTodoId === todo.id}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
title={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
>
<Icon name="send-plane" className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => onSendToCurrentSession(todo.text)}>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.currentSession')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSendToNewSession(todo.id, todo.text)}>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newSession')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSendToNewWorktreeSession(todo.id, todo.text)}
disabled={!canCreateWorktree}
>
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</SortableTodoItem>
);
})}
</ul>
</SortableContext>
</DndContext>
)}
</div>
</div>
);
};
@@ -0,0 +1,203 @@
import React from 'react';
import { toast } from '@/components/ui';
import { generateBranchName } from '@/lib/git/branchNameGenerator';
import { useI18n } from '@/lib/i18n';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import type { ProjectRef } from '@/lib/projectContextApi';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useInputStore } from '@/sync/input-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store';
import type { TodoSendExecution } from '../TodoSendDialog';
type PendingSendTarget = {
kind: 'session' | 'worktree';
todoId: string;
todoText: string;
};
/**
* Sending a todo to an agent.
*
* Creating a session, picking its model/agent, and dispatching the prompt is
* the heaviest thing this surface does and has nothing to do with how todos are
* stored, so it lives apart from the list that triggers it.
*/
export const useProjectTodoSend = (options: {
projectRef: ProjectRef | null;
canCreateWorktree: boolean;
onActionComplete?: () => void;
}) => {
const { projectRef, canCreateWorktree, onActionComplete } = options;
const { t } = useI18n();
const [pendingSendTarget, setPendingSendTarget] = React.useState<PendingSendTarget | null>(null);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const createSession = useSessionUIStore((state) => state.createSession);
const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession);
const sendMessage = useSessionUIStore((state) => state.sendMessage);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const routeToChat = React.useCallback(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
}, [setActiveMainTab, setSessionSwitcherOpen]);
const sendToCurrentSession = React.useCallback(
(todoText: string) => {
if (!currentSessionId) {
toast.error(t('rightSidebar.contextNotesTodo.toast.noActiveSession'));
return;
}
routeToChat();
const fenced = `\`\`\`md\n${todoText}\n\`\`\``;
setPendingInputText(fenced, 'append');
toast.success(t('rightSidebar.contextNotesTodo.toast.sentToCurrentSession'));
onActionComplete?.();
},
[currentSessionId, onActionComplete, routeToChat, setPendingInputText, t]
);
const sendToNewSession = React.useCallback(
(todoId: string, todoText: string) => {
if (!projectRef || sendingTodoId) {
return;
}
setPendingSendTarget({ kind: 'session', todoId, todoText });
},
[projectRef, sendingTodoId]
);
const sendToNewWorktreeSession = React.useCallback(
(todoId: string, todoText: string) => {
if (!projectRef || sendingTodoId) {
return;
}
if (!canCreateWorktree) {
toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo'));
return;
}
setPendingSendTarget({ kind: 'worktree', todoId, todoText });
},
[canCreateWorktree, projectRef, sendingTodoId, t]
);
const confirmSend = React.useCallback(
async (execution: TodoSendExecution) => {
if (!projectRef || !pendingSendTarget) {
return;
}
const visiblePrompt = await renderMagicPrompt('plan.todo.visible', {
todo_text: pendingSendTarget.todoText,
});
const instructionsText = await renderMagicPrompt('plan.todo.instructions', {
todo_text: pendingSendTarget.todoText,
});
const syntheticParts = [{ synthetic: true as const, text: instructionsText }];
setIsSubmitting(true);
setSendingTodoId(pendingSendTarget.todoId);
try {
routeToChat();
let sessionId: string | null = null;
let directoryHint: string | null = projectRef.path;
if (pendingSendTarget.kind === 'worktree') {
if (!canCreateWorktree) {
toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo'));
return;
}
const created = await createWorktreeSessionForNewBranch(projectRef.path, generateBranchName());
if (!created?.id) {
return;
}
sessionId = created.id;
directoryHint = created.path;
} else {
const session = await createSession(undefined, projectRef.path, null);
if (!session?.id) {
toast.error(t('rightSidebar.contextNotesTodo.toast.createSessionFailed'));
return;
}
sessionId = session.id;
directoryHint = session.directory ?? projectRef.path;
initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents ?? []);
}
if (!sessionId) {
return;
}
const selectionState = useSelectionStore.getState();
selectionState.saveSessionModelSelection(sessionId, execution.providerID, execution.modelID);
if (execution.agent.trim()) {
selectionState.saveSessionAgentSelection(sessionId, execution.agent);
selectionState.saveAgentModelForSession(sessionId, execution.agent, execution.providerID, execution.modelID);
selectionState.saveAgentModelVariantForSession(
sessionId,
execution.agent,
execution.providerID,
execution.modelID,
execution.variant || undefined,
);
}
setCurrentSession(sessionId, directoryHint);
await sendMessage(
visiblePrompt,
execution.providerID,
execution.modelID,
execution.agent.trim() || undefined,
undefined,
undefined,
syntheticParts,
execution.variant || undefined,
);
toast.success(
pendingSendTarget.kind === 'worktree'
? t('rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession')
: t('rightSidebar.contextNotesTodo.toast.sentToNewSession')
);
setPendingSendTarget(null);
onActionComplete?.();
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error(t('rightSidebar.contextNotesTodo.toast.sendTodoFailed'), description ? { description } : undefined);
} finally {
setIsSubmitting(false);
setSendingTodoId(null);
}
},
[canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession, t]
);
const closeDialog = React.useCallback(() => {
if (!isSubmitting) {
setPendingSendTarget(null);
}
}, [isSubmitting]);
return {
pendingSendTarget,
isSubmitting,
sendingTodoId,
sendToCurrentSession,
sendToNewSession,
sendToNewWorktreeSession,
confirmSend,
closeDialog,
};
};
@@ -1185,7 +1185,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
</div>
{group.isArchivedBucket && allGroupSessions.length > 0 ? (
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
@@ -1208,7 +1208,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
) : null}
{group.directory && !group.isMain && group.worktree ? (
<div className={cn('absolute right-7 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
@@ -1232,7 +1232,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
) : null}
{group.directory ? (
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
@@ -224,7 +224,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
};
return (
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
@@ -84,7 +84,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
icon inset inside the 24px buttons so the first glyph lines up
with the New-session icon above (16px from the sidebar edge). */}
<div className="ml-[3px] flex items-center gap-1.5">
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
@@ -98,7 +98,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.addProject')}</p></TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
@@ -112,7 +112,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.scheduledTasks')}</p></TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
@@ -127,7 +127,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.newMultiRun')}</p></TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
@@ -143,7 +143,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
</div>
<div className="flex items-center gap-1.5">
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
@@ -158,7 +158,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.searchSessions')}</p></TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
@@ -180,7 +180,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
</Tooltip>
<DropdownMenu>
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
@@ -312,7 +312,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
showCreateButtons ? 'right-7' : 'right-0.5',
)}>
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
@@ -368,7 +368,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
{showCreateButtons && onNewSession ? (
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
@@ -156,11 +156,8 @@ export const resolveArchivedFolderName = (session: Session, projectRoot: string
return segments[segments.length - 1] ?? 'unassigned';
};
export const formatProjectLabel = (label: string): string => {
return label
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
};
// Folder names are shown exactly as they are on disk — no title-casing.
export const formatProjectLabel = (label: string): string => label.trim();
export const renderHighlightedText = (text: string, query: string): React.ReactNode => {
if (!query) {
@@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { formatMoney } from '@/lib/money';
import { clampPercent, resolveUsageTone } from '@/lib/quota';
interface ContextUsageDisplayProps {
@@ -12,6 +13,7 @@ interface ContextUsageDisplayProps {
colorPercentage?: number;
contextLimit: number;
outputLimit?: number;
cost?: number | null;
size?: 'default' | 'compact';
isMobile?: boolean;
hideIcon?: boolean;
@@ -29,6 +31,7 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
colorPercentage,
contextLimit,
outputLimit,
cost = null,
size = 'default',
isMobile = false,
hideIcon = false,
@@ -73,10 +76,13 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
const circularProgressOffset = circularProgressCircumference * (1 - progressPct / 100);
const safeOutputLimit = typeof outputLimit === 'number' ? Math.max(outputLimit, 0) : 0;
const normalizedCost = cost ?? 0;
const hasCost = normalizedCost > 0 && Number.isFinite(normalizedCost);
const tooltipLines = [
t('contextUsage.tooltip.usedTokens', { tokens: formatTokens(totalTokens) }),
t('contextUsage.tooltip.contextLimit', { tokens: formatTokens(contextLimit) }),
t('contextUsage.tooltip.outputLimit', { tokens: formatTokens(safeOutputLimit) }),
...(hasCost ? [t('contextUsage.tooltip.cost', { cost: formatMoney(normalizedCost) })] : []),
];
const isInteractive = !isMobile && typeof onClick === 'function';
@@ -183,6 +189,12 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
<span className="typography-meta text-muted-foreground">{t('contextUsage.mobile.outputLimit')}</span>
<span className="typography-meta text-foreground font-medium">{formatTokens(safeOutputLimit)}</span>
</div>
{hasCost ? (
<div className="flex justify-between items-center">
<span className="typography-meta text-muted-foreground">{t('contextUsage.mobile.cost')}</span>
<span className="typography-meta text-foreground font-medium">{formatMoney(normalizedCost)}</span>
</div>
) : null}
<div className="flex justify-between items-center pt-1 border-t border-border/40">
<span className="typography-meta text-muted-foreground">{t('contextUsage.mobile.usage')}</span>
<span className={cn('typography-meta font-semibold', getPercentageColor(colorPct))}>
@@ -1,6 +1,8 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useProviderLogo } from '@/hooks/useProviderLogo';
import { cn } from '@/lib/utils';
import { getProviderLogoFallbackIcon } from './providerLogoFallback';
interface ProviderLogoProps {
providerId: string;
@@ -16,6 +18,7 @@ export const ProviderLogo: React.FC<ProviderLogoProps> = ({
onError: externalOnError
}) => {
const { src, onError: handleInternalError, hasLogo } = useProviderLogo(providerId);
const fallbackIcon = getProviderLogoFallbackIcon(providerId);
const handleError = React.useCallback(() => {
handleInternalError();
@@ -23,7 +26,7 @@ export const ProviderLogo: React.FC<ProviderLogoProps> = ({
}, [handleInternalError, externalOnError]);
if (!hasLogo || !src) {
return null;
return fallbackIcon ? <Icon name={fallbackIcon} className={cn('text-muted-foreground', className)} /> : null;
}
return (
@@ -39,7 +39,12 @@ const CollapsibleContent = ({
...props
}: React.ComponentProps<typeof BaseCollapsible.Panel>) => (
<BaseCollapsible.Panel
className={cn("overflow-hidden data-[closed]:animate-collapsible-up data-[open]:animate-collapsible-down", className)}
className={cn(
"transition-opacity duration-100 ease-out",
"data-[starting-style]:opacity-0 data-[ending-style]:opacity-0",
"motion-reduce:transition-none",
className,
)}
{...props}
/>
);
+1 -1
View File
@@ -111,7 +111,7 @@ function DialogContent({
{showCloseButton && (
<BaseDialog.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[open]:bg-interactive-active data-[open]:text-foreground absolute top-2 right-2 rounded-lg opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none text-muted-foreground hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
className="ring-offset-background focus:ring-ring data-[open]:bg-interactive-active data-[open]:text-foreground absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none text-muted-foreground hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<Icon name="close"/>
<span className="sr-only">{t('dialog.common.actions.close')}</span>
@@ -0,0 +1,13 @@
import { describe, expect, test } from 'bun:test';
import { getProviderLogoFallbackIcon } from './providerLogoFallback';
describe('provider logo fallbacks', () => {
test('uses a local terminal icon when Command Code has no resolved logo', () => {
expect(getProviderLogoFallbackIcon('command-code')).toBe('terminal-box');
});
test('does not replace providers with their own logo assets', () => {
expect(getProviderLogoFallbackIcon('claude-code')).toBeNull();
expect(getProviderLogoFallbackIcon('cursor')).toBeNull();
});
});

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