Merge remote-tracking branch 'origin/main' into feat/gitlab-issues-mrs

# Conflicts:
#	packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx
#	packages/ui/src/lib/i18n/messages/de.ts
#	packages/ui/src/lib/i18n/messages/en.ts
#	packages/ui/src/lib/i18n/messages/es.ts
#	packages/ui/src/lib/i18n/messages/fr.ts
#	packages/ui/src/lib/i18n/messages/ja.ts
#	packages/ui/src/lib/i18n/messages/ko.ts
#	packages/ui/src/lib/i18n/messages/pl.ts
#	packages/ui/src/lib/i18n/messages/pt-BR.ts
#	packages/ui/src/lib/i18n/messages/uk.ts
#	packages/ui/src/lib/i18n/messages/zh-CN.ts
#	packages/ui/src/lib/i18n/messages/zh-TW.ts
#	packages/web/server/lib/opencode/settings-helpers.js
This commit is contained in:
2026-08-18 20:22:15 +00:00
187 changed files with 14970 additions and 2196 deletions
+13 -13
View File
@@ -19,34 +19,34 @@
"@capacitor/keyboard": "^8.0.0",
"@capacitor/push-notifications": "^8.1.1",
"@capacitor/status-bar": "^8.0.0",
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.1",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.0",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "6.12.2",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "6.12.4",
"@codemirror/language-data": "^6.5.2",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/lint": "^6.9.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "6.39.13",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/lint": "^6.9.7",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "6.43.9",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "1.18.18",
"@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vim": "^6.4.0",
"@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "3.14.5",
"@xenova/transformers": "^2.17.2",
+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';
@@ -703,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();
+2 -2
View File
@@ -109,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);
@@ -540,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';
+3 -3
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 {
@@ -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,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

+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,
@@ -602,59 +604,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,
});
}
@@ -662,7 +667,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);
@@ -983,6 +988,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;
@@ -1074,6 +1080,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();
@@ -1102,7 +1146,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,
@@ -835,7 +835,6 @@ const useMorphdomMarkdown = ({
containerRef,
text,
streaming,
cacheKey,
imageMode = 'inline',
syntaxVars,
ctx,
@@ -843,7 +842,6 @@ const useMorphdomMarkdown = ({
containerRef: React.RefObject<HTMLDivElement | null>;
text: string;
streaming: boolean;
cacheKey: string;
imageMode?: MarkdownImageMode;
syntaxVars: Record<string, string>;
ctx: DecorateContext;
@@ -908,7 +906,7 @@ const useMorphdomMarkdown = ({
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
let active = true;
void renderMarkdownBlocks(text, streaming, cacheKey, imageMode).then((blocks) => {
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active) return;
const existing = Array.from(target.children) as HTMLElement[];
@@ -959,7 +957,7 @@ const useMorphdomMarkdown = ({
return () => {
active = false;
};
}, [containerRef, text, streaming, cacheKey, imageMode, ctx, refreshMermaidViewers]);
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
React.useEffect(() => {
const container = containerRef.current;
@@ -1040,13 +1038,13 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
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,
imageMode: variant === 'assistant' ? 'label' : 'inline',
syntaxVars,
ctx,
@@ -1060,7 +1058,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
if (isAnimated) {
return (
<FadeInOnReveal key={cacheKey} skipAnimation={skipFadeIn}>
<FadeInOnReveal key={fadeKey} skipAnimation={skipFadeIn}>
{markdownContent}
</FadeInOnReveal>
);
@@ -1137,7 +1135,6 @@ const SimpleMarkdownRendererImpl: React.FC<{
containerRef,
text: renderedContent,
streaming: false,
cacheKey: `simple:${variant}`,
syntaxVars,
ctx,
});
@@ -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,
);
}
@@ -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;
};
@@ -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';
@@ -415,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;
};
@@ -483,29 +489,60 @@ 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, imageMode: MarkdownImageMode): Promise<string> => {
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
@@ -545,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,
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}:${imageMode}`;
const key = `${cacheKey}:${index}:${block.mode}:${imageMode}`;
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, imageMode);
touch(key, { hash: contentHash, html });
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);
});
});
@@ -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 {
@@ -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();
@@ -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} />;
}
@@ -10,7 +10,13 @@ import { useSession } from '@/sync/sync-context';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getLinkedIssues, parseLinkedIssueRef, type LinkedIssue } from '@/lib/linkedIssues';
import { linkedEntityLiveInvalidate, useLinkedEntityLive, type LinkedEntityLive } from '@/lib/linkedEntityLive';
import { fetchSessionKnowledgeSummary, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { setLinkedIssue } from '@/sync/session-actions';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { WorkStatusCollapsibleSection, WorkStatusPill, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
import { WorkStatusLinkDialog } from './WorkStatusLinkDialog';
@@ -193,6 +199,54 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
void loadSkills();
}, [directory, loadSkills]);
/**
* What the project sends along with every message. 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 whenever the stores that own pins or memory change, not only when
// the directory does. Unpinning is a write those stores make, and a panel
// that keeps listing what was just unpinned tells the user it is still going
// to the agent when it is not.
const contextEntries = useProjectContextStore((state) => state.entries);
const memoryProject = useAgentMemoryStore((state) => state.project);
const memoryGlobal = useAgentMemoryStore((state) => state.global);
React.useEffect(() => {
let cancelled = false;
void fetchSessionKnowledgeSummary(directory).then((summary) => {
if (!cancelled) setKnowledge(summary);
});
return () => { cancelled = true; };
}, [directory, contextEntries, memoryProject, memoryGlobal]);
const projects = useProjectsStore((state) => state.projects);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
const projectRef = React.useMemo(() => {
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory ?? '');
return resolved ? { id: resolved.id, path: resolved.path } : null;
}, [availableWorktreesByProject, directory, projects]);
// 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 (projectRef) void setNotePinned(projectRef, noteId, false);
}, [projectRef, setNotePinned]);
const unpinPlan = React.useCallback((planId: string) => {
if (projectRef) void setPlanPinned(projectRef, planId, false);
}, [projectRef, setPlanPinned]);
const memoryCount = knowledge.memory.global + knowledge.memory.project;
const pinnedCount = knowledge.notes.length + knowledge.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,
@@ -202,9 +256,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
@@ -222,6 +281,14 @@ 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 the ambient counts in the summary: it is
// something the user chose for this project, not something that happens to
// be installed.
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
@@ -272,6 +339,63 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
<WorkStatusRow muted label={t('chat.workStatus.linkedIssues.empty')} />
) : null}
{/* Named individually: a count alone would not tell the user which note
is riding along with every message they send. */}
{/* 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. */}
{knowledge.notes.map((note) => (
<WorkStatusRow
key={note.id}
muted
leading={(
<button
type="button"
disabled={!projectRef}
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>}
/>
))}
{knowledge.plans.map((plan) => (
<WorkStatusRow
key={plan.id}
muted
leading={(
<button
type="button"
disabled={!projectRef}
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')}
@@ -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;
@@ -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"/>`,
@@ -947,7 +947,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(
@@ -92,6 +92,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
}
const breakdown = source as {
total?: unknown;
input?: unknown;
output?: unknown;
reasoning?: unknown;
@@ -103,6 +104,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 +115,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
reasoning,
cacheRead,
cacheWrite,
total: input + output + reasoning + cacheRead + cacheWrite,
total: reportedTotal > 0 ? reportedTotal : input + output + reasoning + cacheRead + cacheWrite,
};
};
@@ -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,7 @@ 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 { isBrowserClientRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
@@ -54,6 +55,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 +128,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 +245,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 +259,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 +271,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 +393,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 +435,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 +508,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 +541,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 +560,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 +649,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 +1003,101 @@ 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 isCurrentDestination = rootRef.current === operationRoot && getRuntimeKey() === operationRuntime;
try {
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 +1257,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 +1289,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 +1388,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 +1449,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)}>
@@ -8,6 +8,7 @@ import { useViewportStore } from '@/sync/viewport-store';
import { useSessions, useDirectorySync, 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';
@@ -702,7 +703,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 +731,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;
@@ -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;
@@ -13,6 +13,7 @@ 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';
@@ -327,7 +328,11 @@ export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSecti
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name={plugin.icon} className={cn('size-5', plugin.brandClassName)} />
{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>
@@ -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>
);
@@ -117,20 +117,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()
@@ -184,10 +170,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 {
@@ -201,6 +191,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;
@@ -1273,7 +1277,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
@@ -0,0 +1,224 @@
# 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 are project state, not a message attachment
Pinning a note or plan writes to the project, not to the session, so it holds
across every session in that project until it is unpinned. The composer once
carried a chip for it, from when pinned context was a one-shot attachment to the
next message; standing state shown permanently above the input reads as
something being attached to what you are typing, which it is not. What is
attached, and the control to detach it, live in the work status panel instead.
## 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 marks it as standing context for the agent.
Assembly and delivery live in `packages/ui/src/lib/projectContextPinning.ts`;
this surface only owns the toggle. `ComposerPinnedContextChip` shows the user
what is riding along.
## 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,296 @@
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;
expanded: boolean;
onToggleExpanded: () => void;
onSaveBody: (body: string) => void;
onTogglePinned: () => void;
onDelete: () => void;
}> = ({ note, 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',
note.pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={note.pinned}
aria-label={note.pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
title={note.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={note.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;
}> = ({ projectRef, notes, disabled, query }) => {
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 setNotePinned = useProjectContextStore((state) => state.setNotePinned);
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 setNotePinned(projectRef, noteId, pinned);
if (!ok) {
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
},
[projectRef, reportFailure, setNotePinned, 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}
expanded={expandedNoteId === note.id}
onToggleExpanded={() => setExpandedNoteId((current) => (current === note.id ? null : note.id))}
onSaveBody={(body) => handleSaveBody(note.id, body)}
onTogglePinned={() => void handleTogglePinned(note.id, !note.pinned)}
onDelete={() => void handleDelete(note.id)}
/>
))}
</ul>
)}
</div>
</div>
);
};
@@ -0,0 +1,255 @@
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;
}> = ({ projectRef, plans, query, onOpenPlan }) => {
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 setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
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 setPlanPinned(projectRef, planId, pinned);
if (!ok) {
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined);
}
},
[projectRef, setPlanPinned, 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, !plan.pinned)}
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',
plan.pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={plan.pinned}
aria-label={plan.pinned
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
title={plan.pinned
? 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,490 @@
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';
/** 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);
// 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}
/>
) : 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}
// 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,
};
};
+13 -4
View File
@@ -1,12 +1,17 @@
import * as React from 'react';
import { Switch as BaseSwitch } from '@base-ui/react/switch';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
type SwitchProps = React.ComponentPropsWithoutRef<typeof BaseSwitch.Root> & {
loading?: boolean;
};
const Switch = React.forwardRef<
HTMLButtonElement,
React.ComponentPropsWithoutRef<typeof BaseSwitch.Root>
>(({ className, ...props }, ref) => (
SwitchProps
>(({ className, loading = false, ...props }, ref) => (
<BaseSwitch.Root
className={cn(
'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:bg-primary data-[unchecked]:bg-[var(--interactive-border)]',
@@ -14,14 +19,18 @@ const Switch = React.forwardRef<
)}
style={{ width: '36px', height: '20px', minWidth: '36px', minHeight: '20px' }}
{...props}
aria-busy={loading || undefined}
ref={ref}
>
<BaseSwitch.Thumb
className={cn(
'pointer-events-none block rounded-full bg-background shadow-none ring-0 transition-transform data-[checked]:translate-x-4 data-[unchecked]:translate-x-0'
'pointer-events-none flex items-center justify-center rounded-full bg-background shadow-none ring-0 transition-transform data-[checked]:translate-x-4 data-[unchecked]:translate-x-0',
loading && 'bg-status-warning text-background',
)}
style={{ width: '16px', height: '16px', minWidth: '16px', minHeight: '16px' }}
/>
>
{loading ? <Icon name="loader" className="size-3 animate-spin" /> : null}
</BaseSwitch.Thumb>
</BaseSwitch.Root>
));
Switch.displayName = 'Switch';
+30 -5
View File
@@ -26,6 +26,9 @@ type TextareaProps = React.ComponentProps<"textarea"> & {
endSlot?: React.ReactNode;
};
/** Keep in sync with the textarea's `min-h-[82px]` below. */
const TEXTAREA_MIN_HEIGHT = 82;
function ResizeHandle({
onResizeStart,
ariaLabel,
@@ -81,16 +84,29 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
) => {
const { t } = useI18n();
const wrapperRef = React.useRef<HTMLDivElement>(null);
const dragStateRef = React.useRef<{ startY: number; startHeight: number } | null>(null);
const dragStateRef = React.useRef<{ startY: number; startHeight: number; minHeight: number } | null>(null);
const [resizedHeight, setResizedHeight] = React.useState<number | null>(null);
const effectiveResizedHeight = controlledResizedHeight ?? resizedHeight;
const handleResizeStart = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const startHeight = wrapper.getBoundingClientRect().height;
/**
* The floor is the textarea's own minimum plus everything else the
* wrapper stacks around it — the counter row, the gap, the padding.
* Clamping to the textarea minimum alone left no room for that row, so
* dragging the handle far enough pushed the counter out through the
* bottom border instead of stopping.
*/
const innerTextarea = wrapper.querySelector('textarea');
const chromeHeight = innerTextarea
? Math.max(0, startHeight - innerTextarea.getBoundingClientRect().height)
: 0;
dragStateRef.current = {
startY: event.clientY,
startHeight: wrapper.getBoundingClientRect().height,
startHeight,
minHeight: TEXTAREA_MIN_HEIGHT + chromeHeight,
};
const target = event.currentTarget;
target.setPointerCapture(event.pointerId);
@@ -99,7 +115,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
const state = dragStateRef.current;
if (!state) return;
const next = state.startHeight + (moveEvent.clientY - state.startY);
const nextHeight = Math.max(82, next);
const nextHeight = Math.max(state.minHeight, next);
if (onResizeHeightChange) {
onResizeHeightChange(nextHeight);
} else {
@@ -162,12 +178,21 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
<div
ref={wrapperRef}
onPointerDown={focusInnerTextarea}
style={effectiveResizedHeight !== null ? { height: `${effectiveResizedHeight}px` } : undefined}
// minHeight guards a stored height from before the floor was fixed, and
// any future row added to the wrapper: the box never shrinks below what
// it contains, so nothing can spill through the border again.
style={effectiveResizedHeight !== null
? { height: `${effectiveResizedHeight}px`, minHeight: 'fit-content' }
: undefined}
className={cn(
"group/textarea relative flex w-full flex-col rounded-[var(--radius-xl)] bg-[var(--surface-elevated)] pb-2.5",
"ring-1 ring-inset ring-border/60 transition duration-200 ease-out",
"hover:[&:not(:focus-within)]:bg-[var(--surface-subtle)]",
"has-[[disabled]]:pointer-events-none has-[[disabled]]:bg-[var(--surface-subtle)] has-[[disabled]]:ring-transparent",
// Scoped to the textarea, not any disabled descendant: an endSlot
// control that disables itself (an add button with an empty field)
// would otherwise take pointer events away from the whole wrapper,
// leaving the field unclickable and the button permanently disabled.
"has-[textarea:disabled]:pointer-events-none has-[textarea:disabled]:bg-[var(--surface-subtle)] has-[textarea:disabled]:ring-transparent",
!hasError && [
"hover:[&:not(:focus-within)]:ring-transparent",
"focus-within:ring-2 focus-within:ring-[var(--interactive-focus-ring)]",
+61 -9
View File
@@ -38,7 +38,8 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { EditorView } from '@codemirror/view';
import { copyTextToClipboard } from '@/lib/clipboard';
import { generateBranchName } from '@/lib/git/branchNameGenerator';
import { parseProjectPlanMarkdown } from '@/lib/openchamberConfig';
import { fetchProjectPlan, parsePlanMarkdown } from '@/lib/projectContextApi';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { TodoSendDialog, type TodoSendExecution } from '@/components/session/TodoSendDialog';
import { Icon } from "@/components/icon/Icon";
@@ -48,6 +49,9 @@ import { useI18n } from '@/lib/i18n';
type PlanViewProps = {
targetPath?: string | null;
/** Saved project plan to open. Project plans are server-owned and addressed
by id; they never carry a client-visible filesystem path. */
projectPlanId?: string | null;
/** Called after a send action routes the user to the chat — hosts that show
PlanView in an overlay (mobile fullscreen surface) close it here. */
onNavigatedToChat?: () => void;
@@ -150,7 +154,7 @@ type SelectedLineRange = {
end: number;
};
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigatedToChat }) => {
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPlanId = null, onNavigatedToChat }) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const createSession = useSessionUIStore((state) => state.createSession);
@@ -195,6 +199,12 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
const [isPlanSendSubmitting, setIsPlanSendSubmitting] = React.useState(false);
const [resolvedPath, setResolvedPath] = React.useState<string | null>(null);
// Set once a saved project plan has actually loaded. Kept separate from
// `resolvedPath` so nothing downstream can mistake a project plan for a file
// the user could open, edit, or be shown a path for.
const [loadedProjectPlanId, setLoadedProjectPlanId] = React.useState<string | null>(null);
const savePlan = useProjectContextStore((state) => state.savePlan);
const hasDocument = Boolean(resolvedPath) || Boolean(loadedProjectPlanId);
const displayPath = React.useMemo(() => {
if (!resolvedPath || !sessionDirectory || !homeDirectory) {
return resolvedPath;
@@ -212,7 +222,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
if (!content.trim()) {
return t('planView.title.default');
}
return parseProjectPlanMarkdown(content).title || t('planView.title.default');
return parsePlanMarkdown(content, t('planView.title.default')).title;
}, [content, t]);
const sendPromptTitle = React.useMemo(() => parsedTitle.trim() || t('planView.title.default'), [parsedTitle, t]);
const [loading, setLoading] = React.useState(false);
@@ -374,8 +384,9 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
React.useEffect(() => {
// Saved project plans opened via context panel should work even when session plan mode is off.
if (!planModeEnabled && !targetPath) {
if (!planModeEnabled && !targetPath && !projectPlanId) {
setResolvedPath(null);
setLoadedProjectPlanId(null);
setContent('');
setLoading(false);
return;
@@ -407,9 +418,36 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
const run = async () => {
setResolvedPath(null);
setLoadedProjectPlanId(null);
setContent('');
setSaveError(null);
if (projectPlanId) {
if (!currentProjectRef) {
return;
}
setLoading(true);
try {
const plan = await fetchProjectPlan(currentProjectRef, projectPlanId);
if (cancelled) return;
if (!plan) {
// The plan or its markdown is gone. Leave the view empty and
// unsaveable rather than presenting an editor that would recreate
// a document the user deleted.
setSaveError(t('planView.error.loadFailed'));
return;
}
setContent(plan.raw);
setLoadedProjectPlanId(projectPlanId);
} catch (error) {
if (cancelled) return;
setSaveError(error instanceof Error ? error.message : t('planView.error.loadFailed'));
} finally {
if (!cancelled) setLoading(false);
}
return;
}
if (targetPath) {
setLoading(true);
try {
@@ -482,17 +520,31 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
return () => {
cancelled = true;
};
}, [homeDirectory, planModeEnabled, runtimeApis.files, sessionDirectory, session?.slug, session?.time?.created, targetPath]);
}, [currentProjectRef, homeDirectory, planModeEnabled, projectPlanId, runtimeApis.files, sessionDirectory, session?.slug, session?.time?.created, t, targetPath]);
React.useEffect(() => {
if (!resolvedPath) {
setSaveError(null);
if (!resolvedPath && !loadedProjectPlanId) {
return;
}
const controller = window.setTimeout(async () => {
setSaveError(null);
try {
if (loadedProjectPlanId) {
if (!currentProjectRef) {
throw new Error(t('planView.error.writeFailed'));
}
const saved = await savePlan(currentProjectRef, loadedProjectPlanId, content);
if (!saved) {
throw new Error(t('planView.error.writeFailed'));
}
return;
}
if (!resolvedPath) {
return;
}
if (runtimeApis.files?.writeFile) {
const result = await runtimeApis.files.writeFile(resolvedPath, content);
if (!result?.success) {
@@ -516,7 +568,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
return () => {
window.clearTimeout(controller);
};
}, [content, resolvedPath, runtimeApis.files, t]);
}, [content, currentProjectRef, loadedProjectPlanId, resolvedPath, runtimeApis.files, savePlan, t]);
React.useEffect(() => {
return () => {
@@ -672,7 +724,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigat
</div>
) : null}
</div>
{resolvedPath ? (
{hasDocument ? (
<div className="flex items-center gap-1">
<DropdownMenu>
<Tooltip>
@@ -0,0 +1,65 @@
/**
* Keeps agent memory loaded for whatever project the session belongs to.
*
* This does not belong to the Memory tab. The session index is built from the
* loaded snapshot, so leaving the load to the panel meant a user who never
* opened Project notes sent every message with no memory index at all — the
* agent had memories it was never told about.
*
* The session directory is resolved to its project first. A session in a
* worktree has the worktree's path, and loading by that path reads a store the
* agent does not write to, which is the same mismatch in the other direction.
*/
import React from 'react';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
/**
* The directory is a parameter rather than read from `useEffectiveDirectory`,
* because this runs above `SyncProvider` — that hook reads the sync context and
* throws outside it, which took the whole app down with a blank window.
*/
export const useAgentMemorySync = (directory: string | null): void => {
const enabled = useUIStore((state) => (
state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled
));
const projects = useProjectsStore((state) => state.projects);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const effectiveDirectory = directory ?? '';
const load = useAgentMemoryStore((state) => state.load);
const projectPath = React.useMemo(() => {
if (!effectiveDirectory) {
return null;
}
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, effectiveDirectory);
return resolved?.path ?? null;
}, [availableWorktreesByProject, effectiveDirectory, projects]);
React.useEffect(() => {
if (!enabled) {
return;
}
void load(projectPath);
}, [enabled, load, projectPath]);
// The agent writes memory mid-turn through its own tool, so the index for the
// next message has to come from a fresh read rather than the snapshot taken
// before the turn started.
React.useEffect(() => {
if (!enabled) {
return;
}
return subscribeOpenchamberEvents((event) => {
if (event.type === 'agent-memory-changed') {
void load(projectPath);
}
});
}, [enabled, load, projectPath]);
};
+199
View File
@@ -0,0 +1,199 @@
/**
* Client for the OpenChamber agent memory routes.
*
* The store is owned by the server (`packages/web/server/lib/agent-memory`).
* This module only speaks HTTP and resolves no storage paths.
*
* Every function throws on failure. An authoritative read must never resolve to
* an empty list a caller could mistake for "the agent remembers nothing" — that
* reading is exactly what would make the user think memory had been lost.
*
* A 404 is the one exception, and it means the feature is switched off rather
* than that the entry is missing: the server disables the whole surface, so
* callers translate it into `disabled` instead of an error.
*/
import { createProjectIdFromPath } from './projectId';
import { runtimeFetch } from './runtime-fetch';
export type AgentMemoryType = 'fact' | 'preference' | 'reference';
export type AgentMemoryScope = 'global' | 'project';
export interface AgentMemoryEntry {
id: string;
title: string;
body: string;
type: AgentMemoryType;
createdAt: number;
updatedAt: number;
/**
* Reads as an instruction to the model rather than a fact. Kept in the store
* and shown here, but withheld from what sessions are told.
*/
flagged?: boolean;
/** The session this was learned in, when the agent recorded one. */
sessionId?: string;
}
interface AgentMemorySnapshot {
global: AgentMemoryEntry[];
project: AgentMemoryEntry[];
/**
* A scope that failed to load. Kept separate from an empty list so the panel
* can say "could not load" rather than showing an empty tab that reads as
* "the agent has forgotten everything".
*/
globalFailed: boolean;
projectFailed: boolean;
}
/** Mirrors the server's clamps, so the editor stops where storage would cut. */
export const AGENT_MEMORY_TITLE_MAX_LENGTH = 120;
export const AGENT_MEMORY_BODY_MAX_LENGTH = 2000;
/** Raised when the server reports the whole memory surface as switched off. */
export class AgentMemoryDisabledError extends Error {
constructor() {
super('Agent memory is disabled');
this.name = 'AgentMemoryDisabledError';
}
}
const BASE_PATH = '/api/agent-memory';
/**
* Mirrors the server: the storage id comes from the project path, not from
* `project.id`, because the path-derived id is what names the file on disk.
*/
const resolveMemoryProjectId = (projectPath: string | null | undefined): string => {
const trimmed = typeof projectPath === 'string' ? projectPath.trim() : '';
return trimmed ? createProjectIdFromPath(trimmed) : '';
};
const scopeQuery = (scope: AgentMemoryScope, projectId: string): string => {
if (scope === 'global') {
return 'scope=global';
}
if (!projectId) {
throw new Error('Project memory needs a resolvable project path');
}
return `scope=project&projectId=${encodeURIComponent(projectId)}`;
};
interface ErrorPayload {
error?: unknown;
disabled?: unknown;
}
/**
* A 404 alone does not mean the feature is off — a deleted entry answers 404
* too. Only the server's explicit `disabled` flag distinguishes them.
*/
const failed = async (response: Response, fallback: string): Promise<never> => {
let payload: ErrorPayload | null = null;
try {
payload = await response.json() as ErrorPayload | null;
} catch {
// Fall through to the generic message.
}
if (response.status === 404 && payload?.disabled === true) {
throw new AgentMemoryDisabledError();
}
const message = typeof payload?.error === 'string' && payload.error.trim()
? payload.error
: `${fallback} (${response.status})`;
throw new Error(message);
};
const parseEntry = (value: unknown): AgentMemoryEntry | null => {
const record = value as Partial<AgentMemoryEntry> | null;
if (!record || typeof record !== 'object') {
return null;
}
if (typeof record.id !== 'string' || typeof record.title !== 'string' || typeof record.body !== 'string') {
return null;
}
return {
id: record.id,
title: record.title,
body: record.body,
type: record.type === 'preference' || record.type === 'reference' ? record.type : 'fact',
createdAt: typeof record.createdAt === 'number' ? record.createdAt : 0,
updatedAt: typeof record.updatedAt === 'number' ? record.updatedAt : 0,
...(record.flagged === true ? { flagged: true } : {}),
...(typeof record.sessionId === 'string' ? { sessionId: record.sessionId } : {}),
};
};
const parseEntries = (value: unknown): AgentMemoryEntry[] => (
Array.isArray(value) ? value.map(parseEntry).filter((entry): entry is AgentMemoryEntry => entry !== null) : []
);
/**
* Both scopes in one request. Two requests would let one scope render while the
* other is still in flight, which reads as memory that has gone missing.
*/
export const fetchAgentMemory = async (
projectPath: string | null,
options: { signal?: AbortSignal } = {},
): Promise<AgentMemorySnapshot> => {
const projectId = resolveMemoryProjectId(projectPath);
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : '';
const response = await runtimeFetch(`${BASE_PATH}/all${query}`, {
cache: 'no-store',
signal: options.signal,
});
if (!response.ok) {
return failed(response, 'Failed to load agent memory');
}
const payload = await response.json() as Record<string, unknown> | null;
if (!payload || typeof payload !== 'object') {
throw new Error('Malformed agent memory response');
}
return {
global: parseEntries(payload.global),
project: parseEntries(payload.project),
globalFailed: payload.globalFailed === true,
projectFailed: payload.projectFailed === true,
};
};
/** A user correction from the panel; the agent rewrites by saving again. */
export const updateAgentMemory = async (
scope: AgentMemoryScope,
projectPath: string | null,
memoryId: string,
patch: { title?: string; body?: string; type?: AgentMemoryType },
): Promise<AgentMemoryEntry> => {
const query = scopeQuery(scope, resolveMemoryProjectId(projectPath));
const response = await runtimeFetch(`${BASE_PATH}/${encodeURIComponent(memoryId)}?${query}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!response.ok) {
return failed(response, 'Failed to save memory');
}
const payload = await response.json() as { entry?: unknown } | null;
const entry = parseEntry(payload?.entry);
if (!entry) {
throw new Error('Malformed agent memory response');
}
return entry;
};
export const deleteAgentMemory = async (
scope: AgentMemoryScope,
projectPath: string | null,
memoryId: string,
): Promise<void> => {
const query = scopeQuery(scope, resolveMemoryProjectId(projectPath));
const response = await runtimeFetch(`${BASE_PATH}/${encodeURIComponent(memoryId)}?${query}`, {
method: 'DELETE',
});
if (!response.ok) {
await failed(response, 'Failed to delete memory');
}
};
@@ -0,0 +1,74 @@
import { describe, expect, test } from 'bun:test';
import { classifyMemory, countHighlightedMemories, memoryViewKey } from './agentMemoryBadges';
import type { AgentMemoryEntry } from './agentMemoryApi';
const entry = (overrides: Partial<AgentMemoryEntry> = {}): AgentMemoryEntry => ({
id: 'mem-1',
title: 'Uses bun',
body: 'Tests run with bun test.',
type: 'fact',
createdAt: 100,
updatedAt: 100,
...overrides,
});
describe('classifying an entry against the last look', () => {
test('an entry stored since the last look is new', () => {
expect(classifyMemory(entry({ createdAt: 200, updatedAt: 200 }), 100)).toBe('new');
});
test('an entry rewritten since the last look is changed, not new', () => {
// The distinction matters: a memory the agent invented and one it quietly
// rewrote need different attention.
expect(classifyMemory(entry({ createdAt: 50, updatedAt: 200 }), 100)).toBe('changed');
});
test('an untouched entry carries no badge', () => {
expect(classifyMemory(entry({ createdAt: 50, updatedAt: 50 }), 100)).toBeNull();
});
test('a rewrite the user already saw carries no badge', () => {
expect(classifyMemory(entry({ createdAt: 10, updatedAt: 50 }), 100)).toBeNull();
});
test('everything is new before the user has ever looked', () => {
expect(classifyMemory(entry({ createdAt: 1, updatedAt: 1 }), 0)).toBe('new');
});
test('an entry stored exactly at the last look is not re-announced', () => {
expect(classifyMemory(entry({ createdAt: 100, updatedAt: 100 }), 100)).toBeNull();
});
});
describe('counting what deserves a glance', () => {
test('counts new and changed together', () => {
const count = countHighlightedMemories([
entry({ id: 'a', createdAt: 200, updatedAt: 200 }),
entry({ id: 'b', createdAt: 50, updatedAt: 200 }),
entry({ id: 'c', createdAt: 50, updatedAt: 50 }),
], 100);
expect(count).toBe(2);
});
test('an untouched store counts nothing', () => {
expect(countHighlightedMemories([entry({ createdAt: 1, updatedAt: 1 })], 100)).toBe(0);
});
});
describe('where each scope keeps its mark', () => {
test('global has one mark', () => {
expect(memoryViewKey('global', '/tmp/anything')).toBe('global');
});
test('each project keeps its own', () => {
// One shared project mark would let opening one project silently clear
// another project's badges.
expect(memoryViewKey('project', '/tmp/a')).not.toBe(memoryViewKey('project', '/tmp/b'));
});
test('a project scope with no path never collides with global', () => {
expect(memoryViewKey('project', null)).not.toBe('global');
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* What is new or changed in agent memory since the user last looked.
*
* Derived from the entry's own timestamps against a per-scope "last viewed"
* mark, so the store carries no review state and the user is never asked to
* confirm anything. Looking at the tab is the acknowledgement.
*
* The two badges are worth separating: a memory the agent has just invented
* and one it has quietly rewritten need different attention, and lumping them
* together as "new" would hide every correction.
*/
import type { AgentMemoryEntry, AgentMemoryScope } from './agentMemoryApi';
export type MemoryBadge = 'new' | 'changed' | null;
/**
* The key a scope's mark is stored under. Project marks are keyed by path
* because each project has its own store — one shared mark would let opening
* one project silently clear another's badges.
*/
export const memoryViewKey = (scope: AgentMemoryScope, projectPath: string | null): string => (
scope === 'global' ? 'global' : `project:${projectPath ?? ''}`
);
/**
* `viewedAt` of 0 means the user has never opened this scope. Everything stored
* is then genuinely new to them, which is what a first look should show.
*/
export const classifyMemory = (entry: AgentMemoryEntry, viewedAt: number): MemoryBadge => {
if (entry.createdAt > viewedAt) {
return 'new';
}
// Only a change the user has not seen counts. An entry rewritten before their
// last look was already accounted for by that look.
if (entry.updatedAt > viewedAt) {
return 'changed';
}
return null;
};
export const countHighlightedMemories = (entries: AgentMemoryEntry[], viewedAt: number): number => (
entries.reduce((total, entry) => (classifyMemory(entry, viewedAt) ? total + 1 : total), 0)
);
@@ -25,4 +25,9 @@ describe('FilesystemError', () => {
expect(parseFilesystemErrorReason('made-up')).toBe('unknown');
expect(parseFilesystemErrorReason(undefined)).toBe('unknown');
});
test('recognizes filesystem errors created across runtime boundaries', () => {
expect(isFilesystemError({ reason: 'already-exists' })).toBe(true);
expect(isFilesystemError({ reason: 409 })).toBe(false);
});
});
+2
View File
@@ -1,5 +1,6 @@
export type FilesystemErrorReason =
| 'os-permission'
| 'already-exists'
| 'not-found'
| 'not-directory'
| 'invalid-response'
@@ -30,6 +31,7 @@ export const isFilesystemError = (error: unknown): error is FilesystemError => (
export const parseFilesystemErrorReason = (value: unknown): FilesystemErrorReason => {
switch (value) {
case 'os-permission':
case 'already-exists':
case 'not-found':
case 'not-directory':
case 'invalid-response':
+1
View File
@@ -606,6 +606,7 @@ export interface FilesAPI {
readFile?(path: string, options?: FileReadOptions): Promise<{ content: string; path: string }>;
readFileBinary?(path: string, options?: FileReadOptions): Promise<{ dataUrl: string; path: string }>;
writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>;
uploadFile?(path: string, file: Blob, options?: { overwrite?: boolean; directory?: string }): Promise<{ success: boolean; path: string }>;
delete?(path: string): Promise<{ success: boolean }>;
rename?(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }>;
revealPath?(path: string): Promise<{ success: boolean }>;
+2
View File
@@ -155,6 +155,8 @@ export type DesktopSettings = {
showOpenCodeUpdateNotifications?: boolean;
agentControlToolEnabled?: boolean;
agentWebToolEnabled?: boolean;
agentMemoryToolEnabled?: boolean;
agentMemoryFeatureAvailable?: boolean;
optimizeSystemPrompt?: boolean;
openCodeUpdateToastDismissedVersion?: string;
showToolFileIcons?: boolean;
@@ -960,6 +960,9 @@ export const settingsDict = {
'settings.openchamber.tools.field.agentWebTool': 'OpenChamber-Web-Werkzeug',
'settings.openchamber.tools.field.agentWebToolAria': 'Das OpenChamber-Web-Werkzeug aktivieren',
'settings.openchamber.tools.field.agentWebToolInfo': 'Lässt Agenten die Seite im Browser-Panel von OpenChamber ansehen und bedienen: eine URL öffnen, den Inhalt lesen, klicken, tippen, scrollen und zwischen mobiler und Desktop-Ansicht wechseln. Fügt jeder Sitzung eine kleine Werkzeugbeschreibung hinzu. Gilt nach einem Neustart von OpenCode.',
'settings.openchamber.tools.field.agentMemoryTool': 'Agenten-Gedächtniswerkzeug',
'settings.openchamber.tools.field.agentMemoryToolAria': 'Agenten-Gedächtniswerkzeug',
'settings.openchamber.tools.field.agentMemoryToolInfo': 'Lässt Agenten Gelerntes über Sitzungen hinweg behalten, in zwei Speichern: was über Sie zutrifft und was über das jeweilige Projekt zutrifft. Sitzungen erhalten die gespeicherten Titel, damit der Agent bei Bedarf einen Eintrag lesen kann. Beim Ausschalten entfallen Werkzeug, Gedächtnis-Tab und Sitzungsindex. Gilt nach einem Neustart von OpenCode.',
'settings.openchamber.opencodeCli.tooltipPrefix': 'Optionaler absoluter Pfad zur',
'settings.openchamber.opencodeCli.tooltipSuffix': 'Binary.',
'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode Binary-Pfad',
+59 -9
View File
@@ -1181,6 +1181,14 @@ export const dict = {
'sidebarFilesTree.toast.writeNotSupported': 'Schreiben nicht unterstützt',
'sidebarFilesTree.toast.fileCreated': 'Datei erstellt',
'sidebarFilesTree.toast.operationFailed': 'Operation fehlgeschlagen',
'sidebarFilesTree.toast.uploaded': 'Dateien hochgeladen',
'sidebarFilesTree.toast.uploadedWithoutConflicts': 'Dateien ohne Konflikte wurden hochgeladen',
'sidebarFilesTree.toast.uploadFailed': 'Einige Dateien konnten nicht hochgeladen werden',
'sidebarFilesTree.drop.target': 'In {path} hochladen',
'sidebarFilesTree.drop.uploading': 'Dateien werden in {path} hochgeladen',
'sidebarFilesTree.dialog.uploadConflicts.title': 'Vorhandene Dateien ersetzen?',
'sidebarFilesTree.dialog.uploadConflicts.description': 'Dateien mit diesen Namen sind in {path} bereits vorhanden. Das Ersetzen kann nicht rückgängig gemacht werden.',
'sidebarFilesTree.dialog.uploadConflicts.replace': 'Ersetzen',
'sidebarFilesTree.toast.folderNameRequired': 'Ordnername ist erforderlich',
'sidebarFilesTree.toast.folderCreated': 'Ordner erstellt',
'sidebarFilesTree.toast.nameRequired': 'Name ist erforderlich',
@@ -1405,6 +1413,7 @@ export const dict = {
'planView.file.defaultName': 'plan',
'planView.title.default': 'Plan',
'planView.error.saveFailed': 'Speichern fehlgeschlagen',
'planView.error.loadFailed': 'Plan konnte nicht geladen werden',
'planView.error.previewUnavailable': 'Vorschau nicht verfügbar',
'planView.error.switchToEditMode': 'Wechseln Sie zum Bearbeitungsmodus, um das Problem zu beheben.',
'planView.error.writeFailed': 'Schreiben fehlgeschlagen',
@@ -1487,11 +1496,46 @@ export const dict = {
'diffView.hunk.unsupported': 'Das Staging einzelner Stücke wird in dieser Laufzeitumgebung nicht unterstützt.',
'rightSidebar.contextNotesTodo.plan.defaultTitle': 'Plan',
'rightSidebar.contextNotesTodo.empty.selectProject': 'Wähle ein Projekt aus, um Notizen und Aufgaben hinzuzufügen.',
'rightSidebar.contextNotesTodo.notes.title': 'Schnelle Notizen - {project}',
'rightSidebar.contextNotesTodo.notes.placeholder': 'Kontext, Erinnerungen oder Links festhalten',
'rightSidebar.contextNotesTodo.todo.title': 'Aufgaben',
'rightSidebar.contextNotesTodo.todo.itemsSingle': '{count} Element',
'rightSidebar.contextNotesTodo.todo.itemsPlural': '{count} Elemente',
'rightSidebar.contextNotesTodo.notes.addAria': 'Notiz hinzufügen',
'rightSidebar.contextNotesTodo.notes.empty': 'Noch keine Notizen. Halte Kontext, Erinnerungen oder Links fest.',
'rightSidebar.contextNotesTodo.notes.actions.expand': 'Notiz aufklappen',
'rightSidebar.contextNotesTodo.notes.actions.collapse': 'Notiz zuklappen',
'rightSidebar.contextNotesTodo.notes.actions.delete': 'Notiz löschen',
'rightSidebar.contextNotesTodo.notes.actions.pin': 'An Agent-Kontext anheften',
'rightSidebar.contextNotesTodo.notes.actions.unpin': 'Vom Agent-Kontext lösen',
'rightSidebar.contextNotesTodo.notes.source.selection': 'Aus dem Chat',
'rightSidebar.contextNotesTodo.notes.source.agent': 'Vom Agenten',
'rightSidebar.contextNotesTodo.search.placeholder': 'Suchen',
'rightSidebar.contextNotesTodo.search.clear': 'Suche zurücksetzen',
'rightSidebar.contextNotesTodo.search.noResults': 'Nichts passt zu "{query}".',
'rightSidebar.contextNotesTodo.toast.deleteNoteFailed': 'Notiz konnte nicht gelöscht werden',
'rightSidebar.contextNotesTodo.toast.createNoteFailed': 'Notiz konnte nicht erstellt werden',
'rightSidebar.contextNotesTodo.tabs.notes': 'Notizen',
'rightSidebar.contextNotesTodo.tabs.todos': 'Todo',
'rightSidebar.contextNotesTodo.tabs.plans': 'Pläne',
'rightSidebar.contextNotesTodo.plans.actions.back': 'Zurück zu den Plänen',
'rightSidebar.contextNotesTodo.tabs.memory': 'Gedächtnis',
'rightSidebar.contextNotesTodo.sections.label': 'Bereiche des Projektkontexts',
'rightSidebar.contextNotesTodo.sections.resize': 'Breite der Bereichsleiste ändern',
'rightSidebar.contextNotesTodo.memory.scope.project': 'Projekt',
'rightSidebar.contextNotesTodo.memory.scope.label': 'Gedächtnisbereich',
'rightSidebar.contextNotesTodo.memory.scope.global': 'Über Sie',
'rightSidebar.contextNotesTodo.memory.type.fact': 'Fakt',
'rightSidebar.contextNotesTodo.memory.badge.new': 'neu',
'rightSidebar.contextNotesTodo.memory.flagged': 'Vom Agenten zurückgehalten — liest sich wie eine Anweisung',
'rightSidebar.contextNotesTodo.memory.badge.changed': 'geändert',
'rightSidebar.contextNotesTodo.memory.type.preference': 'Präferenz',
'rightSidebar.contextNotesTodo.memory.type.reference': 'Verweis',
'rightSidebar.contextNotesTodo.memory.actions.delete': 'Diesen Eintrag vergessen',
'rightSidebar.contextNotesTodo.memory.actions.editTitle': 'Titel des Eintrags',
'rightSidebar.contextNotesTodo.memory.actions.editBody': 'Text des Eintrags',
'rightSidebar.contextNotesTodo.memory.toast.saveFailed': 'Eintrag konnte nicht gespeichert werden',
'rightSidebar.contextNotesTodo.memory.toast.deleteFailed': 'Eintrag konnte nicht vergessen werden',
'rightSidebar.contextNotesTodo.memory.empty.nothing': 'Der Agent hat hier noch nichts gespeichert.',
'rightSidebar.contextNotesTodo.memory.empty.noMatches': 'Kein gespeicherter Eintrag passt zur Suche.',
'rightSidebar.contextNotesTodo.memory.empty.noProject': 'Öffnen Sie ein Projekt, um zu sehen, woran sich der Agent erinnert.',
'rightSidebar.contextNotesTodo.memory.empty.unavailable': 'Gespeichertes Gedächtnis konnte nicht geladen werden. Es ging nichts verloren — bitte erneut versuchen.',
'rightSidebar.contextNotesTodo.todo.clearCompleted': 'Abgeschlossene löschen',
'rightSidebar.contextNotesTodo.todo.inputPlaceholder': 'Eine Aufgabe hinzufügen',
'rightSidebar.contextNotesTodo.todo.addAria': 'Aufgabe hinzufügen',
@@ -1502,13 +1546,9 @@ export const dict = {
'rightSidebar.contextNotesTodo.todo.actions.delete': 'Lösche "{text}"',
'rightSidebar.contextNotesTodo.todo.actions.send': 'Sende "{text}"',
'rightSidebar.contextNotesTodo.todo.actions.reorder': 'Ordne "{text}" neu',
'rightSidebar.contextNotesTodo.todo.resizeAria': 'Größe der Aufgabenliste ändern',
'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': 'An aktuelle Sitzung senden',
'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': 'An neue Sitzung senden',
'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': 'An neue Worktree-Sitzung senden',
'rightSidebar.contextNotesTodo.plans.title': 'Pläne',
'rightSidebar.contextNotesTodo.plans.filesSingle': '{count} Datei',
'rightSidebar.contextNotesTodo.plans.filesPlural': '{count} Dateien',
'rightSidebar.contextNotesTodo.plans.importFromFile': 'Plan aus Datei importieren',
'rightSidebar.contextNotesTodo.plans.empty': 'Noch keine gespeicherten Pläne.',
'rightSidebar.contextNotesTodo.plans.deletePlan': 'Plan löschen',
@@ -1528,6 +1568,7 @@ export const dict = {
'rightSidebar.contextNotesTodo.toast.sentToNewSession': 'Todo an neue Sitzung gesendet',
'rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession': 'Todo an neue Worktree-Sitzung gesendet',
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Fehler beim Senden des Todos',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Plan konnte nicht aktualisiert werden',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Fehler beim Löschen des Plans',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': 'Plan-Datei ist leer',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Fehler beim Importieren des Plans',
@@ -3081,6 +3122,9 @@ export const dict = {
'quota.window.monthly': 'Monatliches Limit',
'quota.window.credits': 'Credits',
'quota.window.creditsBalance': 'Kreditguthaben',
'quota.window.monthlyCredits': 'Monatliche Credits',
'quota.window.purchasedCredits': 'Gekaufte Credits',
'quota.window.freeCredits': 'Kostenlose Credits',
'quota.window.billingCycle': 'Abrechnungszyklus',
'quota.window.auto': 'Automatisch',
'quota.window.api': 'API',
@@ -3337,7 +3381,7 @@ export const dict = {
'contextRail.surface.browser.description': 'Browserkontext',
'contextRail.surface.preview.description': 'Vorschaukontext',
'contextRail.surface.chat.description': 'Chatkontext',
'contextRail.surface.notes': 'Notizen',
'contextRail.surface.notes': 'Projektwissen',
'contextRail.editorTree.toggle': 'Editorbaum umschalten',
'sidebarFilesTree.actions.collapseAllTitle': 'Alle einklappen',
'filesView.editor.cannotPreviewBinary': 'Binärdatei kann nicht in der Vorschau angezeigt werden',
@@ -3400,6 +3444,12 @@ export const dict = {
'chat.workStatus.subagent.askedQuestion': 'hat gefragt',
'chat.workStatus.section.contextBreakdown': 'Kontextquellen',
'chat.workStatus.breakdown.skills': 'Skills',
'chat.workStatus.breakdown.pinnedNote': 'Notiz',
'chat.workStatus.breakdown.unpin': 'Vom Kontext lösen',
'chat.workStatus.breakdown.pinnedPlan': 'Plan',
'chat.workStatus.breakdown.memory': 'Agenten-Gedächtnis',
'chat.workStatus.breakdown.pinnedKnowledgeSingle': '{count} angeheftet',
'chat.workStatus.breakdown.pinnedKnowledgePlural': '{count} angeheftet',
'chat.workStatus.breakdown.mcp': 'MCP-Server',
'chat.workStatus.action.openChanges': 'Änderungen öffnen',
'chat.workStatus.action.openGit': 'Git-Panel öffnen',
@@ -1022,6 +1022,9 @@ export const settingsDict = {
'settings.openchamber.tools.field.agentWebTool': 'OpenChamber Web tool',
'settings.openchamber.tools.field.agentWebToolAria': 'Enable the OpenChamber Web tool',
'settings.openchamber.tools.field.agentWebToolInfo': 'Let agents look at and interact with the page in OpenChamber\'s browser panel: open a URL, read the page, click, type, scroll, and switch between mobile and desktop layouts. Adds a small tool description to each session. Applies after OpenCode restarts.',
'settings.openchamber.tools.field.agentMemoryTool': 'Agent memory tool',
'settings.openchamber.tools.field.agentMemoryToolAria': 'Agent memory tool',
'settings.openchamber.tools.field.agentMemoryToolInfo': 'Let agents keep what they learn across sessions, in two stores: what is true about you, and what is true about each project. Sessions are given the stored titles so the agent can read an entry when it is relevant. Turning this off removes the tool, the Memory tab, and the session index. Applies after OpenCode restarts.',
'settings.openchamber.opencodeCli.tooltipPrefix': 'Optional absolute path to the',
'settings.openchamber.opencodeCli.tooltipSuffix': 'binary.',
'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode Binary Path',
+59 -9
View File
@@ -1302,7 +1302,7 @@ export const dict = {
'contextRail.surface.browser.description': 'Built-in web browser',
'contextRail.surface.preview.description': 'Dev server preview',
'contextRail.surface.chat.description': 'Session opened side by side',
'contextRail.surface.notes': 'Project notes',
'contextRail.surface.notes': 'Project knowledge',
'contextRail.editorTree.toggle': 'Toggle file tree',
'contextPanel.browser.open': 'Open browser panel',
'contextPanel.browser.addressAria': 'Browser address',
@@ -1431,6 +1431,14 @@ export const dict = {
'sidebarFilesTree.toast.writeNotSupported': 'Write not supported',
'sidebarFilesTree.toast.fileCreated': 'File created',
'sidebarFilesTree.toast.operationFailed': 'Operation failed',
'sidebarFilesTree.toast.uploaded': 'Files uploaded',
'sidebarFilesTree.toast.uploadedWithoutConflicts': 'Files without conflicts were uploaded',
'sidebarFilesTree.toast.uploadFailed': 'Some files could not be uploaded',
'sidebarFilesTree.drop.target': 'Upload to {path}',
'sidebarFilesTree.drop.uploading': 'Uploading files to {path}',
'sidebarFilesTree.dialog.uploadConflicts.title': 'Replace existing files?',
'sidebarFilesTree.dialog.uploadConflicts.description': 'Files with these names already exist in {path}. Replacing them cannot be undone.',
'sidebarFilesTree.dialog.uploadConflicts.replace': 'Replace',
'sidebarFilesTree.toast.folderNameRequired': 'Folder name is required',
'sidebarFilesTree.toast.folderCreated': 'Folder created',
'sidebarFilesTree.toast.nameRequired': 'Name is required',
@@ -1657,6 +1665,7 @@ export const dict = {
'planView.file.defaultName': 'plan',
'planView.title.default': 'Plan',
'planView.error.saveFailed': 'Save failed',
'planView.error.loadFailed': 'Could not load this plan',
'planView.error.previewUnavailable': 'Preview unavailable',
'planView.error.switchToEditMode': 'Switch to edit mode to fix the issue.',
'planView.error.writeFailed': 'Write failed',
@@ -1739,11 +1748,46 @@ export const dict = {
'diffView.hunk.unsupported': 'Staging individual hunks is not supported in this runtime.',
'rightSidebar.contextNotesTodo.plan.defaultTitle': 'Plan',
'rightSidebar.contextNotesTodo.empty.selectProject': 'Select a project to add notes and todos.',
'rightSidebar.contextNotesTodo.notes.title': 'Quick notes - {project}',
'rightSidebar.contextNotesTodo.notes.placeholder': 'Capture context, reminders, or links',
'rightSidebar.contextNotesTodo.todo.title': 'Todo',
'rightSidebar.contextNotesTodo.todo.itemsSingle': '{count} item',
'rightSidebar.contextNotesTodo.todo.itemsPlural': '{count} items',
'rightSidebar.contextNotesTodo.notes.addAria': 'Add note',
'rightSidebar.contextNotesTodo.notes.empty': 'No notes yet. Capture context, reminders, or links.',
'rightSidebar.contextNotesTodo.notes.actions.expand': 'Expand note',
'rightSidebar.contextNotesTodo.notes.actions.collapse': 'Collapse note',
'rightSidebar.contextNotesTodo.notes.actions.delete': 'Delete note',
'rightSidebar.contextNotesTodo.notes.actions.pin': 'Pin to agent context',
'rightSidebar.contextNotesTodo.notes.actions.unpin': 'Unpin from agent context',
'rightSidebar.contextNotesTodo.notes.source.selection': 'From chat',
'rightSidebar.contextNotesTodo.notes.source.agent': 'From agent',
'rightSidebar.contextNotesTodo.search.placeholder': 'Search',
'rightSidebar.contextNotesTodo.search.clear': 'Clear search',
'rightSidebar.contextNotesTodo.search.noResults': 'Nothing matches "{query}".',
'rightSidebar.contextNotesTodo.toast.deleteNoteFailed': 'Failed to delete note',
'rightSidebar.contextNotesTodo.toast.createNoteFailed': 'Failed to create note',
'rightSidebar.contextNotesTodo.tabs.notes': 'Notes',
'rightSidebar.contextNotesTodo.tabs.todos': 'Todo',
'rightSidebar.contextNotesTodo.tabs.plans': 'Plans',
'rightSidebar.contextNotesTodo.plans.actions.back': 'Back to plans',
'rightSidebar.contextNotesTodo.tabs.memory': 'Memory',
'rightSidebar.contextNotesTodo.sections.label': 'Project context sections',
'rightSidebar.contextNotesTodo.sections.resize': 'Resize sections sidebar',
'rightSidebar.contextNotesTodo.memory.scope.project': 'Project',
'rightSidebar.contextNotesTodo.memory.scope.label': 'Memory scope',
'rightSidebar.contextNotesTodo.memory.scope.global': 'About you',
'rightSidebar.contextNotesTodo.memory.type.fact': 'fact',
'rightSidebar.contextNotesTodo.memory.badge.new': 'new',
'rightSidebar.contextNotesTodo.memory.flagged': 'Withheld from the agent — reads as an instruction',
'rightSidebar.contextNotesTodo.memory.badge.changed': 'changed',
'rightSidebar.contextNotesTodo.memory.type.preference': 'preference',
'rightSidebar.contextNotesTodo.memory.type.reference': 'reference',
'rightSidebar.contextNotesTodo.memory.actions.delete': 'Forget this memory',
'rightSidebar.contextNotesTodo.memory.actions.editTitle': 'Memory title',
'rightSidebar.contextNotesTodo.memory.actions.editBody': 'Memory text',
'rightSidebar.contextNotesTodo.memory.toast.saveFailed': 'Failed to save memory',
'rightSidebar.contextNotesTodo.memory.toast.deleteFailed': 'Failed to forget memory',
'rightSidebar.contextNotesTodo.memory.empty.nothing': 'The agent has stored nothing here yet.',
'rightSidebar.contextNotesTodo.memory.empty.noMatches': 'No stored memory matches your search.',
'rightSidebar.contextNotesTodo.memory.empty.noProject': 'Open a project to see what the agent remembers about it.',
'rightSidebar.contextNotesTodo.memory.empty.unavailable': 'Stored memory could not be loaded. Nothing has been lost — try again.',
'rightSidebar.contextNotesTodo.todo.clearCompleted': 'Clear completed',
'rightSidebar.contextNotesTodo.todo.inputPlaceholder': 'Add a todo',
'rightSidebar.contextNotesTodo.todo.addAria': 'Add todo',
@@ -1754,13 +1798,9 @@ export const dict = {
'rightSidebar.contextNotesTodo.todo.actions.delete': 'Delete "{text}"',
'rightSidebar.contextNotesTodo.todo.actions.send': 'Send "{text}"',
'rightSidebar.contextNotesTodo.todo.actions.reorder': 'Reorder "{text}"',
'rightSidebar.contextNotesTodo.todo.resizeAria': 'Resize todo list',
'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': 'Send to current session',
'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': 'Send to new session',
'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': 'Send to new worktree session',
'rightSidebar.contextNotesTodo.plans.title': 'Plans',
'rightSidebar.contextNotesTodo.plans.filesSingle': '{count} file',
'rightSidebar.contextNotesTodo.plans.filesPlural': '{count} files',
'rightSidebar.contextNotesTodo.plans.importFromFile': 'Import plan from file',
'rightSidebar.contextNotesTodo.plans.empty': 'No saved plans yet.',
'rightSidebar.contextNotesTodo.plans.deletePlan': 'Delete plan',
@@ -1780,6 +1820,7 @@ export const dict = {
'rightSidebar.contextNotesTodo.toast.sentToNewSession': 'Todo sent to new session',
'rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession': 'Todo sent to new worktree session',
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Failed to send todo',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Failed to update plan',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Failed to delete plan',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': 'Plan file is empty',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Failed to import plan',
@@ -3358,6 +3399,9 @@ export const dict = {
'quota.window.monthly': 'Monthly Limit',
'quota.window.credits': 'Credits',
'quota.window.creditsBalance': 'Credits Balance',
'quota.window.monthlyCredits': 'Monthly Credits',
'quota.window.purchasedCredits': 'Purchased Credits',
'quota.window.freeCredits': 'Free Credits',
'quota.window.billingCycle': 'Billing Cycle',
'quota.window.auto': 'Auto',
'quota.window.api': 'API',
@@ -3401,6 +3445,12 @@ export const dict = {
'chat.workStatus.subagent.askedQuestion': 'asked a question',
'chat.workStatus.section.contextBreakdown': 'Context sources',
'chat.workStatus.breakdown.skills': 'Skills',
'chat.workStatus.breakdown.pinnedNote': 'note',
'chat.workStatus.breakdown.unpin': 'Unpin from context',
'chat.workStatus.breakdown.pinnedPlan': 'plan',
'chat.workStatus.breakdown.memory': 'Agent memory',
'chat.workStatus.breakdown.pinnedKnowledgeSingle': '{count} pinned',
'chat.workStatus.breakdown.pinnedKnowledgePlural': '{count} pinned',
'chat.workStatus.breakdown.mcp': 'MCP servers',
'chat.workStatus.action.openChanges': 'Open changes',
'chat.workStatus.action.openGit': 'Open Git panel',
@@ -990,6 +990,9 @@ export const settingsDict = {
"settings.openchamber.tools.field.agentWebTool": "Herramienta OpenChamber Web",
"settings.openchamber.tools.field.agentWebToolAria": "Activar la herramienta OpenChamber Web",
"settings.openchamber.tools.field.agentWebToolInfo": "Permite que los agentes vean la página en el panel de navegador de OpenChamber e interactúen con ella: abrir una URL, leer el contenido, hacer clic, escribir, desplazarse y alternar entre diseño móvil y de escritorio. Añade una pequeña descripción de herramienta a cada sesión. Se aplica tras reiniciar OpenCode.",
"settings.openchamber.tools.field.agentMemoryTool": "Herramienta de memoria del agente",
"settings.openchamber.tools.field.agentMemoryToolAria": "Herramienta de memoria del agente",
"settings.openchamber.tools.field.agentMemoryToolInfo": "Permite que los agentes conserven lo aprendido entre sesiones, en dos almacenes: lo que es cierto sobre ti y lo que es cierto sobre cada proyecto. Las sesiones reciben los títulos guardados para que el agente pueda leer una entrada cuando resulte relevante. Al desactivarla se retiran la herramienta, la pestaña Memoria y el índice de sesión. Se aplica tras reiniciar OpenCode.",
"settings.openchamber.opencodeCli.tooltipPrefix": "Ruta absoluta opcional al",
"settings.openchamber.opencodeCli.tooltipSuffix": "ejecutable.",
"settings.openchamber.opencodeCli.field.binaryPath": "Ruta del ejecutable de OpenCode",
+59 -9
View File
@@ -1303,7 +1303,7 @@ export const dict: Record<I18nKey, string> = {
"contextRail.surface.browser.description": "Navegador web integrado",
"contextRail.surface.preview.description": "Vista previa del servidor de desarrollo",
"contextRail.surface.chat.description": "Sesión abierta en paralelo",
"contextRail.surface.notes": "Notas del proyecto",
"contextRail.surface.notes": "Conocimiento del proyecto",
"contextRail.editorTree.toggle": "Alternar árbol de archivos",
"contextPanel.browser.open": "Abrir panel del navegador",
"contextPanel.browser.addressAria": "Dirección del navegador",
@@ -1397,6 +1397,14 @@ export const dict: Record<I18nKey, string> = {
"sidebarFilesTree.toast.writeNotSupported": "La escritura no es compatible",
"sidebarFilesTree.toast.fileCreated": "Archivo creado",
"sidebarFilesTree.toast.operationFailed": "No se pudo completar la operación",
"sidebarFilesTree.toast.uploaded": "Archivos subidos",
"sidebarFilesTree.toast.uploadedWithoutConflicts": "Se subieron los archivos sin conflictos",
"sidebarFilesTree.toast.uploadFailed": "No se pudieron subir algunos archivos",
"sidebarFilesTree.drop.target": "Subir a {path}",
"sidebarFilesTree.drop.uploading": "Subiendo archivos a {path}",
"sidebarFilesTree.dialog.uploadConflicts.title": "¿Reemplazar los archivos existentes?",
"sidebarFilesTree.dialog.uploadConflicts.description": "Ya existen archivos con estos nombres en {path}. El reemplazo no se puede deshacer.",
"sidebarFilesTree.dialog.uploadConflicts.replace": "Reemplazar",
"sidebarFilesTree.toast.folderNameRequired": "El nombre de carpeta es obligatorio",
"sidebarFilesTree.toast.folderCreated": "Carpeta creada",
"sidebarFilesTree.toast.nameRequired": "El nombre es obligatorio",
@@ -1624,6 +1632,7 @@ export const dict: Record<I18nKey, string> = {
"planView.file.defaultName": "plan",
"planView.title.default": "Plan",
"planView.error.saveFailed": "No se pudo guardar",
"planView.error.loadFailed": "No se pudo cargar este plan",
"planView.error.previewUnavailable": "Vista previa no disponible",
"planView.error.switchToEditMode": "Cambia al modo de edición para resolver el problema.",
"planView.error.writeFailed": "No se pudo escribir",
@@ -1718,11 +1727,46 @@ export const dict: Record<I18nKey, string> = {
"diffView.hunk.unsupported": "Preparar fragmentos individuales no es compatible en este entorno.",
"rightSidebar.contextNotesTodo.plan.defaultTitle": "Plan",
"rightSidebar.contextNotesTodo.empty.selectProject": "Selecciona un proyecto para añadir notas y tareas pendientes.",
"rightSidebar.contextNotesTodo.notes.title": "Notas rápidas — {project}",
"rightSidebar.contextNotesTodo.notes.placeholder": "Captura contexto, recordatorios o enlaces",
"rightSidebar.contextNotesTodo.todo.title": "Tareas pendientes",
"rightSidebar.contextNotesTodo.todo.itemsSingle": "{count} elemento",
"rightSidebar.contextNotesTodo.todo.itemsPlural": "{count} elementos",
"rightSidebar.contextNotesTodo.notes.addAria": "Añadir nota",
"rightSidebar.contextNotesTodo.notes.empty": "Aún no hay notas. Guarda contexto, recordatorios o enlaces.",
"rightSidebar.contextNotesTodo.notes.actions.expand": "Expandir nota",
"rightSidebar.contextNotesTodo.notes.actions.collapse": "Contraer nota",
"rightSidebar.contextNotesTodo.notes.actions.delete": "Eliminar nota",
"rightSidebar.contextNotesTodo.notes.actions.pin": "Fijar al contexto del agente",
"rightSidebar.contextNotesTodo.notes.actions.unpin": "Quitar del contexto del agente",
"rightSidebar.contextNotesTodo.notes.source.selection": "Del chat",
"rightSidebar.contextNotesTodo.notes.source.agent": "Del agente",
"rightSidebar.contextNotesTodo.search.placeholder": "Buscar",
"rightSidebar.contextNotesTodo.search.clear": "Borrar búsqueda",
"rightSidebar.contextNotesTodo.search.noResults": "Nada coincide con \"{query}\".",
"rightSidebar.contextNotesTodo.toast.deleteNoteFailed": "No se pudo eliminar la nota",
"rightSidebar.contextNotesTodo.toast.createNoteFailed": "No se pudo crear la nota",
"rightSidebar.contextNotesTodo.tabs.notes": "Notas",
"rightSidebar.contextNotesTodo.tabs.todos": "Tareas",
"rightSidebar.contextNotesTodo.tabs.plans": "Planes",
"rightSidebar.contextNotesTodo.plans.actions.back": "Volver a los planes",
"rightSidebar.contextNotesTodo.tabs.memory": "Memoria",
"rightSidebar.contextNotesTodo.sections.label": "Secciones del contexto del proyecto",
"rightSidebar.contextNotesTodo.sections.resize": "Redimensionar la barra de secciones",
"rightSidebar.contextNotesTodo.memory.scope.project": "Proyecto",
"rightSidebar.contextNotesTodo.memory.scope.label": "Ámbito de la memoria",
"rightSidebar.contextNotesTodo.memory.scope.global": "Sobre ti",
"rightSidebar.contextNotesTodo.memory.type.fact": "hecho",
"rightSidebar.contextNotesTodo.memory.badge.new": "nuevo",
"rightSidebar.contextNotesTodo.memory.flagged": "Retenido del agente: parece una instrucción",
"rightSidebar.contextNotesTodo.memory.badge.changed": "cambiado",
"rightSidebar.contextNotesTodo.memory.type.preference": "preferencia",
"rightSidebar.contextNotesTodo.memory.type.reference": "referencia",
"rightSidebar.contextNotesTodo.memory.actions.delete": "Olvidar esta memoria",
"rightSidebar.contextNotesTodo.memory.actions.editTitle": "Título de la memoria",
"rightSidebar.contextNotesTodo.memory.actions.editBody": "Texto de la memoria",
"rightSidebar.contextNotesTodo.memory.toast.saveFailed": "No se pudo guardar la memoria",
"rightSidebar.contextNotesTodo.memory.toast.deleteFailed": "No se pudo olvidar la memoria",
"rightSidebar.contextNotesTodo.memory.empty.nothing": "El agente aún no ha guardado nada aquí.",
"rightSidebar.contextNotesTodo.memory.empty.noMatches": "Ninguna memoria guardada coincide con tu búsqueda.",
"rightSidebar.contextNotesTodo.memory.empty.noProject": "Abre un proyecto para ver qué recuerda el agente sobre él.",
"rightSidebar.contextNotesTodo.memory.empty.unavailable": "No se pudo cargar la memoria guardada. No se ha perdido nada: inténtalo de nuevo.",
"rightSidebar.contextNotesTodo.todo.clearCompleted": "Limpiar completadas",
"rightSidebar.contextNotesTodo.todo.inputPlaceholder": "Añade una tarea pendiente",
"rightSidebar.contextNotesTodo.todo.addAria": "Añadir tarea pendiente",
@@ -1733,13 +1777,9 @@ export const dict: Record<I18nKey, string> = {
"rightSidebar.contextNotesTodo.todo.actions.delete": "Eliminar \"{text}\"",
"rightSidebar.contextNotesTodo.todo.actions.send": "Enviar \"{text}\"",
"rightSidebar.contextNotesTodo.todo.actions.reorder": "Reordenar \"{text}\"",
"rightSidebar.contextNotesTodo.todo.resizeAria": "Redimensionar lista de tareas",
"rightSidebar.contextNotesTodo.todo.sendMenu.currentSession": "Enviar a la sesión actual",
"rightSidebar.contextNotesTodo.todo.sendMenu.newSession": "Enviar a una nueva sesión",
"rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession": "Enviar a una nueva sesión de worktree",
"rightSidebar.contextNotesTodo.plans.title": "Planes",
"rightSidebar.contextNotesTodo.plans.filesSingle": "{count} archivo",
"rightSidebar.contextNotesTodo.plans.filesPlural": "{count} archivos",
"rightSidebar.contextNotesTodo.plans.importFromFile": "Importar plan desde archivo",
"rightSidebar.contextNotesTodo.plans.empty": "Aún no hay plans guardados.",
"rightSidebar.contextNotesTodo.plans.deletePlan": "Eliminar plan",
@@ -1759,6 +1799,7 @@ export const dict: Record<I18nKey, string> = {
"rightSidebar.contextNotesTodo.toast.sentToNewSession": "Tarea enviada a una nueva sesión",
"rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession": "Tarea enviada a una nueva sesión de worktree",
"rightSidebar.contextNotesTodo.toast.sendTodoFailed": "No se pudo enviar la tarea",
"rightSidebar.contextNotesTodo.toast.updatePlanFailed": "No se pudo actualizar el plan",
"rightSidebar.contextNotesTodo.toast.deletePlanFailed": "No se pudo eliminar el plan",
"rightSidebar.contextNotesTodo.toast.planFileEmpty": "El archivo del plan está vacío",
"rightSidebar.contextNotesTodo.toast.importPlanFailed": "No se pudo importar el plan",
@@ -3360,6 +3401,9 @@ export const dict: Record<I18nKey, string> = {
"quota.window.monthly": "Monthly Limit",
"quota.window.credits": "Credits",
"quota.window.creditsBalance": "Credits Balance",
"quota.window.monthlyCredits": "Créditos mensuales",
"quota.window.purchasedCredits": "Créditos comprados",
"quota.window.freeCredits": "Créditos gratuitos",
"quota.window.billingCycle": "Billing Cycle",
"quota.window.auto": "Auto",
"quota.window.api": "API",
@@ -3403,6 +3447,12 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.subagent.askedQuestion': 'hizo una pregunta',
'chat.workStatus.section.contextBreakdown': 'Fuentes de contexto',
'chat.workStatus.breakdown.skills': 'Habilidades',
'chat.workStatus.breakdown.pinnedNote': 'nota',
'chat.workStatus.breakdown.unpin': 'Dejar de fijar al contexto',
'chat.workStatus.breakdown.pinnedPlan': 'plan',
'chat.workStatus.breakdown.memory': 'Memoria del agente',
'chat.workStatus.breakdown.pinnedKnowledgeSingle': '{count} fijado',
'chat.workStatus.breakdown.pinnedKnowledgePlural': '{count} fijados',
'chat.workStatus.breakdown.mcp': 'Servidores MCP',
'chat.workStatus.action.openChanges': 'Abrir cambios',
'chat.workStatus.action.openGit': 'Abrir panel de Git',
@@ -908,6 +908,9 @@ export const settingsDict = {
'settings.openchamber.tools.field.agentWebTool': 'Outil OpenChamber Web',
'settings.openchamber.tools.field.agentWebToolAria': 'Activer l’outil OpenChamber Web',
'settings.openchamber.tools.field.agentWebToolInfo': 'Laissez les agents consulter la page dans le panneau navigateur d’OpenChamber et interagir avec elle : ouvrir une URL, lire le contenu, cliquer, saisir du texte, faire défiler et basculer entre les mises en page mobile et bureau. Ajoute une courte description d’outil à chaque session. Appliqué après le redémarrage d’OpenCode.',
'settings.openchamber.tools.field.agentMemoryTool': 'Outil de mémoire de l’agent',
'settings.openchamber.tools.field.agentMemoryToolAria': 'Outil de mémoire de l’agent',
'settings.openchamber.tools.field.agentMemoryToolInfo': 'Permet aux agents de conserver ce qu’ils apprennent d’une session à l’autre, dans deux stockages : ce qui est vrai à votre sujet et ce qui est vrai pour chaque projet. Les sessions reçoivent les titres enregistrés afin que l’agent puisse lire une entrée pertinente. La désactivation retire l’outil, l’onglet Mémoire et l’index de session. Appliqué après le redémarrage d’OpenCode.',
'settings.openchamber.opencodeCli.tooltipPrefix': 'Chemin absolu facultatif vers le',
'settings.openchamber.opencodeCli.tooltipSuffix': 'binaire.',
'settings.openchamber.opencodeCli.field.binaryPath': 'Chemin binaire OpenCode',
+59 -9
View File
@@ -1122,7 +1122,7 @@ export const dict = {
'contextRail.surface.browser.description': 'Navigateur web intégré',
'contextRail.surface.preview.description': 'Aperçu du serveur de développement',
'contextRail.surface.chat.description': 'Session ouverte côte à côte',
'contextRail.surface.notes': 'Notes du projet',
'contextRail.surface.notes': 'Connaissances du projet',
'contextRail.editorTree.toggle': 'Afficher/masquer l’arborescence de fichiers',
'contextPanel.browser.open': 'Ouvrir le panneau du navigateur',
'contextPanel.browser.addressAria': 'Adresse du navigateur',
@@ -1198,6 +1198,14 @@ export const dict = {
'sidebarFilesTree.toast.writeNotSupported': 'Écriture non prise en charge',
'sidebarFilesTree.toast.fileCreated': 'Fichier créé',
'sidebarFilesTree.toast.operationFailed': 'L\'opération a échoué',
'sidebarFilesTree.toast.uploaded': 'Fichiers téléversés',
'sidebarFilesTree.toast.uploadedWithoutConflicts': 'Les fichiers sans conflit ont été téléversés',
'sidebarFilesTree.toast.uploadFailed': 'Certains fichiers n’ont pas pu être téléversés',
'sidebarFilesTree.drop.target': 'Téléverser dans {path}',
'sidebarFilesTree.drop.uploading': 'Téléversement des fichiers dans {path}',
'sidebarFilesTree.dialog.uploadConflicts.title': 'Remplacer les fichiers existants ?',
'sidebarFilesTree.dialog.uploadConflicts.description': 'Des fichiers portant ces noms existent déjà dans {path}. Leur remplacement est irréversible.',
'sidebarFilesTree.dialog.uploadConflicts.replace': 'Remplacer',
'sidebarFilesTree.toast.folderNameRequired': 'Le nom du dossier est requis',
'sidebarFilesTree.toast.folderCreated': 'Dossier créé',
'sidebarFilesTree.toast.nameRequired': 'Le nom est requis',
@@ -1333,6 +1341,7 @@ export const dict = {
'planView.file.defaultName': 'plan',
'planView.title.default': 'Plan',
'planView.error.saveFailed': 'Échec de l\'enregistrement',
'planView.error.loadFailed': 'Impossible de charger ce plan',
'planView.error.previewUnavailable': 'Aperçu indisponible',
'planView.error.switchToEditMode': 'Passez en mode édition pour résoudre le problème.',
'planView.error.writeFailed': 'Échec de l\'écriture',
@@ -1415,11 +1424,46 @@ export const dict = {
'diffView.hunk.unsupported': "La préparation de sections individuelles n'est pas prise en charge dans cet environnement.",
'rightSidebar.contextNotesTodo.plan.defaultTitle': 'Plan',
'rightSidebar.contextNotesTodo.empty.selectProject': 'Sélectionnez un projet pour ajouter des notes et des tâches.',
'rightSidebar.contextNotesTodo.notes.title': 'Notes rapides - {project}',
'rightSidebar.contextNotesTodo.notes.placeholder': 'Capturez le contexte, les rappels ou les liens',
'rightSidebar.contextNotesTodo.todo.title': 'Faire',
'rightSidebar.contextNotesTodo.todo.itemsSingle': 'Article {count}',
'rightSidebar.contextNotesTodo.todo.itemsPlural': 'Articles {count}',
'rightSidebar.contextNotesTodo.notes.addAria': 'Ajouter une note',
'rightSidebar.contextNotesTodo.notes.empty': 'Aucune note pour le moment. Notez du contexte, des rappels ou des liens.',
'rightSidebar.contextNotesTodo.notes.actions.expand': 'Développer la note',
'rightSidebar.contextNotesTodo.notes.actions.collapse': 'Réduire la note',
'rightSidebar.contextNotesTodo.notes.actions.delete': 'Supprimer la note',
'rightSidebar.contextNotesTodo.notes.actions.pin': 'Épingler au contexte de l\'agent',
'rightSidebar.contextNotesTodo.notes.actions.unpin': 'Détacher du contexte de l\'agent',
'rightSidebar.contextNotesTodo.notes.source.selection': 'Depuis le chat',
'rightSidebar.contextNotesTodo.notes.source.agent': 'Depuis l\'agent',
'rightSidebar.contextNotesTodo.search.placeholder': 'Rechercher',
'rightSidebar.contextNotesTodo.search.clear': 'Effacer la recherche',
'rightSidebar.contextNotesTodo.search.noResults': 'Aucun résultat pour "{query}".',
'rightSidebar.contextNotesTodo.toast.deleteNoteFailed': 'Échec de la suppression de la note',
'rightSidebar.contextNotesTodo.toast.createNoteFailed': 'Échec de la création de la note',
'rightSidebar.contextNotesTodo.tabs.notes': 'Notes',
'rightSidebar.contextNotesTodo.tabs.todos': 'Tâches',
'rightSidebar.contextNotesTodo.tabs.plans': 'Plans',
'rightSidebar.contextNotesTodo.plans.actions.back': 'Retour aux plans',
'rightSidebar.contextNotesTodo.tabs.memory': 'Mémoire',
'rightSidebar.contextNotesTodo.sections.label': 'Sections du contexte du projet',
'rightSidebar.contextNotesTodo.sections.resize': 'Redimensionner la barre des sections',
'rightSidebar.contextNotesTodo.memory.scope.project': 'Projet',
'rightSidebar.contextNotesTodo.memory.scope.label': 'Portée de la mémoire',
'rightSidebar.contextNotesTodo.memory.scope.global': 'À votre sujet',
'rightSidebar.contextNotesTodo.memory.type.fact': 'fait',
'rightSidebar.contextNotesTodo.memory.badge.new': 'nouveau',
'rightSidebar.contextNotesTodo.memory.flagged': 'Retenu — se lit comme une instruction',
'rightSidebar.contextNotesTodo.memory.badge.changed': 'modifié',
'rightSidebar.contextNotesTodo.memory.type.preference': 'préférence',
'rightSidebar.contextNotesTodo.memory.type.reference': 'référence',
'rightSidebar.contextNotesTodo.memory.actions.delete': 'Oublier cette mémoire',
'rightSidebar.contextNotesTodo.memory.actions.editTitle': 'Titre de la mémoire',
'rightSidebar.contextNotesTodo.memory.actions.editBody': 'Texte de la mémoire',
'rightSidebar.contextNotesTodo.memory.toast.saveFailed': 'Impossible d’enregistrer la mémoire',
'rightSidebar.contextNotesTodo.memory.toast.deleteFailed': 'Impossible d’oublier la mémoire',
'rightSidebar.contextNotesTodo.memory.empty.nothing': 'L’agent n’a encore rien enregistré ici.',
'rightSidebar.contextNotesTodo.memory.empty.noMatches': 'Aucune mémoire enregistrée ne correspond à votre recherche.',
'rightSidebar.contextNotesTodo.memory.empty.noProject': 'Ouvrez un projet pour voir ce que l’agent en retient.',
'rightSidebar.contextNotesTodo.memory.empty.unavailable': 'Impossible de charger la mémoire enregistrée. Rien n’est perdu — réessayez.',
'rightSidebar.contextNotesTodo.todo.clearCompleted': 'Effacer terminé',
'rightSidebar.contextNotesTodo.todo.inputPlaceholder': 'Ajouter une tâche',
'rightSidebar.contextNotesTodo.todo.addAria': 'Ajouter une tâche',
@@ -1430,13 +1474,9 @@ export const dict = {
'rightSidebar.contextNotesTodo.todo.actions.delete': 'Supprimer "{text}"',
'rightSidebar.contextNotesTodo.todo.actions.send': 'Envoyer "{text}"',
'rightSidebar.contextNotesTodo.todo.actions.reorder': 'Récommander "{text}"',
'rightSidebar.contextNotesTodo.todo.resizeAria': 'Redimensionner la liste de tâches',
'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': 'Envoyer à la session en cours',
'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': 'Envoyer à une nouvelle session',
'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': 'Envoyer à une nouvelle session Worktree',
'rightSidebar.contextNotesTodo.plans.title': 'Forfaits',
'rightSidebar.contextNotesTodo.plans.filesSingle': 'Fichier {count}',
'rightSidebar.contextNotesTodo.plans.filesPlural': 'Fichiers {count}',
'rightSidebar.contextNotesTodo.plans.importFromFile': 'Importer un plan à partir d\'un fichier',
'rightSidebar.contextNotesTodo.plans.empty': 'Aucun plan enregistré pour l\'instant.',
'rightSidebar.contextNotesTodo.plans.deletePlan': 'Supprimer le forfait',
@@ -1456,6 +1496,7 @@ export const dict = {
'rightSidebar.contextNotesTodo.toast.sentToNewSession': 'Todo envoyé à une nouvelle session',
'rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession': 'Todo envoyé à une nouvelle session Worktree',
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Échec de l\'envoi de la tâche',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Échec de la mise à jour du plan',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Échec de la suppression du plan',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': 'Le fichier de plan est vide',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Échec de l\'importation du plan',
@@ -2962,6 +3003,9 @@ export const dict = {
'quota.window.monthly': 'Limite mensuelle',
'quota.window.credits': 'Crédits',
'quota.window.creditsBalance': 'Solde de crédits',
'quota.window.monthlyCredits': 'Crédits mensuels',
'quota.window.purchasedCredits': 'Crédits achetés',
'quota.window.freeCredits': 'Crédits gratuits',
'quota.window.billingCycle': 'Cycle de facturation',
'quota.window.auto': 'Auto',
'quota.window.api': 'API',
@@ -3400,6 +3444,12 @@ export const dict = {
'chat.workStatus.subagent.askedQuestion': 'a posé une question',
'chat.workStatus.section.contextBreakdown': 'Sources de contexte',
'chat.workStatus.breakdown.skills': 'Compétences',
'chat.workStatus.breakdown.pinnedNote': 'note',
'chat.workStatus.breakdown.unpin': 'Détacher du contexte',
'chat.workStatus.breakdown.pinnedPlan': 'plan',
'chat.workStatus.breakdown.memory': 'Mémoire de l’agent',
'chat.workStatus.breakdown.pinnedKnowledgeSingle': '{count} épinglé',
'chat.workStatus.breakdown.pinnedKnowledgePlural': '{count} épinglés',
'chat.workStatus.breakdown.mcp': 'Serveurs MCP',
'chat.workStatus.action.openChanges': 'Ouvrir les modifications',
'chat.workStatus.action.openGit': 'Ouvrir le panneau Git',
@@ -1023,6 +1023,9 @@ export const settingsDict = {
'settings.openchamber.tools.field.agentWebTool': 'OpenChamber Web ツール',
'settings.openchamber.tools.field.agentWebToolAria': 'OpenChamber Web ツールを有効にする',
'settings.openchamber.tools.field.agentWebToolInfo': 'エージェントが OpenChamber のブラウザーパネルでページを確認し操作できるようにします。URL を開く、内容を読む、クリック、入力、スクロール、モバイルとデスクトップのレイアウト切り替えが可能です。各セッションに小さなツール説明が追加されます。OpenCode の再起動後に適用されます。',
'settings.openchamber.tools.field.agentMemoryTool': 'エージェントメモリツール',
'settings.openchamber.tools.field.agentMemoryToolAria': 'エージェントメモリツール',
'settings.openchamber.tools.field.agentMemoryToolInfo': 'エージェントが学んだことをセッションをまたいで保持できるようにします。保存先は 2 つで、ユーザーについての事実と、各プロジェクトについての事実です。セッションには保存済みのタイトルが渡され、関連する項目をエージェントが読み出せます。オフにするとツール、メモリタブ、セッションインデックスがすべてなくなります。OpenCode の再起動後に反映されます。',
'settings.openchamber.opencodeCli.tooltipPrefix': '以下への絶対パス(任意):',
'settings.openchamber.opencodeCli.tooltipSuffix': 'バイナリ。',
'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode バイナリパス',
+59 -9
View File
@@ -1299,7 +1299,7 @@ export const dict: Record<I18nKey, string> = {
'contextRail.surface.browser.description': '内蔵ウェブブラウザ',
'contextRail.surface.preview.description': '開発サーバーのプレビュー',
'contextRail.surface.chat.description': '並べて開いたセッション',
'contextRail.surface.notes': 'プロジェクトノート',
'contextRail.surface.notes': 'プロジェクトナレッジ',
'contextRail.editorTree.toggle': 'ファイルツリーの表示切替',
'contextPanel.browser.open': 'ブラウザパネルを開く',
'contextPanel.browser.addressAria': 'ブラウザアドレス',
@@ -1427,6 +1427,14 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.toast.writeNotSupported': '書き込みはサポートされていません',
'sidebarFilesTree.toast.fileCreated': 'ファイルを作成しました',
'sidebarFilesTree.toast.operationFailed': '操作に失敗しました',
'sidebarFilesTree.toast.uploaded': 'ファイルをアップロードしました',
'sidebarFilesTree.toast.uploadedWithoutConflicts': '競合のないファイルをアップロードしました',
'sidebarFilesTree.toast.uploadFailed': '一部のファイルをアップロードできませんでした',
'sidebarFilesTree.drop.target': '{path} にアップロード',
'sidebarFilesTree.drop.uploading': '{path} にファイルをアップロードしています',
'sidebarFilesTree.dialog.uploadConflicts.title': '既存のファイルを置き換えますか?',
'sidebarFilesTree.dialog.uploadConflicts.description': '同じ名前のファイルが {path} に既に存在します。置き換えは元に戻せません。',
'sidebarFilesTree.dialog.uploadConflicts.replace': '置き換える',
'sidebarFilesTree.toast.folderNameRequired': 'フォルダ名が必要です',
'sidebarFilesTree.toast.folderCreated': 'フォルダを作成しました',
'sidebarFilesTree.toast.nameRequired': '名前が必要です',
@@ -1654,6 +1662,7 @@ export const dict: Record<I18nKey, string> = {
'planView.file.defaultName': '計画',
'planView.title.default': '計画',
'planView.error.saveFailed': '保存に失敗しました',
'planView.error.loadFailed': 'この計画を読み込めませんでした',
'planView.error.previewUnavailable': 'プレビューは利用できません',
'planView.error.switchToEditMode': '編集モードに切り替えて問題を修正してください。',
'planView.error.writeFailed': '書き込みに失敗しました',
@@ -1736,11 +1745,46 @@ export const dict: Record<I18nKey, string> = {
'chat.autoReview.actions.stop': '停止',
'rightSidebar.contextNotesTodo.plan.defaultTitle': '計画',
'rightSidebar.contextNotesTodo.empty.selectProject': 'プロジェクトを選択してメモとTODOを追加します。',
'rightSidebar.contextNotesTodo.notes.title': 'クイックメモ - {project}',
'rightSidebar.contextNotesTodo.notes.placeholder': 'コンテキスト、リマインダー、リンクを記録',
'rightSidebar.contextNotesTodo.todo.title': 'TODO',
'rightSidebar.contextNotesTodo.todo.itemsSingle': '{count}項目',
'rightSidebar.contextNotesTodo.todo.itemsPlural': '{count}項目',
'rightSidebar.contextNotesTodo.notes.addAria': 'ノートを追加',
'rightSidebar.contextNotesTodo.notes.empty': 'ノートはまだありません。文脈やメモ、リンクを残せます。',
'rightSidebar.contextNotesTodo.notes.actions.expand': 'ノートを展開',
'rightSidebar.contextNotesTodo.notes.actions.collapse': 'ノートを折りたたむ',
'rightSidebar.contextNotesTodo.notes.actions.delete': 'ノートを削除',
'rightSidebar.contextNotesTodo.notes.actions.pin': 'エージェントのコンテキストにピン留め',
'rightSidebar.contextNotesTodo.notes.actions.unpin': 'エージェントのコンテキストからピン留めを解除',
'rightSidebar.contextNotesTodo.notes.source.selection': 'チャットから',
'rightSidebar.contextNotesTodo.notes.source.agent': 'エージェントから',
'rightSidebar.contextNotesTodo.search.placeholder': '検索',
'rightSidebar.contextNotesTodo.search.clear': '検索をクリア',
'rightSidebar.contextNotesTodo.search.noResults': '「{query}」に一致するものはありません。',
'rightSidebar.contextNotesTodo.toast.deleteNoteFailed': 'ノートを削除できませんでした',
'rightSidebar.contextNotesTodo.toast.createNoteFailed': 'ノートを作成できませんでした',
'rightSidebar.contextNotesTodo.tabs.notes': 'ノート',
'rightSidebar.contextNotesTodo.tabs.todos': 'Todo',
'rightSidebar.contextNotesTodo.tabs.plans': '計画',
'rightSidebar.contextNotesTodo.plans.actions.back': 'プラン一覧に戻る',
'rightSidebar.contextNotesTodo.tabs.memory': 'メモリ',
'rightSidebar.contextNotesTodo.sections.label': 'プロジェクトコンテキストのセクション',
'rightSidebar.contextNotesTodo.sections.resize': 'セクションサイドバーの幅を変更',
'rightSidebar.contextNotesTodo.memory.scope.project': 'プロジェクト',
'rightSidebar.contextNotesTodo.memory.scope.label': 'メモリの範囲',
'rightSidebar.contextNotesTodo.memory.scope.global': 'あなたについて',
'rightSidebar.contextNotesTodo.memory.type.fact': '事実',
'rightSidebar.contextNotesTodo.memory.badge.new': '新規',
'rightSidebar.contextNotesTodo.memory.flagged': 'エージェントには渡されません — 指示のように読めます',
'rightSidebar.contextNotesTodo.memory.badge.changed': '変更',
'rightSidebar.contextNotesTodo.memory.type.preference': '設定',
'rightSidebar.contextNotesTodo.memory.type.reference': '参照',
'rightSidebar.contextNotesTodo.memory.actions.delete': 'この項目を削除',
'rightSidebar.contextNotesTodo.memory.actions.editTitle': 'メモリのタイトル',
'rightSidebar.contextNotesTodo.memory.actions.editBody': 'メモリの本文',
'rightSidebar.contextNotesTodo.memory.toast.saveFailed': 'メモリを保存できませんでした',
'rightSidebar.contextNotesTodo.memory.toast.deleteFailed': '項目を削除できませんでした',
'rightSidebar.contextNotesTodo.memory.empty.nothing': 'エージェントはまだ何も保存していません。',
'rightSidebar.contextNotesTodo.memory.empty.noMatches': '検索条件に一致する項目はありません。',
'rightSidebar.contextNotesTodo.memory.empty.noProject': 'プロジェクトを開くと、エージェントが記憶している内容を確認できます。',
'rightSidebar.contextNotesTodo.memory.empty.unavailable': '保存された記憶を読み込めませんでした。失われてはいません。もう一度お試しください。',
'rightSidebar.contextNotesTodo.todo.clearCompleted': '完了をクリア',
'rightSidebar.contextNotesTodo.todo.inputPlaceholder': 'TODOを追加',
'rightSidebar.contextNotesTodo.todo.addAria': 'TODOを追加',
@@ -1751,13 +1795,9 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.todo.actions.delete': '「{text}」を削除',
'rightSidebar.contextNotesTodo.todo.actions.send': '「{text}」を送信',
'rightSidebar.contextNotesTodo.todo.actions.reorder': '「{text}」を並び替え',
'rightSidebar.contextNotesTodo.todo.resizeAria': 'TODOリストのサイズを変更',
'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': '現在のセッションに送信',
'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': '新しいセッションに送信',
'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': '新しいワークツリーセッションに送信',
'rightSidebar.contextNotesTodo.plans.title': '計画',
'rightSidebar.contextNotesTodo.plans.filesSingle': '{count}ファイル',
'rightSidebar.contextNotesTodo.plans.filesPlural': '{count}ファイル',
'rightSidebar.contextNotesTodo.plans.importFromFile': 'ファイルから計画をインポート',
'rightSidebar.contextNotesTodo.plans.empty': 'まだ保存された計画はありません。',
'rightSidebar.contextNotesTodo.plans.deletePlan': '計画を削除',
@@ -1777,6 +1817,7 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.toast.sentToNewSession': 'TODOを新しいセッションに送信しました',
'rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession': 'TODOを新しいワークツリーセッションに送信しました',
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'TODOの送信に失敗しました',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': '計画を更新できませんでした',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '計画の削除に失敗しました',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': '計画ファイルが空です',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': '計画のインポートに失敗しました',
@@ -3355,6 +3396,9 @@ export const dict: Record<I18nKey, string> = {
'quota.window.monthly': '月間制限',
'quota.window.credits': 'クレジット',
'quota.window.creditsBalance': 'クレジット残高',
'quota.window.monthlyCredits': '月間クレジット',
'quota.window.purchasedCredits': '購入済みクレジット',
'quota.window.freeCredits': '無料クレジット',
'quota.window.billingCycle': '請求サイクル',
'quota.window.auto': '自動',
'quota.window.api': 'API',
@@ -3402,6 +3446,12 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.subagent.askedQuestion': '質問があります',
'chat.workStatus.section.contextBreakdown': 'コンテキストソース',
'chat.workStatus.breakdown.skills': 'スキル',
'chat.workStatus.breakdown.pinnedNote': 'メモ',
'chat.workStatus.breakdown.unpin': 'コンテキストからピンを外す',
'chat.workStatus.breakdown.pinnedPlan': 'プラン',
'chat.workStatus.breakdown.memory': 'エージェントメモリ',
'chat.workStatus.breakdown.pinnedKnowledgeSingle': '{count} 件ピン留め',
'chat.workStatus.breakdown.pinnedKnowledgePlural': '{count} 件ピン留め',
'chat.workStatus.breakdown.mcp': 'MCP サーバー',
'chat.workStatus.action.openChanges': '変更を開く',
'chat.workStatus.action.openGit': 'Git パネルを開く',
@@ -990,6 +990,9 @@ export const settingsDict = {
'settings.openchamber.tools.field.agentWebTool': 'OpenChamber Web 도구',
'settings.openchamber.tools.field.agentWebToolAria': 'OpenChamber Web 도구 활성화',
'settings.openchamber.tools.field.agentWebToolInfo': '에이전트가 OpenChamber 브라우저 패널에서 페이지를 확인하고 조작할 수 있습니다. URL 열기, 내용 읽기, 클릭, 입력, 스크롤, 모바일과 데스크톱 레이아웃 전환이 가능합니다. 각 세션에 작은 도구 설명이 추가됩니다. OpenCode를 다시 시작하면 적용됩니다.',
'settings.openchamber.tools.field.agentMemoryTool': '에이전트 메모리 도구',
'settings.openchamber.tools.field.agentMemoryToolAria': '에이전트 메모리 도구',
'settings.openchamber.tools.field.agentMemoryToolInfo': '에이전트가 배운 내용을 세션 간에 유지하도록 합니다. 저장소는 두 개로, 사용자에 대한 사실과 각 프로젝트에 대한 사실입니다. 세션에는 저장된 제목이 전달되어 관련 항목을 에이전트가 읽을 수 있습니다. 끄면 도구와 메모리 탭, 세션 색인이 모두 사라집니다. OpenCode를 다시 시작한 뒤 적용됩니다.',
'settings.openchamber.opencodeCli.tooltipPrefix': '선택적 절대 경로:',
'settings.openchamber.opencodeCli.tooltipSuffix': 'binary.',
'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode binary 경로',
+59 -9
View File
@@ -1303,7 +1303,7 @@ export const dict: Record<I18nKey, string> = {
'contextRail.surface.browser.description': '내장 웹 브라우저',
'contextRail.surface.preview.description': '개발 서버 미리보기',
'contextRail.surface.chat.description': '나란히 연 세션',
'contextRail.surface.notes': '프로젝트 노트',
'contextRail.surface.notes': '프로젝트 지식',
'contextRail.editorTree.toggle': '파일 트리 표시 전환',
'contextPanel.browser.open': '브라우저 패널 열기',
'contextPanel.browser.addressAria': '브라우저 주소',
@@ -1433,6 +1433,14 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.toast.writeNotSupported': '쓰기를 지원하지 않음',
'sidebarFilesTree.toast.fileCreated': '파일 생성됨',
'sidebarFilesTree.toast.operationFailed': '작업 실패',
'sidebarFilesTree.toast.uploaded': '파일을 업로드했습니다',
'sidebarFilesTree.toast.uploadedWithoutConflicts': '충돌하지 않은 파일을 업로드했습니다',
'sidebarFilesTree.toast.uploadFailed': '일부 파일을 업로드하지 못했습니다',
'sidebarFilesTree.drop.target': '{path}에 업로드',
'sidebarFilesTree.drop.uploading': '{path}에 파일 업로드 중',
'sidebarFilesTree.dialog.uploadConflicts.title': '기존 파일을 교체할까요?',
'sidebarFilesTree.dialog.uploadConflicts.description': '같은 이름의 파일이 {path}에 이미 있습니다. 교체 작업은 취소할 수 없습니다.',
'sidebarFilesTree.dialog.uploadConflicts.replace': '교체',
'sidebarFilesTree.toast.folderNameRequired': '폴더 이름 필수',
'sidebarFilesTree.toast.folderCreated': '폴더 생성됨',
'sidebarFilesTree.toast.nameRequired': '이름 필수',
@@ -1660,6 +1668,7 @@ export const dict: Record<I18nKey, string> = {
'planView.file.defaultName': 'plan',
'planView.title.default': '플랜',
'planView.error.saveFailed': '저장 실패',
'planView.error.loadFailed': '이 계획을 불러오지 못했습니다',
'planView.error.previewUnavailable': '미리보기를 사용할 수 없음',
'planView.error.switchToEditMode': '문제를 수정하려면 편집 모드로 전환하세요.',
'planView.error.writeFailed': '쓰기 실패',
@@ -1742,11 +1751,46 @@ export const dict: Record<I18nKey, string> = {
'diffView.hunk.unsupported': '개별 허크 스테이징은 이 환경에서 지원되지 않습니다.',
'rightSidebar.contextNotesTodo.plan.defaultTitle': '플랜',
'rightSidebar.contextNotesTodo.empty.selectProject': '메모와 Todo를 추가할 프로젝트를 선택하세요.',
'rightSidebar.contextNotesTodo.notes.title': '빠른 메모 - {project}',
'rightSidebar.contextNotesTodo.notes.placeholder': '컨텍스트, 리마인더, 링크를 기록하세요',
'rightSidebar.contextNotesTodo.todo.title': 'Todo',
'rightSidebar.contextNotesTodo.todo.itemsSingle': '{count}개 항목',
'rightSidebar.contextNotesTodo.todo.itemsPlural': '{count}개 항목',
'rightSidebar.contextNotesTodo.notes.addAria': '노트 추가',
'rightSidebar.contextNotesTodo.notes.empty': '아직 노트가 없습니다. 맥락이나 메모, 링크를 남겨 보세요.',
'rightSidebar.contextNotesTodo.notes.actions.expand': '노트 펼치기',
'rightSidebar.contextNotesTodo.notes.actions.collapse': '노트 접기',
'rightSidebar.contextNotesTodo.notes.actions.delete': '노트 삭제',
'rightSidebar.contextNotesTodo.notes.actions.pin': '에이전트 컨텍스트에 고정',
'rightSidebar.contextNotesTodo.notes.actions.unpin': '에이전트 컨텍스트에서 고정 해제',
'rightSidebar.contextNotesTodo.notes.source.selection': '채팅에서',
'rightSidebar.contextNotesTodo.notes.source.agent': '에이전트에서',
'rightSidebar.contextNotesTodo.search.placeholder': '검색',
'rightSidebar.contextNotesTodo.search.clear': '검색 지우기',
'rightSidebar.contextNotesTodo.search.noResults': '"{query}"과(와) 일치하는 항목이 없습니다.',
'rightSidebar.contextNotesTodo.toast.deleteNoteFailed': '노트를 삭제하지 못했습니다',
'rightSidebar.contextNotesTodo.toast.createNoteFailed': '노트를 만들지 못했습니다',
'rightSidebar.contextNotesTodo.tabs.notes': '노트',
'rightSidebar.contextNotesTodo.tabs.todos': '할 일',
'rightSidebar.contextNotesTodo.tabs.plans': '계획',
'rightSidebar.contextNotesTodo.plans.actions.back': '계획 목록으로',
'rightSidebar.contextNotesTodo.tabs.memory': '메모리',
'rightSidebar.contextNotesTodo.sections.label': '프로젝트 컨텍스트 섹션',
'rightSidebar.contextNotesTodo.sections.resize': '섹션 사이드바 너비 조절',
'rightSidebar.contextNotesTodo.memory.scope.project': '프로젝트',
'rightSidebar.contextNotesTodo.memory.scope.label': '메모리 범위',
'rightSidebar.contextNotesTodo.memory.scope.global': '사용자 정보',
'rightSidebar.contextNotesTodo.memory.type.fact': '사실',
'rightSidebar.contextNotesTodo.memory.badge.new': '신규',
'rightSidebar.contextNotesTodo.memory.flagged': '에이전트에 전달되지 않음 — 지시문처럼 읽힘',
'rightSidebar.contextNotesTodo.memory.badge.changed': '변경',
'rightSidebar.contextNotesTodo.memory.type.preference': '선호',
'rightSidebar.contextNotesTodo.memory.type.reference': '참조',
'rightSidebar.contextNotesTodo.memory.actions.delete': '이 항목 삭제',
'rightSidebar.contextNotesTodo.memory.actions.editTitle': '메모리 제목',
'rightSidebar.contextNotesTodo.memory.actions.editBody': '메모리 내용',
'rightSidebar.contextNotesTodo.memory.toast.saveFailed': '메모리를 저장하지 못했습니다',
'rightSidebar.contextNotesTodo.memory.toast.deleteFailed': '항목을 삭제하지 못했습니다',
'rightSidebar.contextNotesTodo.memory.empty.nothing': '에이전트가 아직 저장한 항목이 없습니다.',
'rightSidebar.contextNotesTodo.memory.empty.noMatches': '검색과 일치하는 저장 항목이 없습니다.',
'rightSidebar.contextNotesTodo.memory.empty.noProject': '프로젝트를 열면 에이전트가 기억하는 내용을 볼 수 있습니다.',
'rightSidebar.contextNotesTodo.memory.empty.unavailable': '저장된 메모리를 불러오지 못했습니다. 사라진 것은 없습니다. 다시 시도하세요.',
'rightSidebar.contextNotesTodo.todo.clearCompleted': '완료 항목 지우기',
'rightSidebar.contextNotesTodo.todo.inputPlaceholder': 'Todo 추가',
'rightSidebar.contextNotesTodo.todo.addAria': 'Todo 추가',
@@ -1757,13 +1801,9 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.todo.actions.delete': '"{text}" 삭제',
'rightSidebar.contextNotesTodo.todo.actions.send': '보내기 "{text}"',
'rightSidebar.contextNotesTodo.todo.actions.reorder': '재정렬 "{text}"',
'rightSidebar.contextNotesTodo.todo.resizeAria': '할 일 목록 크기 조정',
'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': '현재 세션으로 보내기',
'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': '새 세션으로 보내기',
'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': '새 워크트리 세션으로 보내기',
'rightSidebar.contextNotesTodo.plans.title': '플랜',
'rightSidebar.contextNotesTodo.plans.filesSingle': '{count} 파일',
'rightSidebar.contextNotesTodo.plans.filesPlural': '{count} 파일',
'rightSidebar.contextNotesTodo.plans.importFromFile': '파일에서 플랜 가져오기',
'rightSidebar.contextNotesTodo.plans.empty': '아직 저장된 플랜 없음',
'rightSidebar.contextNotesTodo.plans.deletePlan': '플랜 삭제',
@@ -1783,6 +1823,7 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.toast.sentToNewSession': '할 일을 새 세션으로 보냈습니다',
'rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession': '할 일을 새 워크트리 세션으로 보냈습니다',
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Todo 전송 실패',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': '계획을 업데이트하지 못했습니다',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '플랜 삭제 실패',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': '플랜 파일이 비어 있음',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': '플랜 가져오기 실패',
@@ -3359,6 +3400,9 @@ export const dict: Record<I18nKey, string> = {
'quota.window.monthly': 'Monthly Limit',
'quota.window.credits': 'Credits',
'quota.window.creditsBalance': 'Credits Balance',
'quota.window.monthlyCredits': '월간 크레딧',
'quota.window.purchasedCredits': '구매한 크레딧',
'quota.window.freeCredits': '무료 크레딧',
'quota.window.billingCycle': 'Billing Cycle',
'quota.window.auto': 'Auto',
'quota.window.api': 'API',
@@ -3402,6 +3446,12 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.subagent.askedQuestion': '질문함',
'chat.workStatus.section.contextBreakdown': '컨텍스트 소스',
'chat.workStatus.breakdown.skills': '스킬',
'chat.workStatus.breakdown.pinnedNote': '노트',
'chat.workStatus.breakdown.unpin': '컨텍스트에서 고정 해제',
'chat.workStatus.breakdown.pinnedPlan': '계획',
'chat.workStatus.breakdown.memory': '에이전트 메모리',
'chat.workStatus.breakdown.pinnedKnowledgeSingle': '{count}개 고정',
'chat.workStatus.breakdown.pinnedKnowledgePlural': '{count}개 고정',
'chat.workStatus.breakdown.mcp': 'MCP 서버',
'chat.workStatus.action.openChanges': '변경 사항 열기',
'chat.workStatus.action.openGit': 'Git 패널 열기',
@@ -950,6 +950,9 @@ export const settingsDict = {
'settings.openchamber.tools.field.agentWebTool': 'Narzędzie OpenChamber Web',
'settings.openchamber.tools.field.agentWebToolAria': 'Włącz narzędzie OpenChamber Web',
'settings.openchamber.tools.field.agentWebToolInfo': 'Pozwól agentom oglądać stronę w panelu przeglądarki OpenChamber i wchodzić z nią w interakcję: otwierać adres URL, czytać treść, klikać, pisać, przewijać i przełączać między układem mobilnym a desktopowym. Dodaje krótki opis narzędzia do każdej sesji. Zastosowane po ponownym uruchomieniu OpenCode.',
'settings.openchamber.tools.field.agentMemoryTool': 'Narzędzie pamięci agenta',
'settings.openchamber.tools.field.agentMemoryToolAria': 'Narzędzie pamięci agenta',
'settings.openchamber.tools.field.agentMemoryToolInfo': 'Pozwala agentom zachowywać to, czego się nauczyły, pomiędzy sesjami, w dwóch magazynach: co jest prawdą o Tobie i co jest prawdą o danym projekcie. Sesje otrzymują zapisane tytuły, aby agent mógł odczytać wpis, gdy jest istotny. Wyłączenie usuwa narzędzie, kartę Pamięć i indeks sesji. Działa po ponownym uruchomieniu OpenCode.',
'settings.openchamber.opencodeCli.tooltipPrefix': 'Opcjonalna ścieżka absolutna do',
'settings.openchamber.opencodeCli.tooltipSuffix': 'pliku binarnego.',
'settings.openchamber.passkeys.actions.add': 'Dodaj klucz dostępu (passkey)',
+59 -9
View File
@@ -1656,7 +1656,7 @@ export const dict: Record<I18nKey, string> = {
'contextRail.surface.browser.description': 'Wbudowana przeglądarka',
'contextRail.surface.preview.description': 'Podgląd serwera deweloperskiego',
'contextRail.surface.chat.description': 'Sesja otwarta obok',
'contextRail.surface.notes': 'Notatki projektu',
'contextRail.surface.notes': 'Wiedza o projekcie',
'contextRail.editorTree.toggle': 'Przełącz drzewo plików',
'contextPanel.browser.open': 'Otwórz panel przeglądarki',
'contextPanel.browser.addressAria': 'Adres przeglądarki',
@@ -2781,6 +2781,7 @@ export const dict: Record<I18nKey, string> = {
'planView.actions.sendToNewWorktreeSession': 'Wyślij do nowej sesji drzewa pracy',
'planView.error.previewUnavailable': 'Podgląd jest niedostępny',
'planView.error.saveFailed': 'Nie udało się zapisać',
'planView.error.loadFailed': 'Nie udało się wczytać tego planu',
'planView.error.switchToEditMode': 'Przełącz do trybu edycji, aby naprawić problem.',
'planView.error.writeFailed': 'Nie udało się zapisać',
'planView.error.writePlanFileFailed': 'Nie udało się zapisać pliku planu ({status})',
@@ -2834,15 +2835,11 @@ export const dict: Record<I18nKey, string> = {
'projectEditDialog.toast.iconUpdated': 'Zaktualizowano ikonę projektu',
'rightSidebar.contextNotesTodo.empty.selectProject': 'Wybierz projekt, aby dodać notatki i zadania.',
'rightSidebar.contextNotesTodo.notes.placeholder': 'Zapisz kontekst, przypomnienia lub linki',
'rightSidebar.contextNotesTodo.notes.title': 'Szybkie notatki — {project}',
'rightSidebar.contextNotesTodo.plan.defaultTitle': 'Plan',
'rightSidebar.contextNotesTodo.plans.deletePlan': 'Usuń plan',
'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': 'Usuń plan „{title}”',
'rightSidebar.contextNotesTodo.plans.empty': 'Brak zapisanych planów.',
'rightSidebar.contextNotesTodo.plans.filesPlural': '{count} plików',
'rightSidebar.contextNotesTodo.plans.filesSingle': '{count} plik',
'rightSidebar.contextNotesTodo.plans.importFromFile': 'Importuj plan z pliku',
'rightSidebar.contextNotesTodo.plans.title': 'Plany',
'rightSidebar.contextNotesTodo.sendDialog.actions.cancel': 'Anuluj',
'rightSidebar.contextNotesTodo.sendDialog.actions.send': 'Wyślij',
'rightSidebar.contextNotesTodo.sendDialog.actions.sending': 'Wysyłanie',
@@ -2850,6 +2847,7 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': 'Wyślij do nowego drzewa pracy',
'rightSidebar.contextNotesTodo.sendDialog.variant.default': 'Domyślny',
'rightSidebar.contextNotesTodo.toast.createSessionFailed': 'Nie udało się utworzyć sesji',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Nie udało się zaktualizować planu',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Nie udało się usunąć planu',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Nie udało się zaimportować planu',
'rightSidebar.contextNotesTodo.toast.loadNotesFailed': 'Nie udało się załadować notatek projektu',
@@ -2869,17 +2867,52 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.todo.actions.markComplete': 'Oznacz „{text}” jako ukończone',
'rightSidebar.contextNotesTodo.todo.actions.send': 'Wyślij „{text}”',
'rightSidebar.contextNotesTodo.todo.actions.reorder': 'Zmień kolejność "{text}"',
'rightSidebar.contextNotesTodo.todo.resizeAria': 'Zmień rozmiar listy zadań',
'rightSidebar.contextNotesTodo.todo.addAria': 'Dodaj zadanie',
'rightSidebar.contextNotesTodo.todo.clearCompleted': 'Wyczyść ukończone',
'rightSidebar.contextNotesTodo.todo.empty': 'Brak zadań. Dodaj krótką checklistę dla tego projektu.',
'rightSidebar.contextNotesTodo.todo.inputPlaceholder': 'Dodaj zadanie',
'rightSidebar.contextNotesTodo.todo.itemsPlural': '{count} elementów',
'rightSidebar.contextNotesTodo.todo.itemsSingle': '{count} element',
'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': 'Wyślij do bieżącej sesji',
'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': 'Wyślij do nowej sesji',
'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': 'Wyślij do nowej sesji drzewa pracy',
'rightSidebar.contextNotesTodo.todo.title': 'Zadania',
'rightSidebar.contextNotesTodo.notes.addAria': 'Dodaj notatkę',
'rightSidebar.contextNotesTodo.notes.empty': 'Brak notatek. Zapisz kontekst, przypomnienia lub linki.',
'rightSidebar.contextNotesTodo.notes.actions.expand': 'Rozwiń notatkę',
'rightSidebar.contextNotesTodo.notes.actions.collapse': 'Zwiń notatkę',
'rightSidebar.contextNotesTodo.notes.actions.delete': 'Usuń notatkę',
'rightSidebar.contextNotesTodo.notes.actions.pin': 'Przypnij do kontekstu agenta',
'rightSidebar.contextNotesTodo.notes.actions.unpin': 'Odepnij od kontekstu agenta',
'rightSidebar.contextNotesTodo.notes.source.selection': 'Z czatu',
'rightSidebar.contextNotesTodo.notes.source.agent': 'Od agenta',
'rightSidebar.contextNotesTodo.search.placeholder': 'Szukaj',
'rightSidebar.contextNotesTodo.search.clear': 'Wyczyść wyszukiwanie',
'rightSidebar.contextNotesTodo.search.noResults': 'Nic nie pasuje do "{query}".',
'rightSidebar.contextNotesTodo.toast.deleteNoteFailed': 'Nie udało się usunąć notatki',
'rightSidebar.contextNotesTodo.toast.createNoteFailed': 'Nie udało się utworzyć notatki',
'rightSidebar.contextNotesTodo.tabs.notes': 'Notatki',
'rightSidebar.contextNotesTodo.tabs.todos': 'Zadania',
'rightSidebar.contextNotesTodo.tabs.plans': 'Plany',
'rightSidebar.contextNotesTodo.plans.actions.back': 'Wróć do planów',
'rightSidebar.contextNotesTodo.tabs.memory': 'Pamięć',
'rightSidebar.contextNotesTodo.sections.label': 'Sekcje kontekstu projektu',
'rightSidebar.contextNotesTodo.sections.resize': 'Zmień szerokość paska sekcji',
'rightSidebar.contextNotesTodo.memory.scope.project': 'Projekt',
'rightSidebar.contextNotesTodo.memory.scope.label': 'Zakres pamięci',
'rightSidebar.contextNotesTodo.memory.scope.global': 'O Tobie',
'rightSidebar.contextNotesTodo.memory.type.fact': 'fakt',
'rightSidebar.contextNotesTodo.memory.badge.new': 'nowe',
'rightSidebar.contextNotesTodo.memory.flagged': 'Wstrzymane — czyta się jak instrukcja',
'rightSidebar.contextNotesTodo.memory.badge.changed': 'zmienione',
'rightSidebar.contextNotesTodo.memory.type.preference': 'preferencja',
'rightSidebar.contextNotesTodo.memory.type.reference': 'odnośnik',
'rightSidebar.contextNotesTodo.memory.actions.delete': 'Zapomnij ten wpis',
'rightSidebar.contextNotesTodo.memory.actions.editTitle': 'Tytuł wpisu',
'rightSidebar.contextNotesTodo.memory.actions.editBody': 'Treść wpisu',
'rightSidebar.contextNotesTodo.memory.toast.saveFailed': 'Nie udało się zapisać wpisu',
'rightSidebar.contextNotesTodo.memory.toast.deleteFailed': 'Nie udało się zapomnieć wpisu',
'rightSidebar.contextNotesTodo.memory.empty.nothing': 'Agent nic tu jeszcze nie zapisał.',
'rightSidebar.contextNotesTodo.memory.empty.noMatches': 'Żaden zapisany wpis nie pasuje do wyszukiwania.',
'rightSidebar.contextNotesTodo.memory.empty.noProject': 'Otwórz projekt, aby zobaczyć, co agent o nim pamięta.',
'rightSidebar.contextNotesTodo.memory.empty.unavailable': 'Nie udało się wczytać zapisanej pamięci. Nic nie przepadło — spróbuj ponownie.',
'saveProjectPlanDialog.actions.cancel': 'Anuluj',
'saveProjectPlanDialog.actions.save': 'Zapisz',
'saveProjectPlanDialog.actions.saving': 'Saving...',
@@ -3181,6 +3214,14 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.toast.folderNameRequired': 'Nazwa folderu jest wymagana',
'sidebarFilesTree.toast.nameRequired': 'Nazwa jest wymagana',
'sidebarFilesTree.toast.operationFailed': 'Operacja nie powiodła się',
'sidebarFilesTree.toast.uploaded': 'Pliki przesłano',
'sidebarFilesTree.toast.uploadedWithoutConflicts': 'Przesłano pliki bez konfliktów',
'sidebarFilesTree.toast.uploadFailed': 'Nie udało się przesłać niektórych plików',
'sidebarFilesTree.drop.target': 'Prześlij do {path}',
'sidebarFilesTree.drop.uploading': 'Przesyłanie plików do {path}',
'sidebarFilesTree.dialog.uploadConflicts.title': 'Zastąpić istniejące pliki?',
'sidebarFilesTree.dialog.uploadConflicts.description': 'Pliki o tych nazwach już istnieją w {path}. Zastąpienia nie można cofnąć.',
'sidebarFilesTree.dialog.uploadConflicts.replace': 'Zastąp',
'sidebarFilesTree.toast.pathCopied': 'Ścieżka skopiowana',
'sidebarFilesTree.toast.renameNotSupported': 'Zmiana nazwy nie jest obsługiwana',
'sidebarFilesTree.toast.renamedSuccessfully': 'Zmieniono nazwę pomyślnie',
@@ -3376,6 +3417,9 @@ export const dict: Record<I18nKey, string> = {
'quota.window.monthly': 'Monthly Limit',
'quota.window.credits': 'Credits',
'quota.window.creditsBalance': 'Credits Balance',
'quota.window.monthlyCredits': 'Kredyty miesięczne',
'quota.window.purchasedCredits': 'Kupione kredyty',
'quota.window.freeCredits': 'Darmowe kredyty',
'quota.window.billingCycle': 'Billing Cycle',
'quota.window.auto': 'Auto',
'quota.window.api': 'API',
@@ -3419,6 +3463,12 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.subagent.askedQuestion': 'zadał pytanie',
'chat.workStatus.section.contextBreakdown': 'Źródła kontekstu',
'chat.workStatus.breakdown.skills': 'Umiejętności',
'chat.workStatus.breakdown.pinnedNote': 'notatka',
'chat.workStatus.breakdown.unpin': 'Odepnij od kontekstu',
'chat.workStatus.breakdown.pinnedPlan': 'plan',
'chat.workStatus.breakdown.memory': 'Pamięć agenta',
'chat.workStatus.breakdown.pinnedKnowledgeSingle': '{count} przypięte',
'chat.workStatus.breakdown.pinnedKnowledgePlural': '{count} przypiętych',
'chat.workStatus.breakdown.mcp': 'Serwery MCP',
'chat.workStatus.action.openChanges': 'Otwórz zmiany',
'chat.workStatus.action.openGit': 'Otwórz panel Git',
@@ -990,6 +990,9 @@ export const settingsDict = {
"settings.openchamber.tools.field.agentWebTool": "Ferramenta OpenChamber Web",
"settings.openchamber.tools.field.agentWebToolAria": "Ativar a ferramenta OpenChamber Web",
"settings.openchamber.tools.field.agentWebToolInfo": "Permita que agentes vejam a página no painel de navegador do OpenChamber e interajam com ela: abrir uma URL, ler o conteúdo, clicar, digitar, rolar e alternar entre layout móvel e desktop. Adiciona uma pequena descrição de ferramenta a cada sessão. Aplicado após reiniciar o OpenCode.",
"settings.openchamber.tools.field.agentMemoryTool": "Ferramenta de memória do agente",
"settings.openchamber.tools.field.agentMemoryToolAria": "Ferramenta de memória do agente",
"settings.openchamber.tools.field.agentMemoryToolInfo": "Permite que os agentes guardem o que aprendem entre sessões, em dois armazenamentos: o que é verdade sobre você e o que é verdade sobre cada projeto. As sessões recebem os títulos armazenados para que o agente possa ler uma entrada quando for relevante. Desativar remove a ferramenta, a aba Memória e o índice da sessão. Vale após reiniciar o OpenCode.",
"settings.openchamber.opencodeCli.tooltipPrefix": "Caminho absoluto opcional para o",
"settings.openchamber.opencodeCli.tooltipSuffix": "executável.",
"settings.openchamber.opencodeCli.field.binaryPath": "Caminho do executável do OpenCode",
+59 -9
View File
@@ -1303,7 +1303,7 @@ export const dict: Record<I18nKey, string> = {
"contextRail.surface.browser.description": "Navegador web integrado",
"contextRail.surface.preview.description": "Pré-visualização do servidor de desenvolvimento",
"contextRail.surface.chat.description": "Sessão aberta lado a lado",
"contextRail.surface.notes": "Notas do projeto",
"contextRail.surface.notes": "Conhecimento do projeto",
"contextRail.editorTree.toggle": "Alternar árvore de arquivos",
"contextPanel.browser.open": "Abrir painel do navegador",
"contextPanel.browser.addressAria": "Endereço do navegador",
@@ -1397,6 +1397,14 @@ export const dict: Record<I18nKey, string> = {
"sidebarFilesTree.toast.writeNotSupported": "A escrita não é compatível",
"sidebarFilesTree.toast.fileCreated": "Arquivo criado",
"sidebarFilesTree.toast.operationFailed": "Não foi possível completar a operación",
"sidebarFilesTree.toast.uploaded": "Arquivos enviados",
"sidebarFilesTree.toast.uploadedWithoutConflicts": "Os arquivos sem conflitos foram enviados",
"sidebarFilesTree.toast.uploadFailed": "Não foi possível enviar alguns arquivos",
"sidebarFilesTree.drop.target": "Enviar para {path}",
"sidebarFilesTree.drop.uploading": "Enviando arquivos para {path}",
"sidebarFilesTree.dialog.uploadConflicts.title": "Substituir arquivos existentes?",
"sidebarFilesTree.dialog.uploadConflicts.description": "Já existem arquivos com esses nomes em {path}. A substituição não pode ser desfeita.",
"sidebarFilesTree.dialog.uploadConflicts.replace": "Substituir",
"sidebarFilesTree.toast.folderNameRequired": "O nome de pasta é obrigatório",
"sidebarFilesTree.toast.folderCreated": "Pasta criada",
"sidebarFilesTree.toast.nameRequired": "O nome é obrigatório",
@@ -1624,6 +1632,7 @@ export const dict: Record<I18nKey, string> = {
"planView.file.defaultName": "plano",
"planView.title.default": "Plano",
"planView.error.saveFailed": "Não foi possível salvar",
"planView.error.loadFailed": "Não foi possível carregar este plano",
"planView.error.previewUnavailable": "Pré-visualização indisponível",
"planView.error.switchToEditMode": "Alterne para o modo de edição para resolver o problema.",
"planView.error.writeFailed": "Não foi possível gravar",
@@ -1718,11 +1727,46 @@ export const dict: Record<I18nKey, string> = {
"diffView.hunk.unsupported": "A preparação de trechos individuais não é suportada neste ambiente.",
"rightSidebar.contextNotesTodo.plan.defaultTitle": "Plano",
"rightSidebar.contextNotesTodo.empty.selectProject": "Selecione um projeto para adicionar notas e tarefas pendentes.",
"rightSidebar.contextNotesTodo.notes.title": "Notas rápidas — {project}",
"rightSidebar.contextNotesTodo.notes.placeholder": "Capture contexto, lembretes ou links",
"rightSidebar.contextNotesTodo.todo.title": "Tarefas pendentes",
"rightSidebar.contextNotesTodo.todo.itemsSingle": "{count} elemento",
"rightSidebar.contextNotesTodo.todo.itemsPlural": "{count} elementos",
"rightSidebar.contextNotesTodo.notes.addAria": "Adicionar nota",
"rightSidebar.contextNotesTodo.notes.empty": "Ainda não há notas. Registre contexto, lembretes ou links.",
"rightSidebar.contextNotesTodo.notes.actions.expand": "Expandir nota",
"rightSidebar.contextNotesTodo.notes.actions.collapse": "Recolher nota",
"rightSidebar.contextNotesTodo.notes.actions.delete": "Excluir nota",
"rightSidebar.contextNotesTodo.notes.actions.pin": "Fixar no contexto do agente",
"rightSidebar.contextNotesTodo.notes.actions.unpin": "Desafixar do contexto do agente",
"rightSidebar.contextNotesTodo.notes.source.selection": "Do chat",
"rightSidebar.contextNotesTodo.notes.source.agent": "Do agente",
"rightSidebar.contextNotesTodo.search.placeholder": "Buscar",
"rightSidebar.contextNotesTodo.search.clear": "Limpar busca",
"rightSidebar.contextNotesTodo.search.noResults": "Nada corresponde a \"{query}\".",
"rightSidebar.contextNotesTodo.toast.deleteNoteFailed": "Falha ao excluir a nota",
"rightSidebar.contextNotesTodo.toast.createNoteFailed": "Falha ao criar a nota",
"rightSidebar.contextNotesTodo.tabs.notes": "Notas",
"rightSidebar.contextNotesTodo.tabs.todos": "Tarefas",
"rightSidebar.contextNotesTodo.tabs.plans": "Planos",
"rightSidebar.contextNotesTodo.plans.actions.back": "Voltar aos planos",
"rightSidebar.contextNotesTodo.tabs.memory": "Memória",
"rightSidebar.contextNotesTodo.sections.label": "Seções do contexto do projeto",
"rightSidebar.contextNotesTodo.sections.resize": "Redimensionar a barra de seções",
"rightSidebar.contextNotesTodo.memory.scope.project": "Projeto",
"rightSidebar.contextNotesTodo.memory.scope.label": "Escopo da memória",
"rightSidebar.contextNotesTodo.memory.scope.global": "Sobre você",
"rightSidebar.contextNotesTodo.memory.type.fact": "fato",
"rightSidebar.contextNotesTodo.memory.badge.new": "novo",
"rightSidebar.contextNotesTodo.memory.flagged": "Retido do agente — parece uma instrução",
"rightSidebar.contextNotesTodo.memory.badge.changed": "alterado",
"rightSidebar.contextNotesTodo.memory.type.preference": "preferência",
"rightSidebar.contextNotesTodo.memory.type.reference": "referência",
"rightSidebar.contextNotesTodo.memory.actions.delete": "Esquecer esta memória",
"rightSidebar.contextNotesTodo.memory.actions.editTitle": "Título da memória",
"rightSidebar.contextNotesTodo.memory.actions.editBody": "Texto da memória",
"rightSidebar.contextNotesTodo.memory.toast.saveFailed": "Não foi possível salvar a memória",
"rightSidebar.contextNotesTodo.memory.toast.deleteFailed": "Não foi possível esquecer a memória",
"rightSidebar.contextNotesTodo.memory.empty.nothing": "O agente ainda não guardou nada aqui.",
"rightSidebar.contextNotesTodo.memory.empty.noMatches": "Nenhuma memória guardada corresponde à sua busca.",
"rightSidebar.contextNotesTodo.memory.empty.noProject": "Abra um projeto para ver o que o agente lembra sobre ele.",
"rightSidebar.contextNotesTodo.memory.empty.unavailable": "Não foi possível carregar a memória guardada. Nada foi perdido — tente novamente.",
"rightSidebar.contextNotesTodo.todo.clearCompleted": "Limpar completadas",
"rightSidebar.contextNotesTodo.todo.inputPlaceholder": "Adicione uma tarefa pendente",
"rightSidebar.contextNotesTodo.todo.addAria": "Adicionar tarefa pendente",
@@ -1733,13 +1777,9 @@ export const dict: Record<I18nKey, string> = {
"rightSidebar.contextNotesTodo.todo.actions.delete": "Excluir \"{text}\"",
"rightSidebar.contextNotesTodo.todo.actions.send": "Enviar \"{text}\"",
"rightSidebar.contextNotesTodo.todo.actions.reorder": "Reordenar \"{text}\"",
"rightSidebar.contextNotesTodo.todo.resizeAria": "Redimensionar lista de tarefas",
"rightSidebar.contextNotesTodo.todo.sendMenu.currentSession": "Enviar à sessão atual",
"rightSidebar.contextNotesTodo.todo.sendMenu.newSession": "Enviar a uma nova sessão",
"rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession": "Enviar a uma nova sessão de worktree",
"rightSidebar.contextNotesTodo.plans.title": "Planos",
"rightSidebar.contextNotesTodo.plans.filesSingle": "{count} arquivo",
"rightSidebar.contextNotesTodo.plans.filesPlural": "{count} arquivos",
"rightSidebar.contextNotesTodo.plans.importFromFile": "Importar plano de arquivo",
"rightSidebar.contextNotesTodo.plans.empty": "Ainda não há planos salvos.",
"rightSidebar.contextNotesTodo.plans.deletePlan": "Excluir plano",
@@ -1759,6 +1799,7 @@ export const dict: Record<I18nKey, string> = {
"rightSidebar.contextNotesTodo.toast.sentToNewSession": "Tarefa enviada para uma nova sessão",
"rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession": "Tarefa enviada para uma nova sessão de worktree",
"rightSidebar.contextNotesTodo.toast.sendTodoFailed": "Não foi possível enviar a tarefa",
"rightSidebar.contextNotesTodo.toast.updatePlanFailed": "Falha ao atualizar o plano",
"rightSidebar.contextNotesTodo.toast.deletePlanFailed": "Não foi possível excluir o plano",
"rightSidebar.contextNotesTodo.toast.planFileEmpty": "O arquivo do plano está vazio",
"rightSidebar.contextNotesTodo.toast.importPlanFailed": "Não foi possível importar o plano",
@@ -3360,6 +3401,9 @@ export const dict: Record<I18nKey, string> = {
"quota.window.monthly": "Monthly Limit",
"quota.window.credits": "Credits",
"quota.window.creditsBalance": "Credits Balance",
"quota.window.monthlyCredits": "Créditos mensais",
"quota.window.purchasedCredits": "Créditos comprados",
"quota.window.freeCredits": "Créditos gratuitos",
"quota.window.billingCycle": "Billing Cycle",
"quota.window.auto": "Auto",
"quota.window.api": "API",
@@ -3403,6 +3447,12 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.subagent.askedQuestion': 'fez uma pergunta',
'chat.workStatus.section.contextBreakdown': 'Fontes de contexto',
'chat.workStatus.breakdown.skills': 'Habilidades',
'chat.workStatus.breakdown.pinnedNote': 'nota',
'chat.workStatus.breakdown.unpin': 'Desafixar do contexto',
'chat.workStatus.breakdown.pinnedPlan': 'plano',
'chat.workStatus.breakdown.memory': 'Memória do agente',
'chat.workStatus.breakdown.pinnedKnowledgeSingle': '{count} fixado',
'chat.workStatus.breakdown.pinnedKnowledgePlural': '{count} fixados',
'chat.workStatus.breakdown.mcp': 'Servidores MCP',
'chat.workStatus.action.openChanges': 'Abrir alterações',
'chat.workStatus.action.openGit': 'Abrir painel do Git',
@@ -990,6 +990,9 @@ export const settingsDict = {
"settings.openchamber.tools.field.agentWebTool": "Інструмент OpenChamber Web",
"settings.openchamber.tools.field.agentWebToolAria": "Увімкнути інструмент OpenChamber Web",
"settings.openchamber.tools.field.agentWebToolInfo": "Дозвольте агентам переглядати сторінку в панелі браузера OpenChamber і взаємодіяти з нею: відкривати URL, читати вміст, клікати, вводити текст, гортати та перемикатися між мобільним і десктопним виглядом. Додає невеликий опис інструмента до кожної сесії. Застосовується після перезапуску OpenCode.",
"settings.openchamber.tools.field.agentMemoryTool": "Інструмент памʼяті агента",
"settings.openchamber.tools.field.agentMemoryToolAria": "Інструмент памʼяті агента",
"settings.openchamber.tools.field.agentMemoryToolInfo": "Дозволяє агентам зберігати вивчене між сесіями у двох сховищах: що правдиве про вас і що правдиве про кожен проєкт. Сесії отримують перелік заголовків, щоб агент міг прочитати потрібний запис. Вимкнення прибирає інструмент, вкладку «Памʼять» і індекс у сесії. Діє після перезапуску OpenCode.",
"settings.openchamber.opencodeCli.tooltipPrefix": "Додатковий абсолютний шлях до",
"settings.openchamber.opencodeCli.tooltipSuffix": "бінарного файлу.",
"settings.openchamber.opencodeCli.field.binaryPath": "Шлях до бінарного файлу OpenCode",
+59 -9
View File
@@ -1303,7 +1303,7 @@ export const dict: Record<I18nKey, string> = {
"contextRail.surface.browser.description": "Вбудований браузер",
"contextRail.surface.preview.description": "Перегляд дев-сервера",
"contextRail.surface.chat.description": "Сесія, відкрита поруч",
"contextRail.surface.notes": "Нотатки проєкту",
"contextRail.surface.notes": "Знання проєкту",
"contextRail.editorTree.toggle": "Перемкнути дерево файлів",
"contextPanel.browser.open": "Відкрити панель браузера",
"contextPanel.browser.addressAria": "Адреса браузера",
@@ -1397,6 +1397,14 @@ export const dict: Record<I18nKey, string> = {
"sidebarFilesTree.toast.writeNotSupported": "Запис не підтримується",
"sidebarFilesTree.toast.fileCreated": "Файл створено",
"sidebarFilesTree.toast.operationFailed": "Операція не вдалася",
"sidebarFilesTree.toast.uploaded": "Файли завантажено",
"sidebarFilesTree.toast.uploadedWithoutConflicts": "Файли без конфліктів завантажено",
"sidebarFilesTree.toast.uploadFailed": "Деякі файли не вдалося завантажити",
"sidebarFilesTree.drop.target": "Завантажити в {path}",
"sidebarFilesTree.drop.uploading": "Завантаження файлів у {path}",
"sidebarFilesTree.dialog.uploadConflicts.title": "Замінити наявні файли?",
"sidebarFilesTree.dialog.uploadConflicts.description": "Файли з такими назвами вже існують у {path}. Заміну неможливо скасувати.",
"sidebarFilesTree.dialog.uploadConflicts.replace": "Замінити",
"sidebarFilesTree.toast.folderNameRequired": "Потрібно вказати назву папки",
"sidebarFilesTree.toast.folderCreated": "Папку створено",
"sidebarFilesTree.toast.nameRequired": "Потрібно вказати назву",
@@ -1624,6 +1632,7 @@ export const dict: Record<I18nKey, string> = {
"planView.file.defaultName": "план",
"planView.title.default": "План",
"planView.error.saveFailed": "Не вдалося зберегти",
"planView.error.loadFailed": "Не вдалося завантажити цей план",
"planView.error.previewUnavailable": "Попередній перегляд недоступний",
"planView.error.switchToEditMode": "Перейдіть у режим редагування, щоб усунути проблему.",
"planView.error.writeFailed": "Помилка запису",
@@ -1718,11 +1727,46 @@ export const dict: Record<I18nKey, string> = {
"diffView.hunk.unsupported": "Додавання окремих шматків до індексу не підтримується в цьому середовищі.",
"rightSidebar.contextNotesTodo.plan.defaultTitle": "План",
"rightSidebar.contextNotesTodo.empty.selectProject": "Виберіть проєкт, щоб додати нотатки та завдання.",
"rightSidebar.contextNotesTodo.notes.title": "Швидкі нотатки - {project}",
"rightSidebar.contextNotesTodo.notes.placeholder": "Зберігайте контекст, нагадування або посилання",
"rightSidebar.contextNotesTodo.todo.title": "Todo",
"rightSidebar.contextNotesTodo.todo.itemsSingle": "{count} пункт",
"rightSidebar.contextNotesTodo.todo.itemsPlural": "пунктів: {count}",
"rightSidebar.contextNotesTodo.notes.addAria": "Додати нотатку",
"rightSidebar.contextNotesTodo.notes.empty": "Нотаток ще немає. Занотуйте контекст, нагадування або посилання.",
"rightSidebar.contextNotesTodo.notes.actions.expand": "Розгорнути нотатку",
"rightSidebar.contextNotesTodo.notes.actions.collapse": "Згорнути нотатку",
"rightSidebar.contextNotesTodo.notes.actions.delete": "Видалити нотатку",
"rightSidebar.contextNotesTodo.notes.actions.pin": "Закріпити в контексті агента",
"rightSidebar.contextNotesTodo.notes.actions.unpin": "Відкріпити з контексту агента",
"rightSidebar.contextNotesTodo.notes.source.selection": "З чату",
"rightSidebar.contextNotesTodo.notes.source.agent": "Від агента",
"rightSidebar.contextNotesTodo.search.placeholder": "Пошук",
"rightSidebar.contextNotesTodo.search.clear": "Очистити пошук",
"rightSidebar.contextNotesTodo.search.noResults": "Нічого не знайдено за запитом «{query}».",
"rightSidebar.contextNotesTodo.toast.deleteNoteFailed": "Не вдалося видалити нотатку",
"rightSidebar.contextNotesTodo.toast.createNoteFailed": "Не вдалося створити нотатку",
"rightSidebar.contextNotesTodo.tabs.notes": "Нотатки",
"rightSidebar.contextNotesTodo.tabs.todos": "Todo",
"rightSidebar.contextNotesTodo.tabs.plans": "Плани",
"rightSidebar.contextNotesTodo.plans.actions.back": "Назад до планів",
"rightSidebar.contextNotesTodo.tabs.memory": "Памʼять",
"rightSidebar.contextNotesTodo.sections.label": "Розділи контексту проєкту",
"rightSidebar.contextNotesTodo.sections.resize": "Змінити ширину бічної панелі розділів",
"rightSidebar.contextNotesTodo.memory.scope.project": "Проєкт",
"rightSidebar.contextNotesTodo.memory.scope.label": "Область памʼяті",
"rightSidebar.contextNotesTodo.memory.scope.global": "Про вас",
"rightSidebar.contextNotesTodo.memory.type.fact": "факт",
"rightSidebar.contextNotesTodo.memory.badge.new": "нове",
"rightSidebar.contextNotesTodo.memory.flagged": "Не надсилається агенту — виглядає як інструкція",
"rightSidebar.contextNotesTodo.memory.badge.changed": "змінено",
"rightSidebar.contextNotesTodo.memory.type.preference": "вподобання",
"rightSidebar.contextNotesTodo.memory.type.reference": "посилання",
"rightSidebar.contextNotesTodo.memory.actions.delete": "Забути цей запис",
"rightSidebar.contextNotesTodo.memory.actions.editTitle": "Заголовок запису",
"rightSidebar.contextNotesTodo.memory.actions.editBody": "Текст запису",
"rightSidebar.contextNotesTodo.memory.toast.saveFailed": "Не вдалося зберегти запис",
"rightSidebar.contextNotesTodo.memory.toast.deleteFailed": "Не вдалося забути запис",
"rightSidebar.contextNotesTodo.memory.empty.nothing": "Агент ще нічого сюди не записав.",
"rightSidebar.contextNotesTodo.memory.empty.noMatches": "Жоден збережений запис не відповідає пошуку.",
"rightSidebar.contextNotesTodo.memory.empty.noProject": "Відкрийте проєкт, щоб побачити, що агент про нього памʼятає.",
"rightSidebar.contextNotesTodo.memory.empty.unavailable": "Не вдалося завантажити памʼять. Нічого не втрачено — спробуйте ще раз.",
"rightSidebar.contextNotesTodo.todo.clearCompleted": "Очистити завершені",
"rightSidebar.contextNotesTodo.todo.inputPlaceholder": "Додати завдання",
"rightSidebar.contextNotesTodo.todo.addAria": "Додати завдання",
@@ -1733,13 +1777,9 @@ export const dict: Record<I18nKey, string> = {
"rightSidebar.contextNotesTodo.todo.actions.delete": "Видалити \"{text}\"",
"rightSidebar.contextNotesTodo.todo.actions.send": "Надіслати \"{text}\"",
"rightSidebar.contextNotesTodo.todo.actions.reorder": "Змінити порядок \"{text}\"",
"rightSidebar.contextNotesTodo.todo.resizeAria": "Змінити розмір списку завдань",
"rightSidebar.contextNotesTodo.todo.sendMenu.currentSession": "Надіслати до поточної сесії",
"rightSidebar.contextNotesTodo.todo.sendMenu.newSession": "Надіслати до нової сесії",
"rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession": "Надіслати до нової сесії в worktree",
"rightSidebar.contextNotesTodo.plans.title": "Плани",
"rightSidebar.contextNotesTodo.plans.filesSingle": "Файл: {count}",
"rightSidebar.contextNotesTodo.plans.filesPlural": "Файлів: {count}",
"rightSidebar.contextNotesTodo.plans.importFromFile": "Імпортувати план із файлу",
"rightSidebar.contextNotesTodo.plans.empty": "Ще немає збережених планів.",
"rightSidebar.contextNotesTodo.plans.deletePlan": "Видалити план",
@@ -1759,6 +1799,7 @@ export const dict: Record<I18nKey, string> = {
"rightSidebar.contextNotesTodo.toast.sentToNewSession": "Завдання надіслано до нової сесії",
"rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession": "Завдання надіслано до нової сесії в worktree",
"rightSidebar.contextNotesTodo.toast.sendTodoFailed": "Не вдалося надіслати завдання",
"rightSidebar.contextNotesTodo.toast.updatePlanFailed": "Не вдалося оновити план",
"rightSidebar.contextNotesTodo.toast.deletePlanFailed": "Не вдалося видалити план",
"rightSidebar.contextNotesTodo.toast.planFileEmpty": "Файл плану порожній",
"rightSidebar.contextNotesTodo.toast.importPlanFailed": "Не вдалося імпортувати план",
@@ -3360,6 +3401,9 @@ export const dict: Record<I18nKey, string> = {
"quota.window.monthly": "Monthly Limit",
"quota.window.credits": "Credits",
"quota.window.creditsBalance": "Credits Balance",
"quota.window.monthlyCredits": "Місячні кредити",
"quota.window.purchasedCredits": "Придбані кредити",
"quota.window.freeCredits": "Безкоштовні кредити",
"quota.window.billingCycle": "Billing Cycle",
"quota.window.auto": "Auto",
"quota.window.api": "API",
@@ -3403,6 +3447,12 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.subagent.askedQuestion': 'поставив питання',
'chat.workStatus.section.contextBreakdown': 'Джерела контексту',
'chat.workStatus.breakdown.skills': 'Скіли',
'chat.workStatus.breakdown.pinnedNote': 'нотатка',
'chat.workStatus.breakdown.unpin': 'Відкріпити від контексту',
'chat.workStatus.breakdown.pinnedPlan': 'план',
'chat.workStatus.breakdown.memory': 'Памʼять агента',
'chat.workStatus.breakdown.pinnedKnowledgeSingle': '{count} закріплено',
'chat.workStatus.breakdown.pinnedKnowledgePlural': '{count} закріплено',
'chat.workStatus.breakdown.mcp': 'Сервери MCP',
'chat.workStatus.action.openChanges': 'Відкрити зміни',
'chat.workStatus.action.openGit': 'Відкрити панель Git',
@@ -990,6 +990,9 @@ export const settingsDict = {
'settings.openchamber.tools.field.agentWebTool': 'OpenChamber Web 工具',
'settings.openchamber.tools.field.agentWebToolAria': '启用 OpenChamber Web 工具',
'settings.openchamber.tools.field.agentWebToolInfo': '让智能体在 OpenChamber 浏览器面板中查看并操作页面:打开网址、读取内容、点击、输入、滚动,以及在移动端与桌面端布局之间切换。会为每个会话添加少量工具说明。在 OpenCode 重启后生效。',
'settings.openchamber.tools.field.agentMemoryTool': '智能体记忆工具',
'settings.openchamber.tools.field.agentMemoryToolAria': '智能体记忆工具',
'settings.openchamber.tools.field.agentMemoryToolInfo': '让智能体把学到的内容跨会话保留下来,分为两个存储:关于你的事实,以及关于每个项目的事实。会话会收到已存条目的标题,智能体可在相关时读取具体内容。关闭后将同时移除该工具、记忆标签页和会话索引。重启 OpenCode 后生效。',
'settings.openchamber.opencodeCli.tooltipPrefix': '可选的',
'settings.openchamber.opencodeCli.tooltipSuffix': '二进制绝对路径。',
'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode 可执行文件路径',
+59 -9
View File
@@ -1303,7 +1303,7 @@ export const dict: Record<I18nKey, string> = {
'contextRail.surface.browser.description': '内置网页浏览器',
'contextRail.surface.preview.description': '开发服务器预览',
'contextRail.surface.chat.description': '并排打开的会话',
'contextRail.surface.notes': '项目笔记',
'contextRail.surface.notes': '项目知识',
'contextRail.editorTree.toggle': '切换文件树',
'contextPanel.browser.open': '打开浏览器面板',
'contextPanel.browser.addressAria': '浏览器地址',
@@ -1397,6 +1397,14 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.toast.writeNotSupported': '不支持写入',
'sidebarFilesTree.toast.fileCreated': '文件已创建',
'sidebarFilesTree.toast.operationFailed': '操作失败',
'sidebarFilesTree.toast.uploaded': '文件已上传',
'sidebarFilesTree.toast.uploadedWithoutConflicts': '无冲突的文件已上传',
'sidebarFilesTree.toast.uploadFailed': '部分文件无法上传',
'sidebarFilesTree.drop.target': '上传到 {path}',
'sidebarFilesTree.drop.uploading': '正在将文件上传到 {path}',
'sidebarFilesTree.dialog.uploadConflicts.title': '替换现有文件?',
'sidebarFilesTree.dialog.uploadConflicts.description': '{path} 中已存在同名文件。替换后无法撤销。',
'sidebarFilesTree.dialog.uploadConflicts.replace': '替换',
'sidebarFilesTree.toast.folderNameRequired': '文件夹名不能为空',
'sidebarFilesTree.toast.folderCreated': '文件夹已创建',
'sidebarFilesTree.toast.nameRequired': '名称不能为空',
@@ -1624,6 +1632,7 @@ export const dict: Record<I18nKey, string> = {
'planView.file.defaultName': 'plan',
'planView.title.default': '计划',
'planView.error.saveFailed': '保存失败',
'planView.error.loadFailed': '无法加载此计划',
'planView.error.previewUnavailable': '预览不可用',
'planView.error.switchToEditMode': '请切换到编辑模式修复问题。',
'planView.error.writeFailed': '写入失败',
@@ -1706,11 +1715,46 @@ export const dict: Record<I18nKey, string> = {
'diffView.hunk.unsupported': '此运行环境不支持暂存单个代码块。',
'rightSidebar.contextNotesTodo.plan.defaultTitle': '计划',
'rightSidebar.contextNotesTodo.empty.selectProject': '请选择一个项目以添加笔记和待办事项。',
'rightSidebar.contextNotesTodo.notes.title': '快速笔记 - {project}',
'rightSidebar.contextNotesTodo.notes.placeholder': '记录上下文、提醒或链接',
'rightSidebar.contextNotesTodo.todo.title': '待办',
'rightSidebar.contextNotesTodo.todo.itemsSingle': '{count} 项',
'rightSidebar.contextNotesTodo.todo.itemsPlural': '{count} 项',
'rightSidebar.contextNotesTodo.notes.addAria': '添加笔记',
'rightSidebar.contextNotesTodo.notes.empty': '还没有笔记。可以记录上下文、提醒或链接。',
'rightSidebar.contextNotesTodo.notes.actions.expand': '展开笔记',
'rightSidebar.contextNotesTodo.notes.actions.collapse': '折叠笔记',
'rightSidebar.contextNotesTodo.notes.actions.delete': '删除笔记',
'rightSidebar.contextNotesTodo.notes.actions.pin': '固定到智能体上下文',
'rightSidebar.contextNotesTodo.notes.actions.unpin': '从智能体上下文取消固定',
'rightSidebar.contextNotesTodo.notes.source.selection': '来自对话',
'rightSidebar.contextNotesTodo.notes.source.agent': '来自智能体',
'rightSidebar.contextNotesTodo.search.placeholder': '搜索',
'rightSidebar.contextNotesTodo.search.clear': '清除搜索',
'rightSidebar.contextNotesTodo.search.noResults': '没有匹配 "{query}" 的内容。',
'rightSidebar.contextNotesTodo.toast.deleteNoteFailed': '删除笔记失败',
'rightSidebar.contextNotesTodo.toast.createNoteFailed': '创建笔记失败',
'rightSidebar.contextNotesTodo.tabs.notes': '笔记',
'rightSidebar.contextNotesTodo.tabs.todos': '待办',
'rightSidebar.contextNotesTodo.tabs.plans': '计划',
'rightSidebar.contextNotesTodo.plans.actions.back': '返回计划列表',
'rightSidebar.contextNotesTodo.tabs.memory': '记忆',
'rightSidebar.contextNotesTodo.sections.label': '项目上下文分区',
'rightSidebar.contextNotesTodo.sections.resize': '调整分区侧栏宽度',
'rightSidebar.contextNotesTodo.memory.scope.project': '项目',
'rightSidebar.contextNotesTodo.memory.scope.label': '记忆范围',
'rightSidebar.contextNotesTodo.memory.scope.global': '关于你',
'rightSidebar.contextNotesTodo.memory.type.fact': '事实',
'rightSidebar.contextNotesTodo.memory.badge.new': '新增',
'rightSidebar.contextNotesTodo.memory.flagged': '不会发送给智能体 — 读起来像指令',
'rightSidebar.contextNotesTodo.memory.badge.changed': '已更改',
'rightSidebar.contextNotesTodo.memory.type.preference': '偏好',
'rightSidebar.contextNotesTodo.memory.type.reference': '参考',
'rightSidebar.contextNotesTodo.memory.actions.delete': '删除这条记忆',
'rightSidebar.contextNotesTodo.memory.actions.editTitle': '记忆标题',
'rightSidebar.contextNotesTodo.memory.actions.editBody': '记忆内容',
'rightSidebar.contextNotesTodo.memory.toast.saveFailed': '保存记忆失败',
'rightSidebar.contextNotesTodo.memory.toast.deleteFailed': '删除记忆失败',
'rightSidebar.contextNotesTodo.memory.empty.nothing': '智能体还没有在这里存过内容。',
'rightSidebar.contextNotesTodo.memory.empty.noMatches': '没有匹配搜索的已存记忆。',
'rightSidebar.contextNotesTodo.memory.empty.noProject': '打开一个项目,查看智能体记住了什么。',
'rightSidebar.contextNotesTodo.memory.empty.unavailable': '无法加载已存记忆。内容并未丢失,请重试。',
'rightSidebar.contextNotesTodo.todo.clearCompleted': '清除已完成',
'rightSidebar.contextNotesTodo.todo.inputPlaceholder': '添加待办',
'rightSidebar.contextNotesTodo.todo.addAria': '添加待办',
@@ -1721,13 +1765,9 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.todo.actions.delete': '删除“{text}”',
'rightSidebar.contextNotesTodo.todo.actions.send': '发送“{text}”',
'rightSidebar.contextNotesTodo.todo.actions.reorder': '重新排序"{text}"',
'rightSidebar.contextNotesTodo.todo.resizeAria': '调整待办列表大小',
'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': '发送到当前会话',
'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': '发送到新会话',
'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': '发送到新工作树会话',
'rightSidebar.contextNotesTodo.plans.title': '计划',
'rightSidebar.contextNotesTodo.plans.filesSingle': '{count} 个文件',
'rightSidebar.contextNotesTodo.plans.filesPlural': '{count} 个文件',
'rightSidebar.contextNotesTodo.plans.importFromFile': '从文件导入计划',
'rightSidebar.contextNotesTodo.plans.empty': '还没有已保存的计划。',
'rightSidebar.contextNotesTodo.plans.deletePlan': '删除计划',
@@ -1747,6 +1787,7 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.toast.sentToNewSession': '待办已发送到新会话',
'rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession': '待办已发送到新的工作树会话',
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': '发送待办失败',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': '更新计划失败',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '删除计划失败',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': '计划文件为空',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': '导入计划失败',
@@ -3360,6 +3401,9 @@ export const dict: Record<I18nKey, string> = {
'quota.window.monthly': 'Monthly Limit',
'quota.window.credits': 'Credits',
'quota.window.creditsBalance': 'Credits Balance',
'quota.window.monthlyCredits': '每月积分',
'quota.window.purchasedCredits': '已购买积分',
'quota.window.freeCredits': '免费积分',
'quota.window.billingCycle': 'Billing Cycle',
'quota.window.auto': 'Auto',
'quota.window.api': 'API',
@@ -3403,6 +3447,12 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.subagent.askedQuestion': '提出了问题',
'chat.workStatus.section.contextBreakdown': '上下文来源',
'chat.workStatus.breakdown.skills': '技能',
'chat.workStatus.breakdown.pinnedNote': '笔记',
'chat.workStatus.breakdown.unpin': '从上下文取消固定',
'chat.workStatus.breakdown.pinnedPlan': '计划',
'chat.workStatus.breakdown.memory': '智能体记忆',
'chat.workStatus.breakdown.pinnedKnowledgeSingle': '已固定 {count}',
'chat.workStatus.breakdown.pinnedKnowledgePlural': '已固定 {count}',
'chat.workStatus.breakdown.mcp': 'MCP 服务器',
'chat.workStatus.action.openChanges': '打开更改',
'chat.workStatus.action.openGit': '打开 Git 面板',
@@ -964,6 +964,9 @@ export const settingsDict = {
'settings.openchamber.tools.field.agentWebTool': 'OpenChamber Web 工具',
'settings.openchamber.tools.field.agentWebToolAria': '啟用 OpenChamber Web 工具',
'settings.openchamber.tools.field.agentWebToolInfo': '讓代理在 OpenChamber 瀏覽器面板中檢視並操作頁面:開啟網址、讀取內容、點擊、輸入、捲動,以及在行動版與桌面版版面之間切換。會為每個工作階段加入少量工具說明。在 OpenCode 重新啟動後生效。',
'settings.openchamber.tools.field.agentMemoryTool': '智慧代理記憶工具',
'settings.openchamber.tools.field.agentMemoryToolAria': '智慧代理記憶工具',
'settings.openchamber.tools.field.agentMemoryToolInfo': '讓代理把學到的內容跨工作階段保留下來,分為兩個儲存區:關於你的事實,以及關於每個專案的事實。工作階段會收到已儲存項目的標題,代理可在相關時讀取內容。關閉後會一併移除該工具、記憶分頁與工作階段索引。重新啟動 OpenCode 後生效。',
'settings.openchamber.opencodeCli.tooltipPrefix': '可選的',
'settings.openchamber.opencodeCli.tooltipSuffix': '二進位檔絕對路徑。',
'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode 可執行檔路徑',
+59 -9
View File
@@ -1315,7 +1315,7 @@ export const dict: Record<I18nKey, string> = {
'contextRail.surface.browser.description': '內建網頁瀏覽器',
'contextRail.surface.preview.description': '開發伺服器預覽',
'contextRail.surface.chat.description': '並排開啟的工作階段',
'contextRail.surface.notes': '專案筆記',
'contextRail.surface.notes': '專案知識',
'contextRail.editorTree.toggle': '切換檔案樹',
'contextPanel.browser.open': '開啟瀏覽器面板',
'contextPanel.browser.addressAria': '瀏覽器網址',
@@ -1409,6 +1409,14 @@ export const dict: Record<I18nKey, string> = {
'sidebarFilesTree.toast.writeNotSupported': '不支援寫入',
'sidebarFilesTree.toast.fileCreated': '檔案已建立',
'sidebarFilesTree.toast.operationFailed': '操作失敗',
'sidebarFilesTree.toast.uploaded': '檔案已上傳',
'sidebarFilesTree.toast.uploadedWithoutConflicts': '無衝突的檔案已上傳',
'sidebarFilesTree.toast.uploadFailed': '部分檔案無法上傳',
'sidebarFilesTree.drop.target': '上傳至 {path}',
'sidebarFilesTree.drop.uploading': '正在將檔案上傳至 {path}',
'sidebarFilesTree.dialog.uploadConflicts.title': '取代現有檔案?',
'sidebarFilesTree.dialog.uploadConflicts.description': '{path} 中已有同名檔案。取代後無法復原。',
'sidebarFilesTree.dialog.uploadConflicts.replace': '取代',
'sidebarFilesTree.toast.folderNameRequired': '資料夾名稱不能為空',
'sidebarFilesTree.toast.folderCreated': '資料夾已建立',
'sidebarFilesTree.toast.nameRequired': '名稱不能為空',
@@ -1634,6 +1642,7 @@ export const dict: Record<I18nKey, string> = {
'planView.file.defaultName': 'plan',
'planView.title.default': '計畫',
'planView.error.saveFailed': '儲存失敗',
'planView.error.loadFailed': '無法載入此計畫',
'planView.error.previewUnavailable': '預覽無法使用',
'planView.error.switchToEditMode': '請切換到編輯模式修復問題。',
'planView.error.writeFailed': '寫入失敗',
@@ -1716,11 +1725,46 @@ export const dict: Record<I18nKey, string> = {
'diffView.hunk.unsupported': '此執行環境不支援暫存個別程式碼區塊。',
'rightSidebar.contextNotesTodo.plan.defaultTitle': '計畫',
'rightSidebar.contextNotesTodo.empty.selectProject': '請選擇一個專案以新增筆記和待辦事項。',
'rightSidebar.contextNotesTodo.notes.title': '快速筆記 - {project}',
'rightSidebar.contextNotesTodo.notes.placeholder': '記錄上下文、提醒或連結',
'rightSidebar.contextNotesTodo.todo.title': '待辦',
'rightSidebar.contextNotesTodo.todo.itemsSingle': '{count} 項',
'rightSidebar.contextNotesTodo.todo.itemsPlural': '{count} 項',
'rightSidebar.contextNotesTodo.notes.addAria': '新增筆記',
'rightSidebar.contextNotesTodo.notes.empty': '尚無筆記。可以記錄脈絡、提醒或連結。',
'rightSidebar.contextNotesTodo.notes.actions.expand': '展開筆記',
'rightSidebar.contextNotesTodo.notes.actions.collapse': '收合筆記',
'rightSidebar.contextNotesTodo.notes.actions.delete': '刪除筆記',
'rightSidebar.contextNotesTodo.notes.actions.pin': '釘選到代理上下文',
'rightSidebar.contextNotesTodo.notes.actions.unpin': '從代理上下文取消釘選',
'rightSidebar.contextNotesTodo.notes.source.selection': '來自對話',
'rightSidebar.contextNotesTodo.notes.source.agent': '來自代理',
'rightSidebar.contextNotesTodo.search.placeholder': '搜尋',
'rightSidebar.contextNotesTodo.search.clear': '清除搜尋',
'rightSidebar.contextNotesTodo.search.noResults': '沒有符合「{query}」的內容。',
'rightSidebar.contextNotesTodo.toast.deleteNoteFailed': '刪除筆記失敗',
'rightSidebar.contextNotesTodo.toast.createNoteFailed': '建立筆記失敗',
'rightSidebar.contextNotesTodo.tabs.notes': '筆記',
'rightSidebar.contextNotesTodo.tabs.todos': '待辦',
'rightSidebar.contextNotesTodo.tabs.plans': '計畫',
'rightSidebar.contextNotesTodo.plans.actions.back': '返回計畫列表',
'rightSidebar.contextNotesTodo.tabs.memory': '記憶',
'rightSidebar.contextNotesTodo.sections.label': '專案脈絡分區',
'rightSidebar.contextNotesTodo.sections.resize': '調整分區側欄寬度',
'rightSidebar.contextNotesTodo.memory.scope.project': '專案',
'rightSidebar.contextNotesTodo.memory.scope.label': '記憶範圍',
'rightSidebar.contextNotesTodo.memory.scope.global': '關於你',
'rightSidebar.contextNotesTodo.memory.type.fact': '事實',
'rightSidebar.contextNotesTodo.memory.badge.new': '新增',
'rightSidebar.contextNotesTodo.memory.flagged': '不會傳給代理 — 讀起來像指令',
'rightSidebar.contextNotesTodo.memory.badge.changed': '已變更',
'rightSidebar.contextNotesTodo.memory.type.preference': '偏好',
'rightSidebar.contextNotesTodo.memory.type.reference': '參考',
'rightSidebar.contextNotesTodo.memory.actions.delete': '刪除這則記憶',
'rightSidebar.contextNotesTodo.memory.actions.editTitle': '記憶標題',
'rightSidebar.contextNotesTodo.memory.actions.editBody': '記憶內容',
'rightSidebar.contextNotesTodo.memory.toast.saveFailed': '儲存記憶失敗',
'rightSidebar.contextNotesTodo.memory.toast.deleteFailed': '刪除記憶失敗',
'rightSidebar.contextNotesTodo.memory.empty.nothing': '代理還沒有在這裡儲存內容。',
'rightSidebar.contextNotesTodo.memory.empty.noMatches': '沒有符合搜尋的已儲存記憶。',
'rightSidebar.contextNotesTodo.memory.empty.noProject': '開啟專案即可查看代理記住了什麼。',
'rightSidebar.contextNotesTodo.memory.empty.unavailable': '無法載入已儲存的記憶。內容並未遺失,請再試一次。',
'rightSidebar.contextNotesTodo.todo.clearCompleted': '清除已完成',
'rightSidebar.contextNotesTodo.todo.inputPlaceholder': '新增待辦',
'rightSidebar.contextNotesTodo.todo.addAria': '新增待辦',
@@ -1731,13 +1775,9 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.todo.actions.delete': '刪除「{text}」',
'rightSidebar.contextNotesTodo.todo.actions.send': '傳送「{text}」',
'rightSidebar.contextNotesTodo.todo.actions.reorder': '重新排序「{text}」',
'rightSidebar.contextNotesTodo.todo.resizeAria': '調整待辦清單大小',
'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': '傳送到目前會話',
'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': '傳送到新會話',
'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': '傳送到新 worktree 會話',
'rightSidebar.contextNotesTodo.plans.title': '計畫',
'rightSidebar.contextNotesTodo.plans.filesSingle': '{count} 個檔案',
'rightSidebar.contextNotesTodo.plans.filesPlural': '{count} 個檔案',
'rightSidebar.contextNotesTodo.plans.importFromFile': '從檔案匯入計畫',
'rightSidebar.contextNotesTodo.plans.empty': '還沒有已儲存的計畫。',
'rightSidebar.contextNotesTodo.plans.deletePlan': '刪除計畫',
@@ -1757,6 +1797,7 @@ export const dict: Record<I18nKey, string> = {
'rightSidebar.contextNotesTodo.toast.sentToNewSession': '待辦已傳送到新會話',
'rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession': '待辦已傳送到新的 worktree 會話',
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': '傳送待辦失敗',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': '更新計畫失敗',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '刪除計畫失敗',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': '計畫檔案為空',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': '匯入計畫失敗',
@@ -3359,6 +3400,9 @@ export const dict: Record<I18nKey, string> = {
'quota.window.monthly': 'Monthly Limit',
'quota.window.credits': 'Credits',
'quota.window.creditsBalance': 'Credits Balance',
'quota.window.monthlyCredits': '每月點數',
'quota.window.purchasedCredits': '已購買點數',
'quota.window.freeCredits': '免費點數',
'quota.window.billingCycle': 'Billing Cycle',
'quota.window.auto': 'Auto',
'quota.window.api': 'API',
@@ -3402,6 +3446,12 @@ export const dict: Record<I18nKey, string> = {
'chat.workStatus.subagent.askedQuestion': '提出了問題',
'chat.workStatus.section.contextBreakdown': '上下文來源',
'chat.workStatus.breakdown.skills': '技能',
'chat.workStatus.breakdown.pinnedNote': '筆記',
'chat.workStatus.breakdown.unpin': '從脈絡取消釘選',
'chat.workStatus.breakdown.pinnedPlan': '計畫',
'chat.workStatus.breakdown.memory': '代理記憶',
'chat.workStatus.breakdown.pinnedKnowledgeSingle': '已釘選 {count}',
'chat.workStatus.breakdown.pinnedKnowledgePlural': '已釘選 {count}',
'chat.workStatus.breakdown.mcp': 'MCP 伺服器',
'chat.workStatus.action.openChanges': '開啟變更',
'chat.workStatus.action.openGit': '開啟 Git 面板',
+5 -379
View File
@@ -1,7 +1,11 @@
/**
* OpenChamber project-level configuration service.
* Stores per-project settings in ~/.config/openchamber/<projectId>.json.
* Stores per-project settings in ~/.config/openchamber/projects/<projectId>.json.
* Migrates from legacy <project>/.openchamber/openchamber.json.
*
* Notes, todos, and plan files used to live here too. They are now server-owned
* (`packages/web/server/lib/project-context`) and reached through
* `@/lib/projectContextApi`; what remains here is the client-owned rest.
*/
import type { FilesAPI } from './api/types';
@@ -34,9 +38,6 @@ interface OpenChamberConfig {
projectPath?: string;
'setup-worktree'?: string[];
'setup-worktree-wait'?: boolean;
projectNotes?: string;
projectTodos?: OpenChamberProjectTodoItem[];
projectPlanFiles?: OpenChamberProjectPlanFileLink[];
projectActions?: OpenChamberProjectAction[];
projectActionsPrimaryId?: string;
draftStarters?: DraftStarterRef[];
@@ -62,42 +63,10 @@ export interface OpenChamberProjectActionsState {
primaryActionId: string | null;
}
export interface OpenChamberProjectTodoItem {
id: string;
text: string;
completed: boolean;
createdAt: number;
}
export interface OpenChamberProjectPlanFileLink {
id: string;
path: string;
createdAt: number;
}
export interface OpenChamberProjectPlanFile {
title: string;
body: string;
raw: string;
path: string;
}
export interface OpenChamberProjectNotesTodos {
notes: string;
todos: OpenChamberProjectTodoItem[];
}
export interface OpenChamberProjectContextData extends OpenChamberProjectNotesTodos {
plans: OpenChamberProjectPlanFileLink[];
}
export const OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH = 3000;
export const OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH = 120;
const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80;
const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000;
const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000;
const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300;
const OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH = 160;
const OPENCHAMBER_ACTION_PLATFORM_SET = new Set<OpenChamberProjectActionPlatform>(['macos', 'linux', 'windows']);
@@ -273,93 +242,6 @@ const trimToMaxLength = (value: string, maxLength: number): string => {
return value.slice(0, maxLength);
};
const sanitizeProjectNotes = (value: unknown): string => {
if (typeof value !== 'string') {
return '';
}
return trimToMaxLength(value, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH);
};
const sanitizeProjectTodoItems = (value: unknown): OpenChamberProjectTodoItem[] => {
if (!Array.isArray(value)) {
return [];
}
const sanitized: OpenChamberProjectTodoItem[] = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
continue;
}
const record = entry as {
id?: unknown;
text?: unknown;
completed?: unknown;
createdAt?: unknown;
};
const id = typeof record.id === 'string' ? record.id.trim() : '';
const textRaw = typeof record.text === 'string' ? record.text : '';
const text = trimToMaxLength(textRaw.trim(), OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH);
if (!id || !text) {
continue;
}
const completed = Boolean(record.completed);
const createdAt =
typeof record.createdAt === 'number' && Number.isFinite(record.createdAt) && record.createdAt >= 0
? record.createdAt
: Date.now();
sanitized.push({
id,
text,
completed,
createdAt,
});
}
return sanitized;
};
const sanitizeProjectPlanFileLinks = (value: unknown): OpenChamberProjectPlanFileLink[] => {
if (!Array.isArray(value)) {
return [];
}
const sanitized: OpenChamberProjectPlanFileLink[] = [];
const seenIds = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
continue;
}
const record = entry as {
id?: unknown;
path?: unknown;
createdAt?: unknown;
};
const id = typeof record.id === 'string' ? record.id.trim() : '';
const path = typeof record.path === 'string' ? record.path.trim() : '';
const createdAt =
typeof record.createdAt === 'number' && Number.isFinite(record.createdAt) && record.createdAt >= 0
? record.createdAt
: Date.now();
if (!id || !path || seenIds.has(id)) {
continue;
}
seenIds.add(id);
sanitized.push({ id, path, createdAt });
}
return sanitized.sort((a, b) => b.createdAt - a.createdAt);
};
const sanitizeProjectActionPlatforms = (value: unknown): OpenChamberProjectActionPlatform[] => {
if (!Array.isArray(value)) {
return [];
@@ -459,97 +341,6 @@ const sanitizeProjectActionsState = (value: {
};
};
const sanitizeProjectNotesAndTodos = (value: {
notes?: unknown;
todos?: unknown;
} | null | undefined): OpenChamberProjectNotesTodos => {
return {
notes: sanitizeProjectNotes(value?.notes),
todos: sanitizeProjectTodoItems(value?.todos),
};
};
const sanitizeProjectContextData = (value: {
notes?: unknown;
todos?: unknown;
plans?: unknown;
} | null | undefined): OpenChamberProjectContextData => {
const notesAndTodos = sanitizeProjectNotesAndTodos(value);
return {
...notesAndTodos,
plans: sanitizeProjectPlanFileLinks(value?.plans),
};
};
const slugifyPlanTitle = (value: string): string => {
const normalized = value
.trim()
.toLowerCase()
.replace(/[`*_#>[\](){}.!?,:;"']/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'plan';
};
const sanitizePlanTitle = (value: string): string => {
return trimToMaxLength(value.trim(), OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH);
};
const createProjectPlanId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
const getProjectStorageDirectory = async (project: ProjectRef): Promise<string | null> => {
const base = await getUserProjectsDirectory();
const safeId = resolveConfigProjectId(project);
if (!base || !safeId) {
return null;
}
return joinPath(base, safeId);
};
const getProjectPlansDirectory = async (project: ProjectRef): Promise<string | null> => {
const projectDirectory = await getProjectStorageDirectory(project);
if (!projectDirectory) {
return null;
}
return joinPath(projectDirectory, 'plans');
};
const formatProjectPlanMarkdown = (title: string, body: string): string => {
const normalizedTitle = sanitizePlanTitle(title) || 'Plan';
const normalizedBody = body.trim();
return normalizedBody
? `# ${normalizedTitle}\n\n${normalizedBody}`
: `# ${normalizedTitle}\n`;
};
export const parseProjectPlanMarkdown = (raw: string): { title: string; body: string } => {
const text = typeof raw === 'string' ? raw : '';
const normalized = text.replace(/\r\n?/g, '\n');
const match = normalized.match(/^\s*#\s+(.+?)\s*(?:\n+|$)/);
if (match) {
const title = sanitizePlanTitle(match[1]);
const body = normalized.slice(match[0].length).replace(/^\n+/, '');
return {
title: title || 'Plan',
body,
};
}
const firstNonEmptyLine = normalized.split('\n').map((line) => line.trim()).find(Boolean) || 'Plan';
return {
title: sanitizePlanTitle(firstNonEmptyLine.replace(/^#+\s*/, '')) || 'Plan',
body: normalized.trim(),
};
};
/**
* Read the config for a project.
* Returns null if file doesn't exist or is invalid.
@@ -725,171 +516,6 @@ export async function saveProjectDraftStarters(project: ProjectRef, starters: Dr
return updateOpenChamberConfig(project, { draftStarters: sanitizeStarterRefs(starters) });
}
export async function getProjectNotesAndTodos(project: ProjectRef): Promise<OpenChamberProjectNotesTodos> {
const config = await readOpenChamberConfig(project);
return sanitizeProjectNotesAndTodos({
notes: config?.projectNotes,
todos: config?.projectTodos,
});
}
export async function saveProjectNotesAndTodos(
project: ProjectRef,
value: OpenChamberProjectNotesTodos
): Promise<boolean> {
const sanitized = sanitizeProjectNotesAndTodos({
notes: value.notes,
todos: value.todos,
});
return updateOpenChamberConfig(project, {
projectNotes: sanitized.notes,
projectTodos: sanitized.todos,
});
}
export async function getProjectContextData(project: ProjectRef): Promise<OpenChamberProjectContextData> {
const config = await readOpenChamberConfig(project);
return sanitizeProjectContextData({
notes: config?.projectNotes,
todos: config?.projectTodos,
plans: config?.projectPlanFiles,
});
}
async function getProjectPlanFiles(project: ProjectRef): Promise<OpenChamberProjectPlanFileLink[]> {
const config = await readOpenChamberConfig(project);
return sanitizeProjectPlanFileLinks(config?.projectPlanFiles);
}
async function saveProjectPlanFiles(
project: ProjectRef,
value: OpenChamberProjectPlanFileLink[]
): Promise<boolean> {
const sanitized = sanitizeProjectPlanFileLinks(value);
return updateOpenChamberConfig(project, {
projectPlanFiles: sanitized,
});
}
export async function readProjectPlanFile(path: string): Promise<OpenChamberProjectPlanFile | null> {
const trimmedPath = typeof path === 'string' ? path.trim() : '';
if (!trimmedPath) {
return null;
}
const raw = await readTextFile(trimmedPath);
if (raw === null) {
return null;
}
const parsed = parseProjectPlanMarkdown(raw);
return {
title: parsed.title,
body: parsed.body,
raw,
path: trimmedPath,
};
}
const deleteFile = async (path: string): Promise<boolean> => {
const runtimeFiles = getRuntimeFilesAPI();
if (runtimeFiles?.delete) {
try {
const result = await runtimeFiles.delete(path);
if (result?.success !== false) {
return true;
}
} catch {
// fall through
}
}
const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/delete`, { path });
return Boolean(res.ok);
};
export async function deleteProjectPlanFile(
project: ProjectRef,
planId: string
): Promise<boolean> {
const trimmedId = typeof planId === 'string' ? planId.trim() : '';
if (!trimmedId) {
return false;
}
const existing = await getProjectPlanFiles(project);
const target = existing.find((entry) => entry.id === trimmedId);
if (!target) {
return false;
}
const next = existing.filter((entry) => entry.id !== trimmedId);
const saved = await saveProjectPlanFiles(project, next);
if (!saved) {
return false;
}
// Best-effort: remove underlying markdown file, ignore failure.
await deleteFile(target.path).catch(() => false);
return true;
}
export async function importProjectPlanFileFromContent(
project: ProjectRef,
content: string,
fallbackTitle?: string
): Promise<OpenChamberProjectPlanFileLink | null> {
const raw = typeof content === 'string' ? content : '';
if (!raw.trim()) {
return null;
}
const parsed = parseProjectPlanMarkdown(raw);
const title = parsed.title || sanitizePlanTitle(fallbackTitle ?? '') || 'Plan';
return createProjectPlanFile(project, { title, body: parsed.body });
}
export async function createProjectPlanFile(
project: ProjectRef,
value: { title: string; body: string }
): Promise<OpenChamberProjectPlanFileLink | null> {
const plansDirectory = await getProjectPlansDirectory(project);
if (!plansDirectory) {
return null;
}
const title = sanitizePlanTitle(value.title) || 'Plan';
const createdAt = Date.now();
const id = createProjectPlanId();
const filePath = joinPath(plansDirectory, `${createdAt}-${slugifyPlanTitle(title)}.md`);
const projectDirectory = await getProjectStorageDirectory(project);
if (!projectDirectory) {
return null;
}
const createdProjectDir = await mkdirp(projectDirectory);
const createdPlansDir = createdProjectDir ? await mkdirp(plansDirectory) : false;
if (!createdProjectDir || !createdPlansDir) {
return null;
}
const wrote = await writeTextFile(filePath, formatProjectPlanMarkdown(title, value.body));
if (!wrote) {
return null;
}
const existing = await getProjectPlanFiles(project);
const nextEntry = { id, path: filePath, createdAt };
const saved = await saveProjectPlanFiles(project, [nextEntry, ...existing]);
if (!saved) {
return null;
}
return nextEntry;
}
export async function getProjectActionsState(project: ProjectRef): Promise<OpenChamberProjectActionsState> {
const config = await readOpenChamberConfig(project);
return sanitizeProjectActionsState({
+32 -1
View File
@@ -31,7 +31,22 @@ type BrowserControlRequestEvent = {
parameters: Record<string, unknown>;
};
type OpenChamberEvent = ScheduledTaskRanEvent | SessionCreatedEvent | BrowserControlRequestEvent;
/**
* The agent changed what it remembers. Carries only which store moved, not the
* entries: listeners re-read from the server, so the event cannot go stale
* between being sent and being handled.
*/
type AgentMemoryChangedEvent = {
type: 'agent-memory-changed';
scope: 'global' | 'project';
projectId?: string;
};
type OpenChamberEvent =
| ScheduledTaskRanEvent
| SessionCreatedEvent
| BrowserControlRequestEvent
| AgentMemoryChangedEvent;
type Listener = (event: OpenChamberEvent) => void;
let eventSource: EventSource | null = null;
@@ -118,6 +133,22 @@ const dispatchFromEnvelope = (envelope: { type: string; properties: unknown }) =
return;
}
if (envelope.type === 'openchamber:agent-memory-changed') {
const properties = getEventProperties(envelope.properties);
const scope = properties?.scope === 'project' ? 'project' : 'global';
const nextEvent: AgentMemoryChangedEvent = {
type: 'agent-memory-changed',
scope,
...(typeof properties?.projectId === 'string' && properties.projectId.length > 0
? { projectId: properties.projectId }
: {}),
};
for (const listener of listeners) {
listener(nextEvent);
}
return;
}
if (envelope.type === 'openchamber:session-created') {
const properties = getEventProperties(envelope.properties);
const sessionId = typeof properties?.sessionId === 'string' ? properties.sessionId : '';
+41
View File
@@ -241,6 +241,47 @@ describe('updateDesktopSettings', () => {
}
});
test('sanitizes a successful fallback settings response before applying it', async () => {
const previousFetch = globalThis.fetch;
const fallbackFetch: typeof fetch = async () => new Response(JSON.stringify({ terminalShell: 'zsh' }), {
headers: { 'Content-Type': 'application/json' },
});
try {
globalThis.fetch = fallbackFetch;
useUIStore.getState().setTerminalShell('fish');
await updateDesktopSettings({ terminalShell: 'zsh' });
expect(useUIStore.getState().terminalShell).toBe('zsh');
expect(getSettingsSaveState()).toBe('idle');
} finally {
globalThis.fetch = previousFetch;
}
});
test('reports an error without applying a malformed fallback settings response', async () => {
const previousFetch = globalThis.fetch;
const fallbackFetch: typeof fetch = async () => new Response(JSON.stringify('ok'), {
headers: { 'Content-Type': 'application/json' },
});
const states: string[] = [];
const unsubscribe = subscribeToSettingsSaveState(() => {
states.push(getSettingsSaveState());
});
try {
globalThis.fetch = fallbackFetch;
useUIStore.getState().setTerminalShell('fish');
await updateDesktopSettings({ terminalShell: 'zsh' });
expect(useUIStore.getState().terminalShell).toBe('fish');
expect(states).toEqual(['saving', 'error']);
} finally {
unsubscribe();
globalThis.fetch = previousFetch;
}
});
test('drains a pending save to the previous runtime and ignores its stale response', async () => {
switchRuntimeEndpoint({ apiBaseUrl: 'https://settings-a.example', runtimeKey: 'settings-a' });
const saveResult = deferred<SettingsPayload>();
+31 -17
View File
@@ -139,7 +139,7 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
if (Array.isArray(settings.projects) && settings.projects.length > 0) {
const collapsed = settings.projects
.filter((project) => (project as unknown as { sidebarCollapsed?: boolean }).sidebarCollapsed === true)
.filter((project) => project.sidebarCollapsed === true)
.map((project) => project.id)
.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (collapsed.length > 0) {
@@ -282,13 +282,14 @@ const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs']
if (seen.has(id)) continue;
seen.add(id);
result.push({
const catalog: NonNullable<DesktopSettings['skillCatalogs']>[number] = {
id,
label,
source,
...(subpath ? { subpath } : {}),
...(gitIdentityId ? { gitIdentityId } : {}),
});
};
if (subpath) catalog.subpath = subpath;
if (gitIdentityId) catalog.gitIdentityId = gitIdentityId;
result.push(catalog);
}
return result;
@@ -435,7 +436,7 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
project.icon = candidate.icon.trim();
}
if (candidate.iconImage === null) {
(project as unknown as Record<string, unknown>).iconImage = null;
project.iconImage = null;
} else if (candidate.iconImage && typeof candidate.iconImage === 'object') {
const iconImage = candidate.iconImage as Record<string, unknown>;
const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : '';
@@ -446,18 +447,18 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
? iconImage.source
: null;
if (mime && updatedAt > 0 && source) {
(project as unknown as Record<string, unknown>).iconImage = { mime, updatedAt, source };
project.iconImage = { mime, updatedAt, source };
}
}
if (typeof candidate.color === 'string' && candidate.color.trim().length > 0) {
project.color = candidate.color.trim();
}
if (candidate.iconBackground === null) {
(project as unknown as Record<string, unknown>).iconBackground = null;
project.iconBackground = null;
} else {
const iconBackground = normalizeIconBackground(candidate.iconBackground);
if (iconBackground) {
(project as unknown as Record<string, unknown>).iconBackground = iconBackground;
project.iconBackground = iconBackground;
}
}
if (typeof candidate.addedAt === 'number' && Number.isFinite(candidate.addedAt) && candidate.addedAt >= 0) {
@@ -471,7 +472,7 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
project.lastOpenedAt = candidate.lastOpenedAt;
}
if (typeof candidate.sidebarCollapsed === 'boolean') {
(project as unknown as Record<string, unknown>).sidebarCollapsed = candidate.sidebarCollapsed;
project.sidebarCollapsed = candidate.sidebarCollapsed;
}
result.push(project);
}
@@ -549,7 +550,7 @@ const sanitizeModelRefs = (value: unknown, limit: number): Array<{ providerID: s
};
const getPersistApi = (): PersistApi | undefined => {
const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist;
const candidate = useUIStore.persist;
if (candidate && typeof candidate === 'object') {
return candidate;
}
@@ -596,6 +597,7 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS
showOpenCodeUpdateNotifications: defaults.showOpenCodeUpdateNotifications,
agentControlToolEnabled: defaults.agentControlToolEnabled,
agentWebToolEnabled: defaults.agentWebToolEnabled,
agentMemoryToolEnabled: defaults.agentMemoryToolEnabled,
showToolFileIcons: defaults.showToolFileIcons,
codeBlockLineWrap: defaults.codeBlockLineWrap,
showTurnChangedFiles: defaults.showTurnChangedFiles,
@@ -778,6 +780,19 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
) {
store.setAgentWebToolEnabled(settings.agentWebToolEnabled);
}
if (
typeof settings.agentMemoryToolEnabled === 'boolean'
&& settings.agentMemoryToolEnabled !== store.agentMemoryToolEnabled
) {
store.setAgentMemoryToolEnabled(settings.agentMemoryToolEnabled);
}
// Server-owned: it says whether this build has the feature at all.
if (
typeof settings.agentMemoryFeatureAvailable === 'boolean'
&& settings.agentMemoryFeatureAvailable !== store.agentMemoryFeatureAvailable
) {
store.setAgentMemoryFeatureAvailable(settings.agentMemoryFeatureAvailable);
}
if (typeof settings.showToolFileIcons === 'boolean' && settings.showToolFileIcons !== store.showToolFileIcons) {
store.setShowToolFileIcons(settings.showToolFileIcons);
}
@@ -1367,11 +1382,7 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) {
if (config && typeof config === 'object') {
const typedConfig = config as Record<string, unknown>;
const providerConfig: {
customGroups?: Array<{id: string; label: string; models: string[]; order: number}>;
modelAssignments?: Record<string, string>;
renamedGroups?: Record<string, string>;
} = {};
const providerConfig: NonNullable<DesktopSettings['usageModelGroups']>[string] = {};
// Parse customGroups
if (Array.isArray(typedConfig.customGroups)) {
@@ -1427,6 +1438,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.agentWebToolEnabled === 'boolean') {
result.agentWebToolEnabled = candidate.agentWebToolEnabled;
}
if (typeof candidate.agentMemoryToolEnabled === 'boolean') {
result.agentMemoryToolEnabled = candidate.agentMemoryToolEnabled;
}
if (typeof candidate.openCodeUpdateToastDismissedVersion === 'string') {
result.openCodeUpdateToastDismissedVersion = candidate.openCodeUpdateToastDismissedVersion.trim().slice(0, 128);
}
@@ -1956,7 +1970,7 @@ async function _flushSettingsUpdate(): Promise<void> {
return;
}
const updated = (await response.json().catch(() => null)) as DesktopSettings | null;
const updated = sanitizeWebSettings(await response.json().catch(() => null));
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
applyDesktopUiPreferences(updated);
+339
View File
@@ -0,0 +1,339 @@
/**
* Client for the OpenChamber project context routes.
*
* Notes, todos, and plan markdown are owned by the server
* (`packages/web/server/lib/project-context`). This module only speaks HTTP:
* it resolves no storage paths and never reads plan files directly, so the
* shared UI has no knowledge of where any of it lives on disk.
*
* Every function throws on failure. An authoritative read must never resolve
* to an empty value that a caller could mistake for "the project has nothing".
*/
import { createProjectIdFromPath } from './projectId';
import { runtimeFetch } from './runtime-fetch';
export interface ProjectTodoItem {
id: string;
text: string;
completed: boolean;
createdAt: number;
}
export interface ProjectPlanLink {
id: string;
file: string;
title: string;
createdAt: number;
pinned: boolean;
}
export type ProjectNoteSource = 'manual' | 'selection' | 'agent';
export interface ProjectNote {
id: string;
body: string;
createdAt: number;
updatedAt: number;
source: ProjectNoteSource;
pinned: boolean;
/** The message this note was distilled from, when it came from a chat. */
origin?: { sessionId: string; messageId?: string };
}
interface ProjectContextData {
notes: ProjectNote[];
todos: ProjectTodoItem[];
plans: ProjectPlanLink[];
}
interface ProjectPlanContent extends ProjectPlanLink {
body: string;
raw: string;
}
export interface ProjectRef {
id: string;
path: string;
}
export const PROJECT_NOTE_BODY_MAX_LENGTH = 3000;
export const PROJECT_TODO_TEXT_MAX_LENGTH = 120;
/**
* Split a plan document into title and body, mirroring the server's own rule so
* an unsaved editor buffer and an imported file title exactly the way the
* stored file will.
*/
export const parsePlanMarkdown = (raw: string, fallback: string): { title: string; body: string } => {
const normalized = (typeof raw === 'string' ? raw : '').replace(/\r\n?/g, '\n');
const heading = normalized.match(/^\s*#\s+(.+?)\s*(?:\n+|$)/);
if (heading) {
return {
title: heading[1].trim() || fallback,
body: normalized.slice(heading[0].length).replace(/^\n+/, ''),
};
}
const firstLine = normalized.split('\n').map((line) => line.trim()).find(Boolean);
return {
title: firstLine ? firstLine.replace(/^#+\s*/, '').trim() || fallback : fallback,
body: normalized.trim(),
};
};
/**
* The storage id is derived from the project path, not from `project.id`.
* Project ids in settings have churned across versions; the path-derived id is
* what the server uses to name the config file, so both sides must agree on it.
*/
export const resolveProjectContextId = (project: ProjectRef | null | undefined): string => {
const projectPath = typeof project?.path === 'string' ? project.path.trim() : '';
if (!projectPath) {
return '';
}
return createProjectIdFromPath(projectPath);
};
const basePath = (projectId: string): string => `/api/project-context/${encodeURIComponent(projectId)}`;
const requireProjectId = (project: ProjectRef): string => {
const projectId = resolveProjectContextId(project);
if (!projectId) {
throw new Error('Project has no resolvable path');
}
return projectId;
};
const readErrorMessage = async (response: Response, fallback: string): Promise<string> => {
try {
const payload = await response.json() as { error?: unknown } | null;
if (payload && typeof payload.error === 'string' && payload.error.trim()) {
return payload.error;
}
} catch {
// Fall through to the generic message.
}
return `${fallback} (${response.status})`;
};
const parseContext = (payload: unknown): ProjectContextData => {
const record = payload as Partial<ProjectContextData> | null;
if (!record || typeof record !== 'object') {
throw new Error('Malformed project context response');
}
return {
notes: Array.isArray(record.notes) ? record.notes : [],
todos: Array.isArray(record.todos) ? record.todos : [],
plans: Array.isArray(record.plans) ? record.plans : [],
};
};
export const fetchProjectContext = async (
project: ProjectRef,
options: { signal?: AbortSignal } = {},
): Promise<ProjectContextData> => {
const response = await runtimeFetch(basePath(requireProjectId(project)), {
cache: 'no-store',
signal: options.signal,
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to load project context'));
}
return parseContext(await response.json());
};
export const saveProjectTodos = async (
project: ProjectRef,
todos: ProjectTodoItem[],
): Promise<ProjectContextData> => {
const response = await runtimeFetch(`${basePath(requireProjectId(project))}/todos`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ todos }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to save project todos'));
}
return parseContext(await response.json());
};
export const createProjectNote = async (
project: ProjectRef,
value: { body: string; source?: ProjectNoteSource; origin?: { sessionId: string; messageId?: string } },
): Promise<{ note: ProjectNote; context: ProjectContextData }> => {
const response = await runtimeFetch(`${basePath(requireProjectId(project))}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
body: value.body,
...(value.source ? { source: value.source } : {}),
...(value.origin ? { origin: value.origin } : {}),
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to create note'));
}
const payload = await response.json() as { note?: ProjectNote; context?: unknown };
if (!payload?.note) {
throw new Error('Malformed note create response');
}
return { note: payload.note, context: parseContext(payload.context) };
};
/**
* Patch a note. Only the supplied fields are sent, so pinning cannot roll back
* an edit that landed between the two requests.
*
* Resolves `null` when the note is gone.
*/
export const updateProjectNote = async (
project: ProjectRef,
noteId: string,
patch: { body?: string; pinned?: boolean },
): Promise<ProjectNote | null> => {
const response = await runtimeFetch(
`${basePath(requireProjectId(project))}/notes/${encodeURIComponent(noteId)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to save note'));
}
const payload = await response.json() as { note?: ProjectNote };
if (!payload?.note) {
throw new Error('Malformed note save response');
}
return payload.note;
};
export const deleteProjectNote = async (
project: ProjectRef,
noteId: string,
): Promise<ProjectContextData> => {
const response = await runtimeFetch(
`${basePath(requireProjectId(project))}/notes/${encodeURIComponent(noteId)}`,
{ method: 'DELETE' },
);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to delete note'));
}
return parseContext(await response.json());
};
/** Resolves `null` when the plan is gone. */
export const setProjectPlanPinned = async (
project: ProjectRef,
planId: string,
pinned: boolean,
): Promise<ProjectPlanLink | null> => {
const response = await runtimeFetch(
`${basePath(requireProjectId(project))}/plans/${encodeURIComponent(planId)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned }),
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to update plan'));
}
const payload = await response.json() as { plan?: ProjectPlanLink };
return payload?.plan ?? null;
};
/**
* Plans are addressed by id. The caller supplies content, never a path, so a
* plan can only ever be created inside the project's own plans directory.
*/
export const createProjectPlan = async (
project: ProjectRef,
value: { title: string; body: string },
): Promise<{ plan: ProjectPlanLink; context: ProjectContextData }> => {
const response = await runtimeFetch(`${basePath(requireProjectId(project))}/plans`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: value.title, body: value.body }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to create plan'));
}
const payload = await response.json() as { plan?: ProjectPlanLink; context?: unknown };
if (!payload?.plan) {
throw new Error('Malformed plan create response');
}
return { plan: payload.plan, context: parseContext(payload.context) };
};
/** Resolves `null` only when the plan or its markdown is genuinely gone. */
export const fetchProjectPlan = async (
project: ProjectRef,
planId: string,
options: { signal?: AbortSignal } = {},
): Promise<ProjectPlanContent | null> => {
const response = await runtimeFetch(
`${basePath(requireProjectId(project))}/plans/${encodeURIComponent(planId)}`,
{ cache: 'no-store', signal: options.signal },
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to read plan'));
}
return await response.json() as ProjectPlanContent;
};
/**
* Overwrite a plan's markdown with the editor's exact buffer.
*
* Resolves `null` when the plan or its file is gone, so an editor open on a
* deleted plan reports that instead of silently recreating it.
*/
export const updateProjectPlan = async (
project: ProjectRef,
planId: string,
raw: string,
): Promise<{ plan: ProjectPlanLink; raw: string } | null> => {
const response = await runtimeFetch(
`${basePath(requireProjectId(project))}/plans/${encodeURIComponent(planId)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ raw }),
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to save plan'));
}
const payload = await response.json() as { plan?: ProjectPlanLink; raw?: string };
if (!payload?.plan) {
throw new Error('Malformed plan save response');
}
return { plan: payload.plan, raw: typeof payload.raw === 'string' ? payload.raw : raw };
};
export const deleteProjectPlan = async (
project: ProjectRef,
planId: string,
): Promise<ProjectContextData> => {
const response = await runtimeFetch(
`${basePath(requireProjectId(project))}/plans/${encodeURIComponent(planId)}`,
{ method: 'DELETE' },
);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to delete plan'));
}
return parseContext(await response.json());
};
@@ -8,6 +8,7 @@ export interface QuotaProviderMeta {
export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
{ id: 'claude', name: 'Claude' },
{ id: 'codex', name: 'Codex' },
{ id: 'command-code', name: 'Command Code' },
{ id: 'cursor', name: 'Cursor' },
{ id: 'github-copilot', name: 'GitHub Copilot' },
{ id: 'google', name: 'Google' },
+3
View File
@@ -81,6 +81,9 @@ export const formatWindowLabel = (label: string): string => {
if (label === 'monthly') return t('quota.window.monthly');
if (label === 'credits') return t('quota.window.credits');
if (label === 'credits_balance') return t('quota.window.creditsBalance');
if (label === 'monthly_credits') return t('quota.window.monthlyCredits');
if (label === 'purchased_credits') return t('quota.window.purchasedCredits');
if (label === 'free_credits') return t('quota.window.freeCredits');
if (label === 'billing_cycle') return t('quota.window.billingCycle');
if (label === 'plan_limit') return t('quota.window.planLimit');
if (label === 'auto') return t('quota.window.auto');
+33 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test';
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { buildRuntimeFetchUrl, isLatin1Safe, runtimeFetch, sanitizeHeadersForBrowser } from './runtime-fetch';
import { addRuntimeProxyHeaders, buildRuntimeFetchUrl, isLatin1Safe, runtimeFetch, sanitizeHeadersForBrowser } from './runtime-fetch';
import { clearRuntimeAuthCredentialProvider, setRuntimeBearerToken } from './runtime-auth';
import { configureRuntimeUrlResolver, getRuntimeUrlResolver, setRuntimeUrlResolver } from './runtime-url';
@@ -48,7 +48,39 @@ describe('buildRuntimeFetchUrl', () => {
});
});
describe('addRuntimeProxyHeaders', () => {
test('bypasses the ngrok browser interstitial for official ngrok hosts', () => {
const headers = addRuntimeProxyHeaders('https://demo.ngrok-free.app/health', new Headers());
expect(headers.get('ngrok-skip-browser-warning')).toBe('openchamber');
});
test('does not add proxy headers to non-ngrok or lookalike hosts', () => {
expect(addRuntimeProxyHeaders('https://runtime.example/health', new Headers()).has('ngrok-skip-browser-warning')).toBe(false);
expect(addRuntimeProxyHeaders('https://ngrok-free.app.evil.example/health', new Headers()).has('ngrok-skip-browser-warning')).toBe(false);
});
});
describe('runtimeFetch transport contract', () => {
test('adds the ngrok bypass header to runtime requests', async () => {
const previous = getRuntimeUrlResolver();
let capturedHeaders = new Headers();
try {
configureRuntimeUrlResolver({ apiBaseUrl: 'https://demo.ngrok-free.app' });
globalThis.fetch = async (_input, init) => {
capturedHeaders = new Headers(init?.headers);
return new Response(null, { status: 204 });
};
await runtimeFetch('/health');
expect(capturedHeaders.get('ngrok-skip-browser-warning')).toBe('openchamber');
} finally {
setRuntimeUrlResolver(previous);
globalThis.fetch = originalFetch;
}
});
test('preserves bodies from actual SDK mutation requests on same-origin runtimes', async () => {
const previous = getRuntimeUrlResolver();
const originalWindow = globalThis.window;
+30 -6
View File
@@ -30,6 +30,20 @@ const isCurrentWindowUrl = (url: URL): boolean => {
const isAbsoluteUrl = (value: string): boolean => /^[a-z][a-z\d+.-]*:\/\//i.test(value);
const isNgrokHost = (hostname: string): boolean =>
/(^|\.)ngrok(?:-free)?\.(?:app|dev|io)$/i.test(hostname);
export const addRuntimeProxyHeaders = (url: string, headers: Headers): Headers => {
try {
if (isNgrokHost(new URL(url).hostname) && !headers.has('ngrok-skip-browser-warning')) {
headers.set('ngrok-skip-browser-warning', 'openchamber');
}
} catch {
// Relative and non-HTTP runtime paths do not need proxy-specific headers.
}
return headers;
};
const appendRuntimeQuery = (url: URL, query?: RuntimeUrlQuery): void => {
if (!query) return;
const entries = query instanceof URLSearchParams ? Array.from(query.entries()) : Object.entries(query);
@@ -266,13 +280,15 @@ export const runtimeFetch = async (input: string | URL | Request, init: RuntimeF
const resolvedInput = resolveRuntimeFetchInput(input, query);
const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined;
const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput));
doFetch = resolvedInput instanceof Request
? () => fetch(new Request(resolvedInput, { ...requestInit, headers }))
: () => fetch(resolvedInput, { ...requestInit, headers });
url =
const resolvedUrl =
resolvedInput instanceof Request ? resolvedInput.url
: resolvedInput instanceof URL ? resolvedInput.toString()
: String(resolvedInput);
addRuntimeProxyHeaders(resolvedUrl, headers);
doFetch = resolvedInput instanceof Request
? () => fetch(new Request(resolvedInput, { ...requestInit, headers }))
: () => fetch(resolvedInput, { ...requestInit, headers });
url = resolvedUrl;
method = String(
requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'),
).toUpperCase();
@@ -313,6 +329,7 @@ export const installRuntimeFetchBridge = (): void => {
const url = new URL(input);
if (isActiveRuntimeServiceUrl(url)) {
const headers = await mergeHeaders(undefined, init?.headers);
addRuntimeProxyHeaders(url.toString(), headers);
return nativeFetch(input, { ...init, headers });
}
} catch {
@@ -321,7 +338,9 @@ export const installRuntimeFetchBridge = (): void => {
return nativeFetch(input, init);
}
const headers = await mergeHeaders(undefined, init?.headers);
return nativeFetch(buildRuntimeFetchUrl(input), { ...init, headers });
const target = buildRuntimeFetchUrl(input);
addRuntimeProxyHeaders(target, headers);
return nativeFetch(target, { ...init, headers });
}
if (input instanceof URL) {
@@ -329,12 +348,15 @@ export const installRuntimeFetchBridge = (): void => {
if (!shouldResolveFetchInput(raw)) {
if (isActiveRuntimeServiceUrl(input)) {
const headers = await mergeHeaders(undefined, init?.headers);
addRuntimeProxyHeaders(input.toString(), headers);
return nativeFetch(input, { ...init, headers });
}
return nativeFetch(input, init);
}
const headers = await mergeHeaders(undefined, init?.headers);
return nativeFetch(buildRuntimeFetchUrl(raw), { ...init, headers });
const target = buildRuntimeFetchUrl(raw);
addRuntimeProxyHeaders(target, headers);
return nativeFetch(target, { ...init, headers });
}
if (input instanceof Request) {
@@ -343,6 +365,7 @@ export const installRuntimeFetchBridge = (): void => {
const url = new URL(input.url);
if (isActiveRuntimeServiceUrl(url)) {
const headers = await mergeHeaders(input.headers, init?.headers);
addRuntimeProxyHeaders(url.toString(), headers);
return nativeFetch(new Request(input, { ...init, headers }));
}
} catch {
@@ -352,6 +375,7 @@ export const installRuntimeFetchBridge = (): void => {
}
const headers = await mergeHeaders(input.headers, init?.headers);
const target = buildRuntimeFetchUrl(input.url);
addRuntimeProxyHeaders(target, headers);
const request = target === input.url ? input : new Request(target, input);
return nativeFetch(new Request(request, { ...init, headers }));
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Project knowledge a session still owes, as decided by the server.
*
* The client neither assembles this text nor tracks what it has sent. It used
* to do both, which meant a session started without a UI got nothing, and a
* conversation that was compacted kept a tab-local belief that the agent still
* had context the summary had just removed.
*
* Nothing here throws. A message must go out even when its background cannot
* be fetched: sending without the block costs the agent some context, failing
* the send costs the user their message.
*/
import { runtimeFetch } from './runtime-fetch';
interface SessionKnowledge {
/** Empty when the session already carries what it needs. */
text: string;
/** Reported back once the message carrying the text has actually gone out. */
signature: string;
}
const EMPTY: SessionKnowledge = { text: '', signature: '' };
export const fetchSessionKnowledge = async (
directory: string | null,
sessionId: string | null,
): Promise<SessionKnowledge> => {
if (!directory) {
return EMPTY;
}
try {
const params = new URLSearchParams({ directory });
if (sessionId) {
params.set('sessionId', sessionId);
}
const response = await runtimeFetch(`/api/session-knowledge?${params.toString()}`, {
cache: 'no-store',
});
if (!response.ok) {
return EMPTY;
}
const payload = await response.json() as Partial<SessionKnowledge> | null;
return {
text: typeof payload?.text === 'string' ? payload.text : '',
signature: typeof payload?.signature === 'string' ? payload.signature : '',
};
} catch {
return EMPTY;
}
};
/**
* Recorded after the send resolves, never before: a failed send must carry the
* block again rather than assume the agent already saw it.
*/
export const reportSessionKnowledgeDelivered = async (
directory: string | null,
sessionId: string | null,
signature: string,
): Promise<void> => {
if (!directory || !sessionId || !signature) {
return;
}
try {
await runtimeFetch('/api/session-knowledge/delivered', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ directory, sessionId, signature }),
});
} catch {
// Only means the block may be sent once more.
}
};
export interface SessionKnowledgeSummary {
notes: Array<{ id: string; body: string }>;
plans: Array<{ id: string; title: string }>;
memory: { global: number; project: number };
}
const EMPTY_SUMMARY: SessionKnowledgeSummary = { notes: [], plans: [], memory: { global: 0, project: 0 } };
/** What the session is carrying, for display. Never throws; shows nothing instead. */
export const fetchSessionKnowledgeSummary = async (
directory: string | null,
): Promise<SessionKnowledgeSummary> => {
if (!directory) {
return EMPTY_SUMMARY;
}
try {
const response = await runtimeFetch(
`/api/session-knowledge/summary?${new URLSearchParams({ directory }).toString()}`,
{ cache: 'no-store' },
);
if (!response.ok) {
return EMPTY_SUMMARY;
}
const payload = await response.json() as Partial<SessionKnowledgeSummary> | null;
return {
notes: Array.isArray(payload?.notes) ? payload.notes : [],
plans: Array.isArray(payload?.plans) ? payload.plans : [],
memory: {
global: typeof payload?.memory?.global === 'number' ? payload.memory.global : 0,
project: typeof payload?.memory?.project === 'number' ? payload.memory.project : 0,
},
};
} catch {
return EMPTY_SUMMARY;
}
};
+11
View File
@@ -1,4 +1,5 @@
import type { I18nKey } from '@/lib/i18n/store';
import { useUIStore } from '@/stores/useUIStore';
import type { SettingsPageSlug, SettingsRuntimeContext } from './metadata';
import { getSettingsPageMeta } from './metadata';
@@ -491,6 +492,16 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
keywords: ['agent', 'tool', 'web', 'browser', 'page', 'preview', 'openchamber'],
isAvailable: (ctx) => !ctx.isVSCode,
},
{
id: 'sessions.agent-memory-tool',
page: 'general',
titleKey: 'settings.openchamber.tools.field.agentMemoryTool',
descriptionKey: 'settings.openchamber.tools.field.agentMemoryToolInfo',
keywords: ['agent', 'tool', 'memory', 'remember', 'recall', 'preferences', 'openchamber'],
// Unreleased: searching for a setting that is not rendered would take the
// user to an empty spot on the page.
isAvailable: (ctx) => !ctx.isVSCode && useUIStore.getState().agentMemoryFeatureAvailable,
},
{
id: 'git.github-account',
page: 'git',
+5 -2
View File
@@ -104,9 +104,12 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
{
id: 'notes',
descriptionKey: 'contextRail.surface.notes.description',
defaultWidthFraction: 1 / 3,
// As wide as the files surface: this panel now carries a sidebar and a
// content column, and a third of the window leaves the content column too
// narrow to read a note in.
defaultWidthFraction: 3 / 5,
mode: 'notes',
icon: 'sticky-note',
icon: 'book-marked',
labelKey: 'contextRail.surface.notes',
availability: 'always',
},
+7
View File
@@ -201,6 +201,13 @@ const TOOL_METADATA: Record<string, ToolMetadata> = {
inputFields: []
},
openchamber_memory: {
displayName: 'OpenChamber Memory',
category: 'system',
outputLanguage: 'json',
inputFields: []
},
plan_enter: {
displayName: 'Plan Mode',
category: 'ai',
@@ -11,6 +11,8 @@ type WorktreeListEntry = {
const listCalls: string[] = [];
const listResolvers: Array<(value: WorktreeListEntry[]) => void> = [];
const createPayloads: unknown[] = [];
const validatePayloads: unknown[] = [];
const createdWorktree = {
head: 'abc123',
name: 'feature',
@@ -80,7 +82,14 @@ mock.module('@/lib/gitApi', () => ({
listResolvers.push(resolve);
});
},
create: mock(() => Promise.resolve(createdWorktreeResult)),
create: mock((_directory: string, payload: unknown) => {
createPayloads.push(payload);
return Promise.resolve(createdWorktreeResult);
}),
validate: mock((_directory: string, payload: unknown) => {
validatePayloads.push(payload);
return Promise.resolve({ ok: true, errors: [] });
}),
remove: mock(() => Promise.resolve({ success: true })),
},
},
@@ -91,6 +100,7 @@ const {
getLatestWorktreeMetadata,
listProjectWorktrees,
partitionWorktreesByRegisteredProject,
validateWorktreeCreate,
worktreeMapsEqual,
} = await import('./worktreeManager');
@@ -108,6 +118,8 @@ describe('worktreeManager list invalidation', () => {
beforeEach(() => {
listCalls.length = 0;
listResolvers.length = 0;
createPayloads.length = 0;
validatePayloads.length = 0;
bootstrapWatcherCalls.length = 0;
bootstrapWatcherOptions.length = 0;
createdWorktreeResult = createdWorktree;
@@ -372,3 +384,56 @@ describe('partitionWorktreesByRegisteredProject', () => {
expect(result.get('/repo')?.map((entry) => entry.path)).toEqual(['/worktrees/loose']);
});
});
describe('worktreeManager fork remote payload wiring', () => {
beforeEach(() => {
listCalls.length = 0;
listResolvers.length = 0;
createPayloads.length = 0;
validatePayloads.length = 0;
bootstrapWatcherCalls.length = 0;
bootstrapWatcherOptions.length = 0;
createdWorktreeResult = createdWorktree;
sessionState.availableWorktreesByProject = new Map();
sessionState.availableWorktrees = [];
sessionState.worktreeMetadata = new Map();
attachmentState.attachments = new Map();
});
test('validate and create forward ensureRemoteName/Url for a fork head', async () => {
const project = { id: 'project-1', path: '/repo' };
const args = {
mode: 'existing' as const,
branchName: 'feature/login',
worktreeName: 'pr-42',
existingBranch: 'remotes/pr-alice/feature/login',
setUpstream: true as const,
upstreamRemote: 'pr-alice',
upstreamBranch: 'feature/login',
ensureRemoteName: 'pr-alice',
ensureRemoteUrl: 'https://github.com/alice/openchamber.git',
};
const validation = await validateWorktreeCreate(project, args);
expect(validation.ok).toBe(true);
expect(validatePayloads).toHaveLength(1);
const validated = validatePayloads[0] as Record<string, unknown>;
expect(validated.mode).toBe('existing');
expect(validated.existingBranch).toBe('remotes/pr-alice/feature/login');
expect(validated.ensureRemoteName).toBe('pr-alice');
expect(validated.ensureRemoteUrl).toBe('https://github.com/alice/openchamber.git');
expect('pullRequest' in validated).toBe(false);
await createWorktree(project, {
...args,
returnAfterDirectoryCreated: true,
});
expect(createPayloads).toHaveLength(1);
const created = createPayloads[0] as Record<string, unknown>;
expect(created.existingBranch).toBe('remotes/pr-alice/feature/login');
expect(created.ensureRemoteName).toBe('pr-alice');
expect(created.ensureRemoteUrl).toBe('https://github.com/alice/openchamber.git');
expect(created.setUpstream).toBe(true);
expect('pullRequest' in created).toBe(false);
});
});
+3
View File
@@ -57,10 +57,13 @@ Examples:
- `useProjectsStore.ts`
- `useGlobalSessionsStore.ts`
- `useSessionFoldersStore.ts`
- `useProjectContextStore.ts`
- `messageQueueStore.ts`
These stores coordinate persistent project/session metadata across multiple views.
`useProjectContextStore.ts` caches server-owned project notes, todos, and plan links, keyed by the path-derived project id. It replaced a pair of `window` CustomEvents that made every mounted notes panel re-read the whole project config. Writes are optimistic and roll back on failure; they are serialized per project, because the server's own store does a read-modify-write and two concurrent saves would otherwise race it. A load that resolves while a write is in flight keeps the local value for that field group only, so a slow snapshot cannot undo newer typing while still delivering the plan list it fetched. A failed load sets `error` and preserves the cached snapshot — an unreachable server must never render as "this project has no notes". Note and plan creation are deliberately not optimistic, since ids and timestamps are assigned by the server. Notes, todos, and plans are written through separate routes and tracked by separate in-flight flags, so a todo toggle cannot clobber a note edit in the same window. Pinned notes and plans are assembled into a synthetic context part by `lib/projectContextPinning.ts` at send time; that module tracks per-session what it already sent so an unchanged pinned set is not re-sent every turn.
`messageQueueStore.ts` keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message.
`useGlobalSessionsStore.ts` owns cold/global active and archived session coverage, including `sessionsByDirectory`. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages.
@@ -0,0 +1,213 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { AgentMemoryDisabledError, type AgentMemoryEntry } from '@/lib/agentMemoryApi';
function entry(overrides: Partial<AgentMemoryEntry> = {}): AgentMemoryEntry {
return {
id: 'mem-1',
title: 'Uses bun',
body: 'Tests run with bun test.',
type: 'fact',
createdAt: 1,
updatedAt: 1,
...overrides,
};
}
interface MemoryReadResult {
global: AgentMemoryEntry[];
project: AgentMemoryEntry[];
globalFailed: boolean;
projectFailed: boolean;
}
/**
* Swappable implementations rather than mock helpers: each test states the one
* behaviour it needs.
*/
let readImpl: () => Promise<MemoryReadResult>;
let deleteImpl: () => Promise<void>;
let updateImpl: (memoryId: string, patch: Record<string, unknown>) => Promise<AgentMemoryEntry>;
let lastPatch: Record<string, unknown> | null = null;
mock.module('@/lib/agentMemoryApi', () => ({
AgentMemoryDisabledError,
fetchAgentMemory: () => readImpl(),
deleteAgentMemory: () => deleteImpl(),
updateAgentMemory: (
_scope: string,
_projectPath: string | null,
memoryId: string,
patch: Record<string, unknown>,
) => {
lastPatch = patch;
return updateImpl(memoryId, patch);
},
}));
const { useAgentMemoryStore } = await import('./useAgentMemoryStore');
beforeEach(() => {
useAgentMemoryStore.getState().reset();
readImpl = async () => ({
global: [entry({ id: 'g1', title: 'About user' })],
project: [entry({ id: 'p1', title: 'About project' })],
globalFailed: false,
projectFailed: false,
});
deleteImpl = async () => undefined;
updateImpl = async (memoryId, patch) => ({ ...entry({ id: memoryId }), ...patch });
lastPatch = null;
});
afterEach(() => {
useAgentMemoryStore.getState().reset();
});
describe('load', () => {
test('holds both scopes', async () => {
await useAgentMemoryStore.getState().load('/tmp/project');
const state = useAgentMemoryStore.getState();
expect(state.global.map((item) => item.id)).toEqual(['g1']);
expect(state.project.map((item) => item.id)).toEqual(['p1']);
expect(state.loaded).toBe(true);
});
test('a failed load keeps what was already held', async () => {
await useAgentMemoryStore.getState().load('/tmp/project');
readImpl = async () => { throw new Error('offline'); };
await useAgentMemoryStore.getState().load('/tmp/project');
const state = useAgentMemoryStore.getState();
// Blanking here would read as the agent having forgotten everything.
expect(state.global).toHaveLength(1);
expect(state.error).toBe('offline');
});
test('a disabled feature clears the lists rather than reporting an error', async () => {
await useAgentMemoryStore.getState().load('/tmp/project');
readImpl = async () => { throw new AgentMemoryDisabledError(); };
await useAgentMemoryStore.getState().load('/tmp/project');
const state = useAgentMemoryStore.getState();
expect(state.disabled).toBe(true);
expect(state.global).toHaveLength(0);
expect(state.error).toBeNull();
});
test('a partly failed read is recorded as failed, not as empty', async () => {
readImpl = async () => ({ global: [], project: [], globalFailed: true, projectFailed: false });
await useAgentMemoryStore.getState().load('/tmp/project');
expect(useAgentMemoryStore.getState().globalFailed).toBe(true);
});
});
describe('delete', () => {
test('removes the entry', async () => {
await useAgentMemoryStore.getState().load('/tmp/project');
const ok = await useAgentMemoryStore.getState().deleteEntry('project', 'p1');
expect(ok).toBe(true);
expect(useAgentMemoryStore.getState().project).toHaveLength(0);
});
test('restores the entry when the delete fails', async () => {
await useAgentMemoryStore.getState().load('/tmp/project');
deleteImpl = async () => { throw new Error('offline'); };
const ok = await useAgentMemoryStore.getState().deleteEntry('project', 'p1');
expect(ok).toBe(false);
expect(useAgentMemoryStore.getState().project).toHaveLength(1);
});
});
describe('user corrections', () => {
test('sends only what changed and adopts the saved entry', async () => {
await useAgentMemoryStore.getState().load('/tmp/project');
const ok = await useAgentMemoryStore.getState().saveEntry('project', 'p1', { body: 'Reworded.' });
expect(ok).toBe(true);
expect(lastPatch).toEqual({ body: 'Reworded.' });
expect(useAgentMemoryStore.getState().project[0].body).toBe('Reworded.');
});
test('a failed save leaves the entry as it was', async () => {
await useAgentMemoryStore.getState().load('/tmp/project');
updateImpl = async () => { throw new Error('offline'); };
const ok = await useAgentMemoryStore.getState().saveEntry('project', 'p1', { body: 'Reworded.' });
expect(ok).toBe(false);
expect(useAgentMemoryStore.getState().project[0].body).toBe('Tests run with bun test.');
expect(useAgentMemoryStore.getState().error).toBe('offline');
});
test('touches only the scope it was given', async () => {
await useAgentMemoryStore.getState().load('/tmp/project');
await useAgentMemoryStore.getState().saveEntry('project', 'p1', { title: 'Clearer' });
expect(useAgentMemoryStore.getState().global[0].title).toBe('About user');
});
});
describe('turning the feature off and on', () => {
test('a successful load clears the disabled flag', async () => {
readImpl = async () => { throw new AgentMemoryDisabledError(); };
await useAgentMemoryStore.getState().load('/tmp/project');
expect(useAgentMemoryStore.getState().disabled).toBe(true);
readImpl = async () => ({
global: [entry({ id: 'g1' })], project: [], globalFailed: false, projectFailed: false,
});
await useAgentMemoryStore.getState().load('/tmp/project');
expect(useAgentMemoryStore.getState().disabled).toBe(false);
});
test('refresh re-reads the store the last load used', async () => {
readImpl = async () => { throw new AgentMemoryDisabledError(); };
await useAgentMemoryStore.getState().load('/tmp/project');
let requestedPath: string | null = 'unset';
readImpl = async () => {
requestedPath = useAgentMemoryStore.getState().projectPath;
return { global: [], project: [], globalFailed: false, projectFailed: false };
};
await useAgentMemoryStore.getState().refresh();
// The disabled answer must not lose the path, or refresh reads the wrong store.
expect(requestedPath).toBe('/tmp/project');
});
test('a stale disabled answer cannot latch the feature off again', async () => {
// Re-enabling fires a load before the setting has finished being written,
// so the server truthfully answers "disabled" to a request that is already
// out of date by the time it lands.
const gate: { release?: () => void } = {};
readImpl = () => new Promise((_resolve, reject) => {
gate.release = () => reject(new AgentMemoryDisabledError());
});
const stale = useAgentMemoryStore.getState().load('/tmp/project');
readImpl = async () => ({
global: [entry({ id: 'g1' })], project: [], globalFailed: false, projectFailed: false,
});
await useAgentMemoryStore.getState().load('/tmp/project');
gate.release?.();
await stale;
expect(useAgentMemoryStore.getState().disabled).toBe(false);
expect(useAgentMemoryStore.getState().global).toHaveLength(1);
});
});
@@ -0,0 +1,159 @@
/**
* Agent memory, as the panel and the send path see it.
*
* The server owns the store; this holds the last snapshot read from it and
* serializes writes so two quick edits cannot land out of order.
*
* A failed load never blanks what is already held. An empty list would read as
* "the agent has forgotten everything", which is the one wrong answer here: the
* user would go looking for lost memory that is sitting safely on disk.
*/
import { create } from 'zustand';
import {
AgentMemoryDisabledError,
deleteAgentMemory,
fetchAgentMemory,
updateAgentMemory,
type AgentMemoryEntry,
type AgentMemoryScope,
} from '@/lib/agentMemoryApi';
interface AgentMemoryState {
global: AgentMemoryEntry[];
project: AgentMemoryEntry[];
/** The project path the held `project` entries belong to. */
projectPath: string | null;
loading: boolean;
loaded: boolean;
/** True once the server has reported the feature switched off. */
disabled: boolean;
globalFailed: boolean;
projectFailed: boolean;
error: string | null;
load: (projectPath: string | null) => Promise<void>;
/** Re-read the store the last load used. */
refresh: () => Promise<void>;
saveEntry: (
scope: AgentMemoryScope,
memoryId: string,
patch: { title?: string; body?: string },
) => Promise<boolean>;
deleteEntry: (scope: AgentMemoryScope, memoryId: string) => Promise<boolean>;
reset: () => void;
}
const EMPTY_STATE = {
global: [] as AgentMemoryEntry[],
project: [] as AgentMemoryEntry[],
projectPath: null as string | null,
loading: false,
loaded: false,
disabled: false,
globalFailed: false,
projectFailed: false,
error: null as string | null,
};
/**
* Only the newest load may write to the store. Turning the feature back on
* fires a load before the setting has finished being written, so an older
* "disabled" answer can arrive after a newer successful one and latch the
* feature off again.
*/
let loadSequence = 0;
/** Serializes writes so a slow first request cannot overwrite a later one. */
let writeChain: Promise<unknown> = Promise.resolve();
const enqueueWrite = <T>(work: () => Promise<T>): Promise<T> => {
const next = writeChain.then(work, work);
writeChain = next.catch(() => undefined);
return next;
};
const listFor = (state: AgentMemoryState, scope: AgentMemoryScope): AgentMemoryEntry[] => (
scope === 'global' ? state.global : state.project
);
const withList = (
scope: AgentMemoryScope,
entries: AgentMemoryEntry[],
): Partial<AgentMemoryState> => (
scope === 'global' ? { global: entries } : { project: entries }
);
const errorMessage = (error: unknown, fallback: string): string => (
error instanceof Error && error.message ? error.message : fallback
);
export const useAgentMemoryStore = create<AgentMemoryState>((set, get) => ({
...EMPTY_STATE,
load: async (projectPath) => {
const requestId = ++loadSequence;
set({ loading: true, projectPath });
try {
const snapshot = await fetchAgentMemory(projectPath);
if (requestId !== loadSequence) return;
set({
global: snapshot.global,
project: snapshot.project,
projectPath,
globalFailed: snapshot.globalFailed,
projectFailed: snapshot.projectFailed,
loading: false,
loaded: true,
disabled: false,
error: null,
});
} catch (error) {
if (requestId !== loadSequence) return;
if (error instanceof AgentMemoryDisabledError) {
// Switched off is not a failure. Clearing the lists is right here and
// only here: with the feature off there is nothing for the user to act
// on, and the tab that would show them is gone too. The path is kept so
// a later refresh knows which store to re-read.
set({ ...EMPTY_STATE, projectPath, disabled: true, loaded: true });
return;
}
// Whatever was loaded before stays. Only the error is new.
set({ loading: false, error: errorMessage(error, 'Failed to load agent memory') });
}
},
refresh: async () => {
await get().load(get().projectPath);
},
saveEntry: async (scope, memoryId, patch) => enqueueWrite(async () => {
const previous = listFor(get(), scope);
try {
const saved = await updateAgentMemory(scope, get().projectPath, memoryId, patch);
set(withList(scope, listFor(get(), scope).map((entry) => (entry.id === memoryId ? saved : entry))));
return true;
} catch (error) {
set({ ...withList(scope, previous), error: errorMessage(error, 'Failed to save memory') });
return false;
}
}),
deleteEntry: async (scope, memoryId) => enqueueWrite(async () => {
const previous = listFor(get(), scope);
set(withList(scope, previous.filter((entry) => entry.id !== memoryId)));
try {
await deleteAgentMemory(scope, get().projectPath, memoryId);
return true;
} catch (error) {
set({ ...withList(scope, previous), error: errorMessage(error, 'Failed to delete memory') });
return false;
}
}),
reset: () => {
set({ ...EMPTY_STATE });
},
}));
@@ -0,0 +1,458 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
interface NotePayload {
id: string;
body: string;
createdAt: number;
updatedAt: number;
source: 'manual' | 'selection' | 'agent';
pinned: boolean;
}
interface ContextPayload {
notes: NotePayload[];
todos: { id: string; text: string; completed: boolean; createdAt: number }[];
plans: { id: string; file: string; title: string; createdAt: number; pinned: boolean }[];
}
const emptyPayload = (): ContextPayload => ({ notes: [], todos: [], plans: [] });
const note = (overrides: Partial<NotePayload> = {}): NotePayload => ({
id: 'n1',
body: 'body',
createdAt: 1,
updatedAt: 1,
source: 'manual',
pinned: false,
...overrides,
});
const planLink = (overrides: Partial<ContextPayload['plans'][number]> = {}) => ({
id: 'p1',
file: 'a.md',
title: 'A',
createdAt: 1,
pinned: false,
...overrides,
});
// The UI tsconfig does not load bun's test globals, so these tests follow the
// local precedent of swapping plain handlers instead of using mock helpers.
const handlers = {
fetch: async (): Promise<ContextPayload> => emptyPayload(),
saveTodos: async (todos: ContextPayload['todos']): Promise<ContextPayload> => ({
notes: [],
todos,
plans: [],
}),
createNote: async (): Promise<{ note: NotePayload; context: ContextPayload }> => ({
note: note(),
context: { notes: [note()], todos: [], plans: [] },
}),
updateNote: async (): Promise<NotePayload | null> => note(),
deleteNote: async (): Promise<ContextPayload> => emptyPayload(),
create: async (): Promise<{ plan: ContextPayload['plans'][number]; context: ContextPayload }> => ({
plan: planLink(),
context: { notes: [], todos: [], plans: [planLink()] },
}),
update: async (): Promise<{ plan: ContextPayload['plans'][number]; raw: string } | null> => ({
plan: planLink(),
raw: '# A',
}),
pinPlan: async (): Promise<ContextPayload['plans'][number] | null> => planLink({ pinned: true }),
remove: async (): Promise<ContextPayload> => emptyPayload(),
};
const calls = { fetch: 0, saveTodos: 0, createNote: 0, updateNote: 0, deleteNote: 0, create: 0, update: 0, pinPlan: 0, remove: 0 };
mock.module('@/lib/projectContextApi', () => ({
fetchProjectContext: () => {
calls.fetch += 1;
return handlers.fetch();
},
saveProjectTodos: (_project: unknown, todos: ContextPayload['todos']) => {
calls.saveTodos += 1;
return handlers.saveTodos(todos);
},
createProjectNote: () => {
calls.createNote += 1;
return handlers.createNote();
},
updateProjectNote: () => {
calls.updateNote += 1;
return handlers.updateNote();
},
deleteProjectNote: () => {
calls.deleteNote += 1;
return handlers.deleteNote();
},
setProjectPlanPinned: () => {
calls.pinPlan += 1;
return handlers.pinPlan();
},
createProjectPlan: () => {
calls.create += 1;
return handlers.create();
},
updateProjectPlan: () => {
calls.update += 1;
return handlers.update();
},
deleteProjectPlan: () => {
calls.remove += 1;
return handlers.remove();
},
resolveProjectContextId: (project: { path?: string } | null | undefined) => (
project?.path ? `path_${project.path}` : ''
),
}));
const { useProjectContextStore } = await import('./useProjectContextStore');
const PROJECT = { id: 'ignored', path: '/repo' };
const store = () => useProjectContextStore.getState();
const entry = () => store().getEntry(PROJECT);
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => { resolve = res; });
return { promise, resolve };
};
const failWith = (message: string) => async (): Promise<never> => {
throw new Error(message);
};
beforeEach(() => {
store().reset();
calls.fetch = 0;
calls.saveTodos = 0;
calls.createNote = 0;
calls.updateNote = 0;
calls.deleteNote = 0;
calls.create = 0;
calls.update = 0;
calls.pinPlan = 0;
calls.remove = 0;
handlers.fetch = async () => emptyPayload();
handlers.saveTodos = async (todos) => ({ notes: [], todos, plans: [] });
handlers.createNote = async () => ({ note: note(), context: { notes: [note()], todos: [], plans: [] } });
handlers.updateNote = async () => note();
handlers.deleteNote = async () => emptyPayload();
handlers.create = async () => ({ plan: planLink(), context: { notes: [], todos: [], plans: [planLink()] } });
handlers.update = async () => ({ plan: planLink(), raw: '# A' });
handlers.pinPlan = async () => planLink({ pinned: true });
handlers.remove = async () => emptyPayload();
});
describe('getEntry', () => {
test('returns a stable empty entry for an unknown project', () => {
expect(entry()).toEqual({ notes: [], todos: [], plans: [], loaded: false, loading: false, error: null });
});
test('returns the empty entry for a project without a path', () => {
expect(store().getEntry({ id: 'x', path: '' }).loaded).toBe(false);
});
});
describe('load', () => {
test('populates from the server', async () => {
handlers.fetch = async () => ({
notes: [note({ body: 'server note' })],
todos: [{ id: 't1', text: 'a', completed: false, createdAt: 1 }],
plans: [planLink()],
});
await store().load(PROJECT);
expect(entry().notes.map((entryNote) => entryNote.body)).toEqual(['server note']);
expect(entry().todos).toHaveLength(1);
expect(entry().plans).toHaveLength(1);
expect(entry().loaded).toBe(true);
expect(entry().error).toBeNull();
});
test('does not refetch once loaded', async () => {
await store().load(PROJECT);
await store().load(PROJECT);
expect(calls.fetch).toBe(1);
});
test('refetches when forced', async () => {
await store().load(PROJECT);
await store().load(PROJECT, { force: true });
expect(calls.fetch).toBe(2);
});
test('a failed load preserves previously loaded data instead of clearing it', async () => {
handlers.fetch = async () => ({ notes: [note({ body: 'kept' })], todos: [], plans: [] });
await store().load(PROJECT);
handlers.fetch = failWith('offline');
await store().load(PROJECT, { force: true });
expect(entry().notes.map((entryNote) => entryNote.body)).toEqual(['kept']);
expect(entry().loaded).toBe(true);
expect(entry().error).toBe('offline');
});
test('a first-load failure reports the error and stays unloaded', async () => {
handlers.fetch = failWith('boom');
await store().load(PROJECT);
expect(entry().loaded).toBe(false);
expect(entry().notes).toEqual([]);
expect(entry().error).toBe('boom');
});
test('concurrent loads issue a single request', async () => {
await Promise.all([store().load(PROJECT), store().load(PROJECT), store().load(PROJECT)]);
expect(calls.fetch).toBe(1);
});
});
describe('saveTodos', () => {
test('applies optimistically before the request resolves', async () => {
const gate = deferred<ContextPayload>();
handlers.saveTodos = () => gate.promise;
const pending = store().saveTodos(PROJECT, [{ id: 't1', text: 'typed', completed: false, createdAt: 1 }]);
expect(entry().todos).toHaveLength(1);
gate.resolve({ notes: [], todos: [{ id: 't1', text: 'typed', completed: false, createdAt: 1 }], plans: [] });
expect(await pending).toBe(true);
expect(entry().todos).toHaveLength(1);
});
test('rolls back and reports the error on failure', async () => {
await store().saveTodos(PROJECT, [{ id: 't1', text: 'original', completed: false, createdAt: 1 }]);
handlers.saveTodos = failWith('disk full');
expect(await store().saveTodos(PROJECT, [])).toBe(false);
expect(entry().todos.map((todo) => todo.text)).toEqual(['original']);
expect(entry().error).toBe('disk full');
});
test('serializes concurrent writes in call order', async () => {
const order: string[] = [];
handlers.saveTodos = async (todos) => {
const label = todos[0]?.text ?? 'empty';
order.push(`start:${label}`);
await new Promise((resolve) => setTimeout(resolve, 5));
order.push(`end:${label}`);
return { notes: [], todos, plans: [] };
};
await Promise.all([
store().saveTodos(PROJECT, [{ id: '1', text: 'first', completed: false, createdAt: 1 }]),
store().saveTodos(PROJECT, [{ id: '2', text: 'second', completed: false, createdAt: 2 }]),
]);
expect(order).toEqual(['start:first', 'end:first', 'start:second', 'end:second']);
});
test('a load resolving during an in-flight write does not clobber it', async () => {
const gate = deferred<ContextPayload>();
handlers.saveTodos = () => gate.promise;
handlers.fetch = async () => ({
notes: [note({ body: 'from server' })],
todos: [{ id: 'stale', text: 'stale', completed: false, createdAt: 0 }],
plans: [],
});
const pending = store().saveTodos(PROJECT, [{ id: 'local', text: 'local', completed: false, createdAt: 1 }]);
await store().load(PROJECT);
expect(entry().todos.map((todo) => todo.id)).toEqual(['local']);
// The same snapshot still delivers the fields the write did not touch.
expect(entry().notes.map((entryNote) => entryNote.body)).toEqual(['from server']);
gate.resolve({ notes: [], todos: [{ id: 'local', text: 'local', completed: false, createdAt: 1 }], plans: [] });
await pending;
});
test('ignores a project without a resolvable path', async () => {
expect(await store().saveTodos({ id: 'x', path: '' }, [])).toBe(false);
expect(calls.saveTodos).toBe(0);
});
});
describe('notes', () => {
test('createNote adopts the committed list', async () => {
handlers.createNote = async () => ({
note: note({ id: 'n9', body: 'fresh' }),
context: { notes: [note({ id: 'n9', body: 'fresh' })], todos: [], plans: [] },
});
const created = await store().createNote(PROJECT, { body: 'fresh' });
expect(created?.id).toBe('n9');
expect(entry().notes.map((entryNote) => entryNote.id)).toEqual(['n9']);
});
test('createNote refuses a whitespace-only body without calling the server', async () => {
expect(await store().createNote(PROJECT, { body: ' ' })).toBeNull();
expect(calls.createNote).toBe(0);
});
test('createNote reports failure without inserting a placeholder row', async () => {
handlers.createNote = failWith('no space');
expect(await store().createNote(PROJECT, { body: 'x' })).toBeNull();
expect(entry().notes).toEqual([]);
expect(entry().error).toBe('no space');
});
test('saveNoteBody applies optimistically and commits the server copy', async () => {
await store().createNote(PROJECT, { body: 'before' });
handlers.updateNote = async () => note({ body: 'after', updatedAt: 9 });
expect(await store().saveNoteBody(PROJECT, 'n1', 'after')).toBe(true);
expect(entry().notes[0].body).toBe('after');
expect(entry().notes[0].updatedAt).toBe(9);
});
test('saveNoteBody rolls back on failure', async () => {
await store().createNote(PROJECT, { body: 'before' });
handlers.updateNote = failWith('read only');
expect(await store().saveNoteBody(PROJECT, 'n1', 'after')).toBe(false);
expect(entry().notes[0].body).toBe('body');
expect(entry().error).toBe('read only');
});
test('saveNoteBody drops a note the server reports as gone', async () => {
await store().createNote(PROJECT, { body: 'before' });
handlers.updateNote = async () => null;
expect(await store().saveNoteBody(PROJECT, 'n1', 'after')).toBe(false);
expect(entry().notes).toEqual([]);
});
test('setNotePinned applies optimistically', async () => {
await store().createNote(PROJECT, { body: 'x' });
const gate = deferred<NotePayload | null>();
handlers.updateNote = () => gate.promise;
const pending = store().setNotePinned(PROJECT, 'n1', true);
expect(entry().notes[0].pinned).toBe(true);
gate.resolve(note({ pinned: true }));
expect(await pending).toBe(true);
});
test('setNotePinned rolls back on failure', async () => {
await store().createNote(PROJECT, { body: 'x' });
handlers.updateNote = failWith('locked');
expect(await store().setNotePinned(PROJECT, 'n1', true)).toBe(false);
expect(entry().notes[0].pinned).toBe(false);
});
test('deleteNote removes optimistically and restores on failure', async () => {
await store().createNote(PROJECT, { body: 'x' });
handlers.deleteNote = failWith('busy');
expect(await store().deleteNote(PROJECT, 'n1')).toBe(false);
expect(entry().notes.map((entryNote) => entryNote.id)).toEqual(['n1']);
expect(entry().error).toBe('busy');
});
test('deleteNote commits the server list on success', async () => {
await store().createNote(PROJECT, { body: 'x' });
expect(await store().deleteNote(PROJECT, 'n1')).toBe(true);
expect(entry().notes).toEqual([]);
});
});
describe('plans', () => {
test('createPlan commits the server context', async () => {
const plan = await store().createPlan(PROJECT, { title: 'A', body: 'x' });
expect(plan?.id).toBe('p1');
expect(entry().plans.map((item) => item.id)).toEqual(['p1']);
});
test('createPlan reports failure without inserting a placeholder row', async () => {
handlers.create = failWith('no space');
expect(await store().createPlan(PROJECT, { title: 'A', body: 'x' })).toBeNull();
expect(entry().plans).toEqual([]);
expect(entry().error).toBe('no space');
});
test('deletePlan removes optimistically', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
const gate = deferred<ContextPayload>();
handlers.remove = () => gate.promise;
const pending = store().deletePlan(PROJECT, 'p1');
expect(entry().plans).toEqual([]);
gate.resolve(emptyPayload());
expect(await pending).toBe(true);
});
test('savePlan folds the refreshed title back into the list', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
handlers.update = async () => ({ plan: planLink({ title: 'Renamed' }), raw: '# Renamed' });
expect(await store().savePlan(PROJECT, 'p1', '# Renamed')).toBe(true);
expect(entry().plans[0].title).toBe('Renamed');
});
test('savePlan drops a plan the server reports as gone', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
handlers.update = async () => null;
expect(await store().savePlan(PROJECT, 'p1', '# X')).toBe(false);
expect(entry().plans).toEqual([]);
});
test('savePlan keeps the row and reports the error when the request fails', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
handlers.update = failWith('read only');
expect(await store().savePlan(PROJECT, 'p1', '# X')).toBe(false);
expect(entry().plans.map((item) => item.id)).toEqual(['p1']);
expect(entry().error).toBe('read only');
});
test('setPlanPinned applies optimistically and rolls back on failure', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
handlers.pinPlan = failWith('locked');
expect(await store().setPlanPinned(PROJECT, 'p1', true)).toBe(false);
expect(entry().plans[0].pinned).toBe(false);
expect(entry().error).toBe('locked');
});
test('setPlanPinned commits the server copy', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
expect(await store().setPlanPinned(PROJECT, 'p1', true)).toBe(true);
expect(entry().plans[0].pinned).toBe(true);
});
test('deletePlan restores the row when the request fails', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
handlers.remove = failWith('locked');
expect(await store().deletePlan(PROJECT, 'p1')).toBe(false);
expect(entry().plans.map((item) => item.id)).toEqual(['p1']);
expect(entry().error).toBe('locked');
});
});
describe('reset', () => {
test('drops every cached project', async () => {
await store().load(PROJECT);
expect(entry().loaded).toBe(true);
store().reset();
expect(entry().loaded).toBe(false);
});
});
@@ -0,0 +1,450 @@
/**
* Project context store: notes, todos, and plan links, keyed by project.
*
* Replaces the `openchamber:project-notes-updated` / `openchamber:project-plan-saved`
* window events that previously forced every mounted panel to re-read the whole
* config. Writers now mutate the store and every reader re-renders from it.
*
* Storage is server-owned; this store is a cache with optimistic mutations.
* See `packages/web/server/lib/project-context/DOCUMENTATION.md`.
*/
import { create } from 'zustand';
import {
createProjectNote,
createProjectPlan,
deleteProjectNote,
deleteProjectPlan,
fetchProjectContext,
resolveProjectContextId,
saveProjectTodos,
setProjectPlanPinned,
updateProjectNote,
updateProjectPlan,
type ProjectNote,
type ProjectNoteSource,
type ProjectPlanLink,
type ProjectRef,
type ProjectTodoItem,
} from '@/lib/projectContextApi';
interface ProjectContextEntry {
notes: ProjectNote[];
todos: ProjectTodoItem[];
plans: ProjectPlanLink[];
/** True once an authoritative load has succeeded at least once. */
loaded: boolean;
loading: boolean;
/** Last load or save failure. Never clears cached data on its own. */
error: string | null;
}
interface MutationFlags {
/** A note write is in flight; a slower load must not overwrite the list. */
notes: boolean;
/** A todo write is in flight; same rule. */
todos: boolean;
/** A plan write is in flight; same rule. */
plans: boolean;
}
interface ProjectContextState {
entries: Record<string, ProjectContextEntry>;
}
interface ProjectContextActions {
getEntry: (project: ProjectRef | null | undefined) => ProjectContextEntry;
load: (project: ProjectRef, options?: { force?: boolean }) => Promise<void>;
saveTodos: (project: ProjectRef, todos: ProjectTodoItem[]) => Promise<boolean>;
createNote: (
project: ProjectRef,
value: { body: string; source?: ProjectNoteSource; origin?: { sessionId: string; messageId?: string } },
) => Promise<ProjectNote | null>;
saveNoteBody: (project: ProjectRef, noteId: string, body: string) => Promise<boolean>;
setNotePinned: (project: ProjectRef, noteId: string, pinned: boolean) => Promise<boolean>;
deleteNote: (project: ProjectRef, noteId: string) => Promise<boolean>;
createPlan: (project: ProjectRef, value: { title: string; body: string }) => Promise<ProjectPlanLink | null>;
savePlan: (project: ProjectRef, planId: string, raw: string) => Promise<boolean>;
setPlanPinned: (project: ProjectRef, planId: string, pinned: boolean) => Promise<boolean>;
deletePlan: (project: ProjectRef, planId: string) => Promise<boolean>;
reset: () => void;
}
type ProjectContextStore = ProjectContextState & ProjectContextActions;
export const EMPTY_PROJECT_CONTEXT_ENTRY: ProjectContextEntry = {
notes: [],
todos: [],
plans: [],
loaded: false,
loading: false,
error: null,
};
/**
* Per-project write chains and in-flight mutation flags.
*
* Kept outside the store because they are coordination state, not rendered
* state: putting them in the store would re-render every consumer whenever a
* write starts or finishes.
*/
const writeChains = new Map<string, Promise<unknown>>();
const mutationFlags = new Map<string, MutationFlags>();
const flagsFor = (projectId: string): MutationFlags => {
const existing = mutationFlags.get(projectId);
if (existing) return existing;
const created: MutationFlags = { notes: false, todos: false, plans: false };
mutationFlags.set(projectId, created);
return created;
};
/**
* Serialize writes per project so two saves cannot interleave into a
* last-writer-wins race against the server's own read-modify-write.
*/
const enqueueWrite = <T>(projectId: string, operation: () => Promise<T>): Promise<T> => {
const previous = writeChains.get(projectId) ?? Promise.resolve();
const next = previous.then(operation, operation);
writeChains.set(projectId, next.catch(() => undefined));
return next;
};
const errorMessage = (error: unknown, fallback: string): string => (
error instanceof Error && error.message ? error.message : fallback
);
export const useProjectContextStore = create<ProjectContextStore>((set, get) => {
const patchEntry = (projectId: string, patch: Partial<ProjectContextEntry>) => {
set((state) => ({
entries: {
...state.entries,
[projectId]: { ...(state.entries[projectId] ?? EMPTY_PROJECT_CONTEXT_ENTRY), ...patch },
},
}));
};
const currentEntry = (projectId: string): ProjectContextEntry => (
get().entries[projectId] ?? EMPTY_PROJECT_CONTEXT_ENTRY
);
return {
entries: {},
getEntry: (project) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return EMPTY_PROJECT_CONTEXT_ENTRY;
return get().entries[projectId] ?? EMPTY_PROJECT_CONTEXT_ENTRY;
},
/**
* Load authoritative context.
*
* A failure sets `error` and leaves any previously loaded data in place:
* an unreachable server must not read as "this project has no notes",
* which is exactly how a user loses trust in a notes panel.
*/
load: async (project, options = {}) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return;
const entry = currentEntry(projectId);
if (entry.loading) return;
if (entry.loaded && !options.force) return;
patchEntry(projectId, { loading: true });
try {
const data = await fetchProjectContext(project);
const flags = flagsFor(projectId);
const committed = currentEntry(projectId);
// A mutation that started after this load began is newer than the
// snapshot; keep the local value for that field group only.
patchEntry(projectId, {
notes: flags.notes ? committed.notes : data.notes,
todos: flags.todos ? committed.todos : data.todos,
plans: flags.plans ? committed.plans : data.plans,
loaded: true,
loading: false,
error: null,
});
} catch (error) {
patchEntry(projectId, {
loading: false,
error: errorMessage(error, 'Failed to load project context'),
});
}
},
/**
* Optimistically apply todos, then persist.
*
* On failure the previous list is restored, so the panel never shows a
* state that is not on disk without also showing the error.
*/
saveTodos: async (project, todos) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return false;
const previous = currentEntry(projectId).todos;
patchEntry(projectId, { todos, error: null });
const flags = flagsFor(projectId);
flags.todos = true;
try {
const committed = await enqueueWrite(projectId, () => saveProjectTodos(project, todos));
patchEntry(projectId, { todos: committed.todos, loaded: true });
return true;
} catch (error) {
patchEntry(projectId, {
todos: previous,
error: errorMessage(error, 'Failed to save project todos'),
});
return false;
} finally {
flags.todos = false;
}
},
/**
* Create a note. Not optimistic: the id and timestamps come from the
* server, and a placeholder row that cannot be edited or pinned is worse
* than a brief wait.
*
* The caller may be a chat action running while the panel is not mounted,
* so the committed list is adopted wholesale rather than spliced into a
* possibly-empty local one.
*/
createNote: async (project, value) => {
const projectId = resolveProjectContextId(project);
const body = value.body.trim();
if (!projectId || !body) return null;
const flags = flagsFor(projectId);
flags.notes = true;
try {
const { note, context } = await enqueueWrite(
projectId,
() => createProjectNote(project, { ...value, body }),
);
patchEntry(projectId, { notes: context.notes, loaded: true, error: null });
return note;
} catch (error) {
patchEntry(projectId, { error: errorMessage(error, 'Failed to create note') });
return null;
} finally {
flags.notes = false;
}
},
saveNoteBody: async (project, noteId, body) => {
const projectId = resolveProjectContextId(project);
const trimmed = body.trim();
if (!projectId || !trimmed) return false;
const previous = currentEntry(projectId).notes;
patchEntry(projectId, {
notes: previous.map((note) => (note.id === noteId ? { ...note, body: trimmed } : note)),
error: null,
});
const flags = flagsFor(projectId);
flags.notes = true;
try {
const saved = await enqueueWrite(projectId, () => updateProjectNote(project, noteId, { body: trimmed }));
if (!saved) {
patchEntry(projectId, { notes: currentEntry(projectId).notes.filter((note) => note.id !== noteId) });
return false;
}
patchEntry(projectId, {
notes: currentEntry(projectId).notes.map((note) => (note.id === noteId ? saved : note)),
});
return true;
} catch (error) {
patchEntry(projectId, { notes: previous, error: errorMessage(error, 'Failed to save note') });
return false;
} finally {
flags.notes = false;
}
},
/** Sends `pinned` alone, so it cannot roll back a concurrent body edit. */
setNotePinned: async (project, noteId, pinned) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return false;
const previous = currentEntry(projectId).notes;
patchEntry(projectId, {
notes: previous.map((note) => (note.id === noteId ? { ...note, pinned } : note)),
error: null,
});
const flags = flagsFor(projectId);
flags.notes = true;
try {
const saved = await enqueueWrite(projectId, () => updateProjectNote(project, noteId, { pinned }));
if (!saved) {
patchEntry(projectId, { notes: currentEntry(projectId).notes.filter((note) => note.id !== noteId) });
return false;
}
patchEntry(projectId, {
notes: currentEntry(projectId).notes.map((note) => (note.id === noteId ? saved : note)),
});
return true;
} catch (error) {
patchEntry(projectId, { notes: previous, error: errorMessage(error, 'Failed to save note') });
return false;
} finally {
flags.notes = false;
}
},
deleteNote: async (project, noteId) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return false;
const previous = currentEntry(projectId).notes;
patchEntry(projectId, { notes: previous.filter((note) => note.id !== noteId), error: null });
const flags = flagsFor(projectId);
flags.notes = true;
try {
const context = await enqueueWrite(projectId, () => deleteProjectNote(project, noteId));
patchEntry(projectId, { notes: context.notes });
return true;
} catch (error) {
patchEntry(projectId, { notes: previous, error: errorMessage(error, 'Failed to delete note') });
return false;
} finally {
flags.notes = false;
}
},
/**
* Create a plan. Not optimistic: the id and file name are assigned by the
* server, and a placeholder row that cannot be opened is worse than a
* short wait.
*/
createPlan: async (project, value) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return null;
const flags = flagsFor(projectId);
flags.plans = true;
try {
const { plan, context } = await enqueueWrite(projectId, () => createProjectPlan(project, value));
patchEntry(projectId, { plans: context.plans, loaded: true, error: null });
return plan;
} catch (error) {
patchEntry(projectId, { error: errorMessage(error, 'Failed to create plan') });
return null;
} finally {
flags.plans = false;
}
},
/**
* Persist an edited plan and fold the refreshed title back into the list,
* so renaming a plan's heading in the editor is reflected in the panel
* without a reload. Resolves false when the plan is gone.
*/
savePlan: async (project, planId, raw) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return false;
const flags = flagsFor(projectId);
flags.plans = true;
try {
const result = await enqueueWrite(projectId, () => updateProjectPlan(project, planId, raw));
if (!result) {
patchEntry(projectId, {
plans: currentEntry(projectId).plans.filter((plan) => plan.id !== planId),
});
return false;
}
patchEntry(projectId, {
plans: currentEntry(projectId).plans.map((plan) => (plan.id === planId ? result.plan : plan)),
error: null,
});
return true;
} catch (error) {
patchEntry(projectId, { error: errorMessage(error, 'Failed to save plan') });
return false;
} finally {
flags.plans = false;
}
},
setPlanPinned: async (project, planId, pinned) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return false;
const previous = currentEntry(projectId).plans;
patchEntry(projectId, {
plans: previous.map((plan) => (plan.id === planId ? { ...plan, pinned } : plan)),
error: null,
});
const flags = flagsFor(projectId);
flags.plans = true;
try {
const saved = await enqueueWrite(projectId, () => setProjectPlanPinned(project, planId, pinned));
if (!saved) {
patchEntry(projectId, { plans: currentEntry(projectId).plans.filter((plan) => plan.id !== planId) });
return false;
}
patchEntry(projectId, {
plans: currentEntry(projectId).plans.map((plan) => (plan.id === planId ? saved : plan)),
});
return true;
} catch (error) {
patchEntry(projectId, { plans: previous, error: errorMessage(error, 'Failed to update plan') });
return false;
} finally {
flags.plans = false;
}
},
deletePlan: async (project, planId) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return false;
const previous = currentEntry(projectId);
patchEntry(projectId, { plans: previous.plans.filter((plan) => plan.id !== planId), error: null });
const flags = flagsFor(projectId);
flags.plans = true;
try {
const context = await enqueueWrite(projectId, () => deleteProjectPlan(project, planId));
patchEntry(projectId, { plans: context.plans });
return true;
} catch (error) {
patchEntry(projectId, {
plans: previous.plans,
error: errorMessage(error, 'Failed to delete plan'),
});
return false;
} finally {
flags.plans = false;
}
},
/** Drop every cached project. Used when the active runtime changes. */
reset: () => {
writeChains.clear();
mutationFlags.clear();
set({ entries: {} });
},
};
});

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