Merge main
This commit is contained in:
@@ -35,7 +35,8 @@ import {
|
||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||
import { BtwPanel } from './btw/BtwPanel';
|
||||
import { useBtwPanelState } from './btw/useBtwPanelState';
|
||||
import { destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
|
||||
import { wasPromotedBtwSession } from '@/lib/sessionBtwMetadata';
|
||||
import { BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
|
||||
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
@@ -104,10 +105,12 @@ import {
|
||||
type ComposerEditorHandle,
|
||||
} from './composer/editor/ComposerEditor';
|
||||
import { createComposerEditorViewStore } from './composer/editor/viewStore';
|
||||
import { composerAutoCorrect } from './composer/editor/autocorrect';
|
||||
import {
|
||||
appendInlineText,
|
||||
appendWithLineBreaks,
|
||||
buildImagePasteInsertion,
|
||||
getMarkdownAutoPairEdit,
|
||||
shouldWrapSelectionAsLink,
|
||||
withInlineInsertionBoundaries,
|
||||
} from './composer/text';
|
||||
@@ -338,6 +341,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
[btwDirectory, btwSessionId, currentSessionId],
|
||||
);
|
||||
const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed;
|
||||
// A session promoted out of `/btw` keeps the boundary instructions in its
|
||||
// transcript — there is no way to delete a message part — so it has to say
|
||||
// they no longer apply.
|
||||
const isPromotedBtwSession = wasPromotedBtwSession(btwPanel.parentSession);
|
||||
const activeRuntimeKey = getRuntimeKey();
|
||||
const chatDraftIdentity = React.useMemo(
|
||||
() => createChatDraftIdentity(
|
||||
@@ -1010,6 +1017,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
|
||||
if (!providerIdToSend || !modelIdToSend) {
|
||||
console.warn('Cannot send message: provider or model not selected');
|
||||
toast.error(t('chat.chatInput.toast.noModelSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1126,7 +1134,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
composerText: !queuedOnly && inputSnapshot.hasContent ? inputSnapshot.message : null,
|
||||
composerAttachments: attachedFiles,
|
||||
inlineComments: drafts,
|
||||
syntheticTexts: syntheticParts?.map((part) => part.text) ?? [],
|
||||
// btw mode: the boundary rides with every send, not just the
|
||||
// first one, so the inherited transcript stays reference material
|
||||
// for the whole side conversation.
|
||||
syntheticTexts: [
|
||||
...(isBtwActive ? [BTW_BOUNDARY_INSTRUCTION] : []),
|
||||
...(isPromotedBtwSession ? [BTW_PROMOTION_NOTICE] : []),
|
||||
...(syntheticParts?.map((part) => part.text) ?? []),
|
||||
],
|
||||
linkedIssue: linkedIssue
|
||||
? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText }
|
||||
: null,
|
||||
@@ -1620,39 +1635,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const selEnd = ta?.getSelection().end ?? -1;
|
||||
|
||||
if (ta && selStart >= 0) {
|
||||
const applyEdit = (next: string, caretStart: number, caretEnd: number) => {
|
||||
const edit = getMarkdownAutoPairEdit(message, e.key, selStart, selEnd);
|
||||
if (edit) {
|
||||
e.preventDefault();
|
||||
setMessage(next);
|
||||
composerRef.current?.setSelection(caretStart, caretEnd);
|
||||
updateAutocompleteState(next, caretEnd);
|
||||
};
|
||||
|
||||
// Wrap the current selection: select text, press ` * _ ~ ( [ { " '
|
||||
const WRAP_PAIRS: Record<string, [string, string]> = {
|
||||
'`': ['`', '`'], '*': ['*', '*'], '_': ['_', '_'], '~': ['~', '~'],
|
||||
'(': ['(', ')'], '[': ['[', ']'], '{': ['{', '}'],
|
||||
'"': ['"', '"'], "'": ["'", "'"],
|
||||
};
|
||||
if (selEnd > selStart && WRAP_PAIRS[e.key]) {
|
||||
const [open, close] = WRAP_PAIRS[e.key];
|
||||
const selected = message.slice(selStart, selEnd);
|
||||
const next = `${message.slice(0, selStart)}${open}${selected}${close}${message.slice(selEnd)}`;
|
||||
applyEdit(next, selStart + open.length, selEnd + open.length);
|
||||
ta.replaceRange(
|
||||
edit.from,
|
||||
edit.to,
|
||||
edit.insert,
|
||||
edit.selectionStart,
|
||||
edit.selectionEnd,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Typing the third backtick at line start expands into a fenced
|
||||
// code block with the caret on the empty middle line (Slack-like).
|
||||
if (e.key === '`' && selStart === selEnd) {
|
||||
const before = message.slice(0, selStart);
|
||||
if (/(^|\n)``$/.test(before)) {
|
||||
const after = message.slice(selEnd);
|
||||
const next = `${before}\`\n\n\`\`\`${after}`;
|
||||
const caret = before.length + 2; // after the completed ``` and first newline
|
||||
applyEdit(next, caret, caret);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2851,7 +2845,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
|
||||
: t('chat.chatInput.placeholder.selectSession')}
|
||||
editable={Boolean(currentSessionId || newSessionDraftOpen)}
|
||||
autoCorrect={isMobile}
|
||||
autoCorrect={composerAutoCorrect({ isMobile })}
|
||||
autoCapitalize={isMobile ? 'sentences' : 'none'}
|
||||
spellCheck={isMobile || inputSpellcheckEnabled}
|
||||
fillContainer={isComposerExpanded}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { deriveMessageRole } from './message/messageRole';
|
||||
import { filterVisibleParts, normalizeParts } from './message/partUtils';
|
||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||
import { isHiddenUserMessage } from './message/hiddenUserMessage';
|
||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||
import { flattenAssistantTextParts, flattenUserTextParts } from '@/lib/messages/messageText';
|
||||
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
@@ -702,40 +702,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const messageTextContent = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
const shellOutputs = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const output = part.shellAction?.output;
|
||||
return typeof output === 'string' ? output.trim() : '';
|
||||
})
|
||||
.filter((output) => output.length > 0);
|
||||
|
||||
if (shellOutputs.length > 0) {
|
||||
return shellOutputs.join('\n\n');
|
||||
}
|
||||
|
||||
const shellCommands = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const command = part.shellAction?.command;
|
||||
return typeof command === 'string' ? command.trim() : '';
|
||||
})
|
||||
.filter((command) => command.length > 0);
|
||||
|
||||
if (shellCommands.length > 0) {
|
||||
return shellCommands.join('\n');
|
||||
}
|
||||
|
||||
const textParts = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const text = part.text || part.content || '';
|
||||
return text.trim();
|
||||
})
|
||||
.filter((text) => text.length > 0);
|
||||
|
||||
const combined = textParts.join('\n');
|
||||
return combined.replace(/\n\s*\n+/g, '\n');
|
||||
return flattenUserTextParts(displayParts);
|
||||
}
|
||||
|
||||
if (assistantErrorText && assistantErrorText.trim().length > 0) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
@@ -66,8 +65,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
}, ref) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const sessionMessages = useSessionMessages(currentSessionId ?? '');
|
||||
const hasMessagesInCurrentSession = sessionMessages.length > 0;
|
||||
const hasSession = Boolean(currentSessionId);
|
||||
const hasNewSessionDraft = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const canStartSessionCommand = hasSession || hasNewSessionDraft;
|
||||
@@ -140,7 +137,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
}));
|
||||
|
||||
const builtInCommands: CommandInfo[] = [
|
||||
...(hasSession && !hasMessagesInCurrentSession
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
|
||||
: []
|
||||
),
|
||||
@@ -200,10 +197,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
];
|
||||
const allCommands = mergeCommandAutocompleteItems(builtInCommands, customCommands, skillCommands);
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const filtered = (searchQuery
|
||||
const filtered = searchQuery
|
||||
? allCommands.filter(cmd => commandMatchesSearch(cmd, searchQuery))
|
||||
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
: allCommands;
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase());
|
||||
@@ -216,9 +212,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
setCommands(filtered);
|
||||
} catch {
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const builtInCommands: CommandInfo[] = [
|
||||
...(hasSession && !hasMessagesInCurrentSession
|
||||
...(hasSession
|
||||
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
|
||||
: []
|
||||
),
|
||||
@@ -277,12 +272,12 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
),
|
||||
];
|
||||
|
||||
const filtered = (searchQuery
|
||||
const filtered = searchQuery
|
||||
? builtInCommands.filter(cmd =>
|
||||
fuzzyMatch(cmd.name, searchQuery) ||
|
||||
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
|
||||
)
|
||||
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
: builtInCommands;
|
||||
|
||||
setCommands(filtered);
|
||||
} finally {
|
||||
@@ -291,7 +286,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
};
|
||||
|
||||
loadCommands();
|
||||
}, [searchQuery, hasMessagesInCurrentSession, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
|
||||
}, [searchQuery, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
|
||||
@@ -47,8 +47,12 @@ export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof Ma
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy>> = (props) => (
|
||||
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
|
||||
type SimpleMarkdownRendererProps = React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy> & {
|
||||
fallbackContent?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ fallbackContent, ...props }) => (
|
||||
<React.Suspense fallback={fallbackContent ?? <MobileMarkdownFallback {...props} />}>
|
||||
<SimpleMarkdownRendererLazy {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
@@ -2283,7 +2283,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
: 'Default';
|
||||
|
||||
return (
|
||||
<span className={cn('typography-micro whitespace-nowrap', wasAdjusted ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
<span className={cn(
|
||||
'typography-micro whitespace-nowrap',
|
||||
isHighlighted
|
||||
? (wasAdjusted ? 'text-interactive-selection-foreground' : 'text-interactive-selection-foreground/70')
|
||||
: (wasAdjusted ? 'text-foreground' : 'text-muted-foreground'),
|
||||
)}>
|
||||
Thinking: {displayLabel}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as sessionActions from '@/sync/session-actions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { serializeQuestionAsJson, serializeQuestionAsMarkdown } from './questionSerializers';
|
||||
import { QUESTION_CUSTOM_TEXTAREA_MIN_HEIGHT, getQuestionCustomTextareaHeight } from './questionTextareaSizing';
|
||||
import { QuestionMarkdown } from './QuestionMarkdown';
|
||||
|
||||
interface QuestionCardProps {
|
||||
question: QuestionRequest;
|
||||
@@ -423,7 +424,11 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
||||
</div>
|
||||
) : activeQuestion ? (
|
||||
<>
|
||||
<div className="typography-meta font-medium text-foreground mb-1.5">{activeQuestion.question}</div>
|
||||
<QuestionMarkdown
|
||||
content={activeQuestion.question}
|
||||
size="meta"
|
||||
className="font-medium text-foreground mb-1.5"
|
||||
/>
|
||||
|
||||
{isMultiple ? (
|
||||
<div className="typography-micro text-muted-foreground mb-1.5">{t('chat.questionCard.selectMultiple')}</div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { SimpleMarkdownRenderer } from './MarkdownRenderer';
|
||||
import { QuestionMarkdown } from './QuestionMarkdown';
|
||||
|
||||
describe('QuestionMarkdown', () => {
|
||||
test('delegates exact content to the tool markdown renderer', () => {
|
||||
const content = 'Choose **one** from `mode`: [details](https://example.com)';
|
||||
const element = QuestionMarkdown({ content, size: 'meta' });
|
||||
|
||||
expect(element.type).toBe(SimpleMarkdownRenderer);
|
||||
expect(element.props.content).toBe(content);
|
||||
expect(element.props.variant).toBe('tool');
|
||||
expect(element.props.fallbackContent.props.children).toBe(content);
|
||||
expect(element.props.fallbackContent.props.className).toContain('whitespace-pre-wrap');
|
||||
});
|
||||
|
||||
test('preserves question typography size and caller classes', () => {
|
||||
const meta = QuestionMarkdown({ content: 'Meta', size: 'meta', className: 'font-medium text-foreground' });
|
||||
const micro = QuestionMarkdown({ content: 'Micro', size: 'micro', className: 'text-muted-foreground' });
|
||||
|
||||
expect(meta.props.className).toBe('question-markdown typography-meta font-medium text-foreground');
|
||||
expect(micro.props.className).toBe('question-markdown typography-micro text-muted-foreground');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SimpleMarkdownRenderer } from './MarkdownRenderer';
|
||||
|
||||
interface QuestionMarkdownProps {
|
||||
content: string;
|
||||
size: 'meta' | 'micro';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function QuestionMarkdown({ content, size, className }: QuestionMarkdownProps) {
|
||||
const classes = cn('question-markdown', size === 'meta' ? 'typography-meta' : 'typography-micro', className);
|
||||
|
||||
return (
|
||||
<SimpleMarkdownRenderer
|
||||
content={content}
|
||||
variant="tool"
|
||||
className={classes}
|
||||
fallbackContent={<div className={cn(classes, 'whitespace-pre-wrap')}>{content}</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetada
|
||||
import { useBtwStore } from '@/stores/useBtwStore';
|
||||
|
||||
export type BtwPanelState = {
|
||||
/** The session the composer is in — the one `/btw` would fork. */
|
||||
parentSession: Session | null;
|
||||
/** The active fork for this parent, or null when no panel should exist. */
|
||||
btwSessionId: string | null;
|
||||
btwSession: Session | null;
|
||||
@@ -40,6 +42,7 @@ export function useBtwPanelState(
|
||||
const destroying = Boolean(uiState?.destroying);
|
||||
const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null;
|
||||
return {
|
||||
parentSession: parentSession ?? null,
|
||||
btwSessionId,
|
||||
btwSession: btwSessionId ? btwSession : null,
|
||||
// SAFETY: the SDK Session type omits the server's `directory` field; this
|
||||
|
||||
@@ -112,6 +112,14 @@ 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.
|
||||
|
||||
The content element keeps the existing correction policy: on in the mobile UI,
|
||||
off elsewhere. CodeMirror also reads the attribute and reverts Apple and
|
||||
Android's insert-period-on-double-space only when its value is exactly `off`.
|
||||
`editor/autocorrect.ts` uses the HTML standard's
|
||||
[ASCII case-insensitive `autocorrect` keywords](https://html.spec.whatwg.org/multipage/interaction.html#attr-autocorrect)
|
||||
to keep desktop word correction off while avoiding that CodeMirror-only
|
||||
revert. Its platform checks deliberately match CodeMirror's own browser flags.
|
||||
|
||||
`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
|
||||
is cheaper and far simpler than incremental mapping, and it keeps the editor
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
appendInlineText,
|
||||
appendWithLineBreaks,
|
||||
buildImagePasteInsertion,
|
||||
getMarkdownAutoPairEdit,
|
||||
shouldWrapSelectionAsLink,
|
||||
withInlineInsertionBoundaries,
|
||||
} from '../text';
|
||||
@@ -119,3 +120,39 @@ describe('shouldWrapSelectionAsLink', () => {
|
||||
expect(shouldWrapSelectionAsLink('https://x.dev', '[docs](https://y.dev)')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMarkdownAutoPairEdit', () => {
|
||||
test('completes a fenced block with the caret on the middle line', () => {
|
||||
expect(getMarkdownAutoPairEdit('``', '`', 2, 2)).toEqual({
|
||||
from: 2,
|
||||
to: 2,
|
||||
insert: '`\n\n```',
|
||||
selectionStart: 4,
|
||||
selectionEnd: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test('completes a fence at the start of any line', () => {
|
||||
expect(getMarkdownAutoPairEdit('intro\n``tail', '`', 8, 8)).toEqual({
|
||||
from: 8,
|
||||
to: 8,
|
||||
insert: '`\n\n```',
|
||||
selectionStart: 10,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
});
|
||||
|
||||
test('does not complete two backticks in the middle of a line', () => {
|
||||
expect(getMarkdownAutoPairEdit('text ``', '`', 7, 7)).toBeNull();
|
||||
});
|
||||
|
||||
test('wraps selected text and keeps the text selected', () => {
|
||||
expect(getMarkdownAutoPairEdit('hello', '*', 1, 4)).toEqual({
|
||||
from: 1,
|
||||
to: 4,
|
||||
insert: '*ell*',
|
||||
selectionStart: 2,
|
||||
selectionEnd: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ComposerLanguageContext } from '../language/tokenize';
|
||||
import type { ComposerAutoCorrect } from './autocorrect';
|
||||
import { composerLanguage, setLanguageContext } from './composerLanguage';
|
||||
import type { ComposerEditorViewStore } from './viewStore';
|
||||
import { composerEditorTheme, composerSelectionExtension } from './theme';
|
||||
@@ -63,8 +64,8 @@ export interface ComposerEditorHandle {
|
||||
selectAll(): void;
|
||||
/** Replace the current selection, leaving the caret after the insertion. */
|
||||
insertText(text: string): void;
|
||||
/** Replace an explicit range; the caret lands at `caret` or after the text. */
|
||||
replaceRange(from: number, to: number, text: string, caret?: number): void;
|
||||
/** Replace a range; selection defaults to a caret after the inserted text. */
|
||||
replaceRange(from: number, to: number, text: string, selectionStart?: number, selectionEnd?: number): void;
|
||||
/** Viewport coordinates of the caret, for positioning popups. */
|
||||
caretCoords(position?: number): { top: number; bottom: number; left: number } | null;
|
||||
/** The scrollable element, for measuring and scroll compensation. */
|
||||
@@ -89,8 +90,11 @@ export interface ComposerEditorProps {
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
spellCheck?: boolean;
|
||||
/** Mobile keyboards; ignored on desktop. */
|
||||
autoCorrect?: boolean;
|
||||
/**
|
||||
* The content element's autocorrect keyword. See `autocorrect.ts` for the
|
||||
* case-sensitive CodeMirror workaround.
|
||||
*/
|
||||
autoCorrect?: ComposerAutoCorrect;
|
||||
autoCapitalize?: 'none' | 'sentences';
|
||||
/** Fill the available height instead of growing with the content. */
|
||||
fillContainer?: boolean;
|
||||
@@ -157,7 +161,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
placeholder,
|
||||
editable = true,
|
||||
spellCheck = false,
|
||||
autoCorrect = false,
|
||||
autoCorrect = 'off',
|
||||
autoCapitalize = 'none',
|
||||
fillContainer = false,
|
||||
maxLines = 8,
|
||||
@@ -287,7 +291,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
}),
|
||||
EditorView.contentAttributes.of({
|
||||
spellcheck: String(handlersRef.current.spellCheck ?? false),
|
||||
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off',
|
||||
autocorrect: handlersRef.current.autoCorrect ?? 'off',
|
||||
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
|
||||
...(handlersRef.current['aria-label']
|
||||
? { 'aria-label': handlersRef.current['aria-label'] }
|
||||
@@ -454,7 +458,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
if (!view) return;
|
||||
const content = view.contentDOM;
|
||||
content.setAttribute('spellcheck', String(spellCheck));
|
||||
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off');
|
||||
content.setAttribute('autocorrect', autoCorrect);
|
||||
content.setAttribute('autocapitalize', autoCapitalize);
|
||||
}, [autoCapitalize, autoCorrect, spellCheck]);
|
||||
|
||||
@@ -516,12 +520,13 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
},
|
||||
replaceRange(from, to, text, caret) {
|
||||
replaceRange(from, to, text, selectionStart, selectionEnd = selectionStart) {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const anchor = selectionStart ?? from + text.length;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: caret ?? from + text.length },
|
||||
selection: { anchor, head: selectionEnd ?? anchor },
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { composerAutoCorrect, type ComposerAutoCorrect } from '../autocorrect';
|
||||
|
||||
const platform = (overrides: Partial<Navigator>): Navigator => ({
|
||||
maxTouchPoints: 0,
|
||||
platform: '',
|
||||
userAgent: '',
|
||||
vendor: '',
|
||||
...overrides,
|
||||
} as Navigator);
|
||||
|
||||
const codeMirrorKeepsDoubleSpacePeriod = (
|
||||
autoCorrect: ComposerAutoCorrect,
|
||||
): boolean => autoCorrect !== 'off';
|
||||
|
||||
const affectedPlatforms: Array<[string, Navigator]> = [
|
||||
['macOS', platform({ platform: 'MacIntel' })],
|
||||
['iPhone', platform({
|
||||
platform: 'iPhone',
|
||||
userAgent: 'Mozilla/5.0 Mobile/15E148 Safari/604.1',
|
||||
vendor: 'Apple Computer, Inc.',
|
||||
})],
|
||||
['iPadOS touch detection', platform({
|
||||
maxTouchPoints: 5,
|
||||
userAgent: 'Mozilla/5.0 Version/17.4 Safari/605.1.15',
|
||||
vendor: 'Apple Computer, Inc.',
|
||||
})],
|
||||
['Android', platform({
|
||||
platform: 'Linux armv8l',
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8)',
|
||||
})],
|
||||
];
|
||||
|
||||
const unaffectedPlatforms: Array<[string, Navigator]> = [
|
||||
['Windows', platform({ platform: 'Win32' })],
|
||||
['Linux', platform({ platform: 'Linux x86_64' })],
|
||||
];
|
||||
|
||||
describe('composerAutoCorrect', () => {
|
||||
test('matches the pinned CodeMirror period-revert guard', () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(import.meta.resolve('@codemirror/view')),
|
||||
'utf8',
|
||||
);
|
||||
const semantics = source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\s+/g, '');
|
||||
|
||||
expect(/getAttribute\(["']autocorrect["']\)==["']off["']/.test(semantics)).toBe(true);
|
||||
expect(semantics).toContain(
|
||||
'constios=safari&&(/Mobile\\/\\w+/.test(nav.userAgent)||nav.maxTouchPoints>2)',
|
||||
);
|
||||
expect(semantics).toContain('mac:ios||/Mac/.test(nav.platform)');
|
||||
expect(semantics).toContain('android:/Android\\b/.test(nav.userAgent)');
|
||||
});
|
||||
|
||||
for (const [name, navigator] of affectedPlatforms) {
|
||||
test(`preserves the ${name} platform period without enabling autocorrect`, () => {
|
||||
const autoCorrect = composerAutoCorrect({ isMobile: false, navigator });
|
||||
|
||||
expect(autoCorrect.toLowerCase()).toBe('off');
|
||||
// @codemirror/view 6.39.13 reverts the native period only for exact "off".
|
||||
expect(codeMirrorKeepsDoubleSpacePeriod(autoCorrect)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, navigator] of unaffectedPlatforms) {
|
||||
test(`leaves desktop correction off on ${name}`, () => {
|
||||
expect(composerAutoCorrect({ isMobile: false, navigator })).toBe('off');
|
||||
});
|
||||
}
|
||||
|
||||
test('uses CodeMirror platform detection rather than a macOS user agent', () => {
|
||||
expect(composerAutoCorrect({
|
||||
isMobile: false,
|
||||
navigator: platform({
|
||||
platform: 'Linux x86_64',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
}),
|
||||
})).toBe('off');
|
||||
});
|
||||
|
||||
test('preserves the existing mobile autocorrect policy', () => {
|
||||
expect(composerAutoCorrect({
|
||||
isMobile: true,
|
||||
navigator: platform({ platform: 'Win32' }),
|
||||
})).toBe('on');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export type ComposerAutoCorrect = 'on' | 'off' | 'Off';
|
||||
|
||||
type PlatformNavigator = Pick<Navigator,
|
||||
'maxTouchPoints' | 'platform' | 'userAgent' | 'vendor'
|
||||
>;
|
||||
|
||||
/** Keep desktop autocorrect off without triggering CodeMirror's period revert. */
|
||||
export function composerAutoCorrect(options: {
|
||||
isMobile: boolean;
|
||||
navigator?: PlatformNavigator;
|
||||
}): ComposerAutoCorrect {
|
||||
if (options.isMobile) return 'on';
|
||||
|
||||
const nav = options.navigator
|
||||
?? (typeof navigator === 'undefined'
|
||||
? { maxTouchPoints: 0, platform: '', userAgent: '', vendor: '' }
|
||||
: navigator);
|
||||
// These must match CodeMirror's flags because its revert checks exact "off".
|
||||
const ios = /Apple Computer/.test(nav.vendor)
|
||||
&& (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2);
|
||||
return ios || /Mac/.test(nav.platform) || /Android\b/.test(nav.userAgent)
|
||||
? 'Off'
|
||||
: 'off';
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': {
|
||||
padding: '0',
|
||||
// Keep the drawn empty-document cursor inside the scroller's horizontal clip.
|
||||
paddingInlineStart: '1px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
|
||||
@@ -104,3 +104,61 @@ export function shouldWrapSelectionAsLink(url: string, selected: string): boolea
|
||||
&& selected.trim().length > 0
|
||||
&& !selected.includes('](');
|
||||
}
|
||||
|
||||
const MARKDOWN_WRAP_PAIRS: Record<string, [string, string]> = {
|
||||
'`': ['`', '`'],
|
||||
'*': ['*', '*'],
|
||||
'_': ['_', '_'],
|
||||
'~': ['~', '~'],
|
||||
'(': ['(', ')'],
|
||||
'[': ['[', ']'],
|
||||
'{': ['{', '}'],
|
||||
'"': ['"', '"'],
|
||||
"'": ["'", "'"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Markdown source-mode conveniences handled before CodeMirror inserts a key.
|
||||
* The returned text change and selection belong to one editor transaction so
|
||||
* the caret cannot be applied against the previous document.
|
||||
*/
|
||||
export function getMarkdownAutoPairEdit(
|
||||
value: string,
|
||||
key: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
): {
|
||||
from: number;
|
||||
to: number;
|
||||
insert: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
} | null {
|
||||
const pair = MARKDOWN_WRAP_PAIRS[key];
|
||||
if (selectionEnd > selectionStart && pair) {
|
||||
const selected = value.slice(selectionStart, selectionEnd);
|
||||
const [open, close] = pair;
|
||||
return {
|
||||
from: selectionStart,
|
||||
to: selectionEnd,
|
||||
insert: `${open}${selected}${close}`,
|
||||
selectionStart: selectionStart + open.length,
|
||||
selectionEnd: selectionEnd + open.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (key === '`' && selectionStart === selectionEnd) {
|
||||
const before = value.slice(0, selectionStart);
|
||||
if (/(^|\n)``$/.test(before)) {
|
||||
return {
|
||||
from: selectionStart,
|
||||
to: selectionEnd,
|
||||
insert: '`\n\n```',
|
||||
selectionStart: selectionStart + 2,
|
||||
selectionEnd: selectionStart + 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ const STYLE_CLASS: Record<AnyStyle, string> = {
|
||||
mentionAgent: 'text-[var(--status-success)]',
|
||||
mentionCommand: 'text-[var(--primary)]',
|
||||
mentionSnippet: 'text-[var(--status-warning)]',
|
||||
code: 'rounded-[3px] bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
|
||||
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)] px-[0.3125rem] py-0.5',
|
||||
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
|
||||
// A `~path` is written for the reader's benefit, not to attach anything —
|
||||
// it takes the same colour as a file mention, since it names the same kind
|
||||
|
||||
@@ -156,9 +156,8 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
row.setAttribute('data-md-code-line', '');
|
||||
|
||||
const number = document.createElement('span');
|
||||
number.setAttribute('data-md-code-line-number', '');
|
||||
number.setAttribute('data-md-code-line-number', String(index + 1));
|
||||
number.setAttribute('aria-hidden', 'true');
|
||||
number.textContent = String(index + 1);
|
||||
|
||||
const content = document.createElement('span');
|
||||
content.setAttribute('data-md-code-line-content', '');
|
||||
@@ -168,7 +167,6 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
} else {
|
||||
content.textContent = sourceLine;
|
||||
}
|
||||
|
||||
row.append(number, content);
|
||||
fragment.appendChild(row);
|
||||
if (index < sourceLines.length - 1 || hasTrailingNewline) {
|
||||
@@ -543,6 +541,67 @@ const closeAllMenus = (container: HTMLElement): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const getContainingMarkdownCode = (node: Node): HTMLElement | null => {
|
||||
const element = node.nodeType === 1 ? node as Element : node.parentElement;
|
||||
return element?.closest<HTMLElement>('pre code[data-md-code-lines]') ?? null;
|
||||
};
|
||||
|
||||
const getMarkdownCodeSelectionText = (range: Range): string | null => {
|
||||
const code = getContainingMarkdownCode(range.startContainer);
|
||||
if (!code || code !== getContainingMarkdownCode(range.endContainer)) return null;
|
||||
// Line numbers are CSS-generated, so the DOM range is already the exact
|
||||
// source selection, including boundaries between rows and empty lines.
|
||||
return range.toString();
|
||||
};
|
||||
|
||||
type MarkdownCopyState = {
|
||||
registrations: number;
|
||||
handler: (event: ClipboardEvent) => void;
|
||||
menuHandler: (event: Event) => void;
|
||||
};
|
||||
|
||||
const markdownCopyStates = new WeakMap<Document, MarkdownCopyState>();
|
||||
|
||||
const registerMarkdownCodeCopy = (doc: Document): (() => void) => {
|
||||
let state = markdownCopyStates.get(doc);
|
||||
if (!state) {
|
||||
const getSelectedText = (): string | null => {
|
||||
const selection = doc.getSelection();
|
||||
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
|
||||
return getMarkdownCodeSelectionText(selection.getRangeAt(0));
|
||||
};
|
||||
const handler = (event: ClipboardEvent) => {
|
||||
if (!event.clipboardData) return;
|
||||
const text = getSelectedText();
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.clipboardData.setData('text/plain', text);
|
||||
};
|
||||
const menuHandler = (event: Event) => {
|
||||
const text = getSelectedText();
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
void copyTextToClipboard(text);
|
||||
};
|
||||
state = { registrations: 0, handler, menuHandler };
|
||||
markdownCopyStates.set(doc, state);
|
||||
doc.addEventListener('copy', handler, true);
|
||||
doc.defaultView?.addEventListener('openchamber:copy', menuHandler);
|
||||
}
|
||||
state.registrations += 1;
|
||||
|
||||
return () => {
|
||||
const current = markdownCopyStates.get(doc);
|
||||
if (!current) return;
|
||||
current.registrations -= 1;
|
||||
if (current.registrations > 0) return;
|
||||
doc.removeEventListener('copy', current.handler, true);
|
||||
doc.defaultView?.removeEventListener('openchamber:copy', current.menuHandler);
|
||||
markdownCopyStates.delete(doc);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach a single delegated click listener for all in-markdown actions: code
|
||||
* copy, table copy/download menus, mermaid copy/download, loopback preview.
|
||||
@@ -552,6 +611,7 @@ export const attachMarkdownInteractions = (
|
||||
container: HTMLElement,
|
||||
ctx: DecorateContext,
|
||||
): (() => void) => {
|
||||
const unregisterCodeCopy = registerMarkdownCodeCopy(container.ownerDocument);
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
@@ -658,5 +718,8 @@ export const attachMarkdownInteractions = (
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
return () => container.removeEventListener('click', handleClick);
|
||||
return () => {
|
||||
unregisterCodeCopy();
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -279,3 +279,30 @@ describe('Markdown images', () => {
|
||||
expect(html).not.toContain('data-openchamber-markdown-image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CJK-aware link parsing', () => {
|
||||
const hrefOf = (html: string): string | null => /<a\b[^>]*href="([^"]*)"/.exec(html)?.[1] ?? null;
|
||||
|
||||
test('bare URL followed by a CJK annotation trims the annotation from the href', () => {
|
||||
const html = renderMarkdownSync('访问 https://example.com/docs(中文说明)了解更多');
|
||||
expect(hrefOf(html)).toBe('https://example.com/docs');
|
||||
});
|
||||
|
||||
test('bare URL followed by CJK punctuation trims the punctuation', () => {
|
||||
expect(hrefOf(renderMarkdownSync('地址 https://example.com/guide,详见'))).toBe(
|
||||
'https://example.com/guide',
|
||||
);
|
||||
expect(hrefOf(renderMarkdownSync('官网 https://example.com。'))).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('correct links are unaffected', () => {
|
||||
expect(hrefOf(renderMarkdownSync('官方文档见 [这里](https://docs.example.com)(中文说明)'))).toBe(
|
||||
'https://docs.example.com',
|
||||
);
|
||||
expect(hrefOf(renderMarkdownSync('[下载](https://dl.example.com/安装包(正式版))'))).toBe(
|
||||
'https://dl.example.com/安装包(正式版)',
|
||||
);
|
||||
expect(hrefOf(renderMarkdownSync('[a](url(1))'))).toBe('url(1)');
|
||||
expect(hrefOf(renderMarkdownSync('[a](url "title")'))).toBe('url');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Marked, marked, type Tokens } from 'marked';
|
||||
import markedLinkifyIt from 'marked-linkify-it';
|
||||
import remend from 'remend';
|
||||
import katex from 'katex';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -331,10 +332,15 @@ const blockMathExtension = {
|
||||
},
|
||||
};
|
||||
|
||||
const createParser = (imageMode: MarkdownImageMode) => new Marked().use({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
extensions: [inlineMathExtension, blockMathExtension],
|
||||
// marked's GFM autolink swallows CJK punctuation after a bare URL, so switch
|
||||
// to marked-linkify-it, which treats Unicode punctuation as a URL boundary.
|
||||
// Plain CJK characters right after a URL are still consumed, matching GitHub.
|
||||
const createParser = (imageMode: MarkdownImageMode) => new Marked().use(
|
||||
markedLinkifyIt({ fuzzyLink: false }),
|
||||
{
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
extensions: [inlineMathExtension, blockMathExtension],
|
||||
renderer: {
|
||||
// Assistant output is untrusted. Markdown constructs still render as HTML,
|
||||
// but raw HTML must remain visible text so it cannot introduce active DOM
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getStreamingOutputAppend, getToolOutput, renderTerminalOutput } from './toolOutput';
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
import { tryParseJsonOutput } from '../toolRenderers';
|
||||
import { parseDiffToUnified, tryParseJsonOutput } from '../toolRenderers';
|
||||
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
|
||||
import { getToolDescriptionFallback } from './toolRenderUtils';
|
||||
|
||||
@@ -42,6 +42,29 @@ describe('getToolOutput', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDiffToUnified', () => {
|
||||
test('handles a streamed diff with a bare Index header', () => {
|
||||
expect(parseDiffToUnified('Index:')).toEqual([]);
|
||||
expect(parseDiffToUnified('Index:\n@@ -1,1 +1,1 @@\n-old\n+new')).toEqual([
|
||||
{
|
||||
file: 'file',
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: 'removed', lineNumber: 1, content: 'old' },
|
||||
{ type: 'added', lineNumber: 1, content: 'new' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves spaces when extracting the indexed filename', () => {
|
||||
const [hunk] = parseDiffToUnified('Index: src/my file.ts\n@@ -1,1 +1,1 @@\n-old\n+new');
|
||||
|
||||
expect(hunk?.file).toBe('my file.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTerminalOutput', () => {
|
||||
test('renders carriage-return progress updates as their latest value', () => {
|
||||
expect(renderTerminalOutput('Downloading 10%\r\u001B[2KDownloading 90%')).toBe('Downloading 90%');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMobileAppActions } from '@/apps/mobileAppContext';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { QuestionMarkdown } from '../../QuestionMarkdown';
|
||||
import { MessageFilesDisplay } from '../../FileAttachment';
|
||||
import { getToolMetadata } from '@/lib/toolHelpers';
|
||||
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2';
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
renderTodoOutput,
|
||||
tryParseJsonOutput,
|
||||
coerceToText,
|
||||
capToolOutputText,
|
||||
} from '../toolRenderers';
|
||||
import { JsonTreeViewer } from '@/components/ui/JsonTreeViewer';
|
||||
import { JsonSummaryView } from './JsonSummaryView';
|
||||
@@ -605,11 +607,15 @@ const getToolOutputText = (
|
||||
part: ToolPartType,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string => {
|
||||
// Cap oversized payloads before JSON.parse / syntax highlighting / DOM work
|
||||
// so a single huge tool output can't trigger a V8 Zone-allocation OOM that
|
||||
// hard-crashes the renderer (issue #2265).
|
||||
const capped = capToolOutputText(output);
|
||||
if (part.tool === 'bash') {
|
||||
return output;
|
||||
return capped;
|
||||
}
|
||||
|
||||
return formatEditOutput(output, part.tool, metadata);
|
||||
return formatEditOutput(capped, part.tool, metadata);
|
||||
};
|
||||
|
||||
const StreamingPlainTextOutput: React.FC<{ output: string }> = ({ output }) => {
|
||||
@@ -1407,7 +1413,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
<div className="space-y-2">
|
||||
{parsedQA.map((qa, index) => (
|
||||
<div key={index} className="space-y-0.5">
|
||||
<div className="typography-micro text-muted-foreground">{qa.question}</div>
|
||||
<QuestionMarkdown content={qa.question} size="micro" className="text-muted-foreground" />
|
||||
<div className="typography-meta text-foreground whitespace-pre-wrap">{qa.answer}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1444,7 +1450,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
{q.header ? (
|
||||
<div className="typography-micro text-muted-foreground">{coerceToText(q.header)}</div>
|
||||
) : null}
|
||||
<div className="typography-meta text-foreground">{coerceToText(q.question)}</div>
|
||||
<QuestionMarkdown content={coerceToText(q.question)} size="meta" className="text-foreground" />
|
||||
{Array.isArray(q.options) && q.options.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{q.options.map((opt) => (
|
||||
@@ -1965,6 +1971,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
return null;
|
||||
}, [descriptionPath, normalizedPartTool, stateWithData, input]);
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
const mobileActions = useMobileAppActions();
|
||||
|
||||
const openApplyPatchFile = (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!runtime?.editor) {
|
||||
@@ -2037,6 +2044,61 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
handleMainClick(event);
|
||||
};
|
||||
|
||||
// Quick-open target for the file-link icon in the tool header. Resolves the
|
||||
// primary file path (and, for diff tools, the first changed line + diff) so
|
||||
// the user can open the file in the side panel (web/desktop) or editor
|
||||
// (VS Code) without expanding the tool card. Reuses the same path helpers as
|
||||
// handleMainClick above; the difference is the web fallback — handleMainClick
|
||||
// only opens when runtime.editor is available, this icon also falls back to
|
||||
// useUIStore.openContextFile{AtLine} so the file opens in the right pane.
|
||||
const quickOpenTarget = React.useMemo<{ absolutePath: string; line?: number; toolDiff?: string; toolName: string } | null>(() => {
|
||||
if (isTaskTool) return null;
|
||||
const toolName = normalizedPartTool || part.tool;
|
||||
const filePath = getPrimaryToolPath(toolName, input, metadata);
|
||||
if (typeof filePath !== 'string') return null;
|
||||
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
|
||||
let line: number | undefined;
|
||||
let toolDiff: string | undefined;
|
||||
if (toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch') {
|
||||
line = getFirstChangedLineFromMetadata(toolName, metadata, filePath);
|
||||
toolDiff = getPrimaryDiffFromMetadata(toolName, metadata, filePath);
|
||||
}
|
||||
return { absolutePath, line, toolDiff, toolName };
|
||||
}, [isTaskTool, normalizedPartTool, part.tool, input, metadata, currentDirectory]);
|
||||
|
||||
const openQuickTarget = () => {
|
||||
if (!quickOpenTarget) return;
|
||||
const { absolutePath, line, toolDiff, toolName } = quickOpenTarget;
|
||||
if (runtime?.editor) {
|
||||
if (runtime.runtime.isVSCode && toolDiff && (toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch')) {
|
||||
const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`;
|
||||
void runtime.editor.openDiff('', absolutePath, label, { line, patch: toolDiff });
|
||||
return;
|
||||
}
|
||||
runtime.editor.openFile(absolutePath, line);
|
||||
return;
|
||||
}
|
||||
const uiStore = useUIStore.getState();
|
||||
if (typeof line === 'number' && Number.isFinite(line)) {
|
||||
uiStore.openContextFileAtLine(currentDirectory, absolutePath, Math.max(1, Math.trunc(line)), 1);
|
||||
} else {
|
||||
uiStore.openContextFile(currentDirectory, absolutePath);
|
||||
}
|
||||
mobileActions?.openFiles();
|
||||
};
|
||||
|
||||
const handleQuickOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
openQuickTarget();
|
||||
};
|
||||
|
||||
const handleQuickOpenKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openQuickTarget();
|
||||
};
|
||||
|
||||
const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
|
||||
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
|
||||
const shouldRenderTaskSummary = useDeferredExpandedContent(isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || !!taskSessionId));
|
||||
@@ -2130,7 +2192,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<MinDurationShineText
|
||||
active={Boolean(isActive && !isError)}
|
||||
minDurationMs={300}
|
||||
@@ -2140,6 +2202,22 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
>
|
||||
{displayName}
|
||||
</MinDurationShineText>
|
||||
{quickOpenTarget ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickOpen}
|
||||
onKeyDown={handleQuickOpenKeyDown}
|
||||
className={cn(
|
||||
'flex-shrink-0 inline-flex h-4 w-4 items-center justify-center rounded transition-opacity hover:bg-[var(--surface-hover)]',
|
||||
'opacity-0 group-hover/tool:opacity-60 hover:opacity-100 focus-visible:opacity-100',
|
||||
)}
|
||||
style={{ color: 'var(--tools-icon)' }}
|
||||
title={t('chat.toolPart.openFile')}
|
||||
aria-label={t('chat.toolPart.openFile')}
|
||||
>
|
||||
<Icon name="external-link" className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{normalizedPartTool === 'bash' && typeof effectiveTimeStart === 'number' ? (
|
||||
<span className={cn('flex-shrink-0 tabular-nums text-muted-foreground/80', TOOL_ROW_DESCRIPTION_CLASS)}>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
|
||||
import { capToolOutputText, TOOL_OUTPUT_MAX_CHARS } from './toolRenderers';
|
||||
|
||||
// Regression coverage for issue #2265: the desktop renderer hard-crashes with a
|
||||
// V8 "Zone Allocation failed" OOM when a tool returns oversized external content
|
||||
// (e.g. a fetched Google Slides page with full-resolution base64 images inlined),
|
||||
// because the whole payload previously flowed through JSON.parse / syntax
|
||||
// highlighting / DOM rendering as a single unbounded JS string. capToolOutputText
|
||||
// is the bounded size guard that runs before any of that work.
|
||||
describe('capToolOutputText (issue #2265 renderer OOM guard)', () => {
|
||||
test('exposes a sane positive default cap', () => {
|
||||
expect(typeof TOOL_OUTPUT_MAX_CHARS).toBe('number');
|
||||
expect(TOOL_OUTPUT_MAX_CHARS).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('returns short output unchanged', () => {
|
||||
const output = 'hello world';
|
||||
expect(capToolOutputText(output)).toBe(output);
|
||||
});
|
||||
|
||||
test('returns output at exactly the cap unchanged', () => {
|
||||
const output = 'a'.repeat(TOOL_OUTPUT_MAX_CHARS);
|
||||
expect(capToolOutputText(output)).toBe(output);
|
||||
expect(capToolOutputText(output).length).toBe(TOOL_OUTPUT_MAX_CHARS);
|
||||
});
|
||||
|
||||
test('caps oversized output and never emits the full string', () => {
|
||||
const oversized = 'x'.repeat(TOOL_OUTPUT_MAX_CHARS + 10_000);
|
||||
const capped = capToolOutputText(oversized);
|
||||
|
||||
// The pathological full-size string must not survive to the renderer.
|
||||
expect(capped.length).toBeLessThan(oversized.length);
|
||||
// Head of the payload is preserved for the user.
|
||||
expect(capped.startsWith('x'.repeat(1000))).toBe(true);
|
||||
// A truncation notice is appended so the truncation is visible.
|
||||
expect(capped).toContain('output truncated');
|
||||
expect(capped).toContain('10000 more characters');
|
||||
});
|
||||
|
||||
test('honors a custom cap', () => {
|
||||
const output = 'abcdefghij'; // 10 chars
|
||||
const capped = capToolOutputText(output, 4);
|
||||
expect(capped.startsWith('abcd')).toBe(true);
|
||||
expect(capped).toContain('output truncated');
|
||||
// Only the first 4 chars of the original body are retained.
|
||||
expect(capped).not.toContain('efghij');
|
||||
});
|
||||
|
||||
test('simulated large webfetch payload is bounded well below original size', () => {
|
||||
// ~6MB single string, matching the 5MB-20MB Zone-allocation trigger range
|
||||
// described in the issue (a Slides page with embedded base64 images).
|
||||
const base64Blob = 'QUJD'.repeat(1_500_000); // 6,000,000 chars
|
||||
const capped = capToolOutputText(base64Blob);
|
||||
|
||||
expect(base64Blob.length).toBeGreaterThan(5_000_000);
|
||||
expect(capped.length).toBeLessThan(TOOL_OUTPUT_MAX_CHARS + 256);
|
||||
expect(capped).toContain('renderer from running out of memory');
|
||||
});
|
||||
|
||||
test('non-string input is returned unchanged (defensive)', () => {
|
||||
// @ts-expect-error verifying runtime robustness against non-string inputs
|
||||
expect(capToolOutputText(undefined)).toBeUndefined();
|
||||
// @ts-expect-error verifying runtime robustness against non-string inputs
|
||||
expect(capToolOutputText(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,28 @@ export const coerceToText = (value: unknown, fallback = ''): string => {
|
||||
}
|
||||
};
|
||||
|
||||
// Guards the renderer process against V8 "Zone Allocation failed" OOM crashes
|
||||
// (issue #2265). When a tool returns oversized external content — e.g. a fetched
|
||||
// web page with full-resolution base64 images inlined — the entire payload flows
|
||||
// through this module as a single JS string that is JSON.parsed, syntax
|
||||
// highlighted, and attached to the DOM. A large enough single string exceeds
|
||||
// V8's Zone allocator and hard-crashes the renderer before any virtualization or
|
||||
// CSS clip can help. Capping the string length before that work happens keeps a
|
||||
// useful head of the output while preventing the pathological allocation.
|
||||
export const TOOL_OUTPUT_MAX_CHARS = 512 * 1024;
|
||||
|
||||
export const capToolOutputText = (
|
||||
output: string,
|
||||
maxChars: number = TOOL_OUTPUT_MAX_CHARS,
|
||||
): string => {
|
||||
if (typeof output !== 'string' || output.length <= maxChars) {
|
||||
return output;
|
||||
}
|
||||
const omitted = output.length - maxChars;
|
||||
const notice = `\n\n… [output truncated: ${omitted} more characters not shown to prevent the renderer from running out of memory]`;
|
||||
return output.slice(0, maxChars) + notice;
|
||||
};
|
||||
|
||||
const hasLspDiagnostics = (output: string): boolean => {
|
||||
if (!output) return false;
|
||||
return output.includes('<diagnostics')
|
||||
@@ -575,7 +597,7 @@ export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => {
|
||||
|
||||
if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) {
|
||||
if (line.startsWith('Index:')) {
|
||||
currentFile = line.split(' ')[1].split('/').pop() || 'file';
|
||||
currentFile = line.slice('Index:'.length).trim().split('/').pop() || 'file';
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
|
||||
@@ -943,7 +943,12 @@ export const ContextPanel: React.FC = () => {
|
||||
: activeTab?.mode === 'notes'
|
||||
? <ProjectContextPanel />
|
||||
: activeTab?.mode === 'plan'
|
||||
? <React.Suspense fallback={null}><PlanView targetPath={activeTab.targetPath} projectPlanId={activeTab.projectPlanId} /></React.Suspense>
|
||||
? <React.Suspense fallback={null}><PlanView
|
||||
targetPath={activeTab.targetPath}
|
||||
savedProjectPlan={activeTab.projectPlanId && activeTab.projectPlanRef
|
||||
? { projectRef: activeTab.projectPlanRef, planId: activeTab.projectPlanId }
|
||||
: null}
|
||||
/></React.Suspense>
|
||||
: null;
|
||||
|
||||
const browserTabs = React.useMemo(
|
||||
|
||||
@@ -6,63 +6,53 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { formatDirectoryName } from '@/lib/utils';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
import { useProjectContextOwner } from '@/hooks/useProjectContextOwner';
|
||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const ProjectContextPanel: React.FC<{
|
||||
onActionComplete?: () => void;
|
||||
onOpenPlan?: (plan: { id: string; title: string }) => void;
|
||||
onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
|
||||
}> = ({ onActionComplete, onOpenPlan }) => {
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const { t } = useI18n();
|
||||
const gitDirectories = useGitStore((state) => state.directories);
|
||||
const isChatContext = useSessionUIStore((state) => (
|
||||
state.newSessionDraft.open
|
||||
? state.newSessionDraft.target === 'chat'
|
||||
: isChatDirectoryPath(state.currentSessionDirectory)
|
||||
));
|
||||
const chatSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||
const chatsRoot = getChatsRootFromDirectory(chatSessionDirectory) ?? getChatsRootForHome(homeDirectory);
|
||||
|
||||
const activeProject = React.useMemo(() => {
|
||||
if (isChatContext) return null;
|
||||
if (activeProjectId) {
|
||||
return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null;
|
||||
}
|
||||
return projects[0] ?? null;
|
||||
}, [activeProjectId, isChatContext, projects]);
|
||||
// One owner decision shared with the panel, agent memory, and PlanView:
|
||||
// chats resolve to the Chats owner, worktrees to their project, and an
|
||||
// unrecognized directory owns nothing (null) rather than borrowing
|
||||
// whichever project happens to be active.
|
||||
const projectRef = useProjectContextOwner(chatSessionDirectory);
|
||||
|
||||
const projectRef = React.useMemo(() => {
|
||||
if (isChatContext && chatsRoot) {
|
||||
return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot };
|
||||
}
|
||||
if (!activeProject) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: activeProject.id,
|
||||
path: activeProject.path,
|
||||
};
|
||||
}, [activeProject, chatsRoot, isChatContext]);
|
||||
// Display-only lookup: a user-renamed project label wins over the directory
|
||||
// name. The owner decision stays with the hook — this must not reintroduce
|
||||
// a fallback.
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const labeledProject = React.useMemo(
|
||||
() => (projectRef ? projects.find((project) => project.id === projectRef.id) ?? null : null),
|
||||
[projectRef, projects],
|
||||
);
|
||||
|
||||
const projectLabel = React.useMemo(() => {
|
||||
if (isChatContext) return t('sessions.sidebar.activity.chatsTitle');
|
||||
if (!activeProject) {
|
||||
if (!projectRef) {
|
||||
return null;
|
||||
}
|
||||
return activeProject.label?.trim()
|
||||
|| formatDirectoryName(activeProject.path, homeDirectory)
|
||||
|| activeProject.path;
|
||||
}, [activeProject, homeDirectory, isChatContext, t]);
|
||||
if (projectRef.id === CHAT_DRAFT_PROJECT_ID) {
|
||||
return t('sessions.sidebar.activity.chatsTitle');
|
||||
}
|
||||
return labeledProject?.label?.trim()
|
||||
|| formatDirectoryName(projectRef.path, homeDirectory)
|
||||
|| projectRef.path;
|
||||
}, [homeDirectory, labeledProject, projectRef, t]);
|
||||
|
||||
const canCreateWorktree = React.useMemo(() => {
|
||||
if (!activeProject) {
|
||||
if (!projectRef || projectRef.id === CHAT_DRAFT_PROJECT_ID) {
|
||||
return false;
|
||||
}
|
||||
return gitDirectories.get(activeProject.path)?.isGitRepo === true;
|
||||
}, [activeProject, gitDirectories]);
|
||||
return gitDirectories.get(projectRef.path)?.isGitRepo === true;
|
||||
}, [gitDirectories, projectRef]);
|
||||
|
||||
return (
|
||||
/* The panel scrolls its own tab content; a scroller here would nest. */
|
||||
|
||||
@@ -690,7 +690,9 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
onMouseMove={handleMouseActivity}
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1.5 rounded-md typography-meta flex items-center gap-2 cursor-pointer',
|
||||
!disabled && (isHighlighted ? 'bg-interactive-selection' : 'hover:bg-interactive-hover/50'),
|
||||
!disabled && (isHighlighted
|
||||
? 'bg-interactive-selection text-interactive-selection-foreground'
|
||||
: 'hover:bg-interactive-hover/50'),
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
rowClassName,
|
||||
)}
|
||||
@@ -703,9 +705,9 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
) : null}
|
||||
{showProviderLogo ? <ProviderLogo providerId={entry.providerID} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
|
||||
<span className="font-medium truncate">{getModelDisplayName(entry.model)}</span>
|
||||
{contextTokens ? <span className="typography-micro text-muted-foreground flex-shrink-0">{contextTokens}</span> : null}
|
||||
{contextTokens ? <span className={cn('typography-micro flex-shrink-0', isHighlighted ? 'text-interactive-selection-foreground/70' : 'text-muted-foreground')}>{contextTokens}</span> : null}
|
||||
</div>
|
||||
{count > 0 ? <span className="typography-micro text-muted-foreground flex-shrink-0">x{count}</span> : null}
|
||||
{count > 0 ? <span className={cn('typography-micro flex-shrink-0', isHighlighted ? 'text-interactive-selection-foreground/70' : 'text-muted-foreground')}>x{count}</span> : null}
|
||||
{renderRowEnd?.(entry, { isHighlighted, isSelected })}
|
||||
{isSelected ? <Icon name="check" className="h-4 w-4 text-primary flex-shrink-0" /> : null}
|
||||
{onToggleFavorite ? (
|
||||
|
||||
@@ -32,7 +32,6 @@ import { startDesktopWindowDrag } from '@/lib/desktopNative';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
const MAX_MODELS_PER_GROUP = 5;
|
||||
|
||||
interface MultiRunAttachedFile {
|
||||
id: string;
|
||||
@@ -727,7 +726,6 @@ const RunGroupCard: React.FC<RunGroupCardProps> = ({
|
||||
const snippetRef = React.useRef<SnippetAutocompleteHandle>(null);
|
||||
|
||||
const handleAddModel = React.useCallback((model: ModelSelectionWithId) => {
|
||||
if (group.models.length >= MAX_MODELS_PER_GROUP) return;
|
||||
onUpdate(group.id, { models: [...group.models, model] });
|
||||
}, [group.id, group.models, onUpdate]);
|
||||
|
||||
@@ -987,7 +985,7 @@ const RunGroupCard: React.FC<RunGroupCardProps> = ({
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel
|
||||
required
|
||||
info={<InfoTip>{t('multirun.launcher.models.info', { max: MAX_MODELS_PER_GROUP })}</InfoTip>}
|
||||
info={<InfoTip>{t('multirun.launcher.models.info')}</InfoTip>}
|
||||
>
|
||||
{t('multirun.launcher.models.label')}
|
||||
</FieldLabel>
|
||||
@@ -997,7 +995,6 @@ const RunGroupCard: React.FC<RunGroupCardProps> = ({
|
||||
onRemove={handleRemoveModel}
|
||||
onUpdate={handleUpdateModel}
|
||||
minModels={1}
|
||||
maxModels={MAX_MODELS_PER_GROUP}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
type ClickEvent = { stopPropagation: () => void };
|
||||
type ClickHandler = (event: ClickEvent) => void;
|
||||
type ChildrenProps = { children?: React.ReactNode };
|
||||
type ClickableProps = ChildrenProps & { onClick?: ClickHandler };
|
||||
type TriggerProps = ChildrenProps & { render?: React.ReactNode };
|
||||
|
||||
interface AgentDraftSnapshot {
|
||||
name: string;
|
||||
scope: string;
|
||||
description?: string;
|
||||
model?: string | null;
|
||||
variant?: string;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
prompt?: string;
|
||||
mode?: string;
|
||||
permission?: Record<string, string>;
|
||||
disable?: boolean;
|
||||
}
|
||||
|
||||
interface AgentRecord {
|
||||
name: string;
|
||||
description: string;
|
||||
model: { providerID: string; modelID: string };
|
||||
variant: string;
|
||||
temperature: number;
|
||||
topP: number;
|
||||
prompt: string;
|
||||
mode: string;
|
||||
permission: Array<{ permission: string; pattern: string; action: 'allow' | 'ask' | 'deny' }>;
|
||||
scope: string;
|
||||
disable: boolean;
|
||||
}
|
||||
|
||||
interface AgentStoreState {
|
||||
selectedAgentName: string | null;
|
||||
agents: AgentRecord[];
|
||||
setAgentDraft: (draft: AgentDraftSnapshot) => void;
|
||||
setSelectedAgent: (name: string) => void;
|
||||
createAgent: () => Promise<{ ok: boolean }>;
|
||||
deleteAgent: () => Promise<{ ok: boolean }>;
|
||||
loadAgents: () => Promise<void>;
|
||||
}
|
||||
|
||||
const sourceAgent: AgentRecord = {
|
||||
name: 'writer',
|
||||
description: 'Writes concise documentation',
|
||||
model: { providerID: 'openai', modelID: 'gpt-4.1' },
|
||||
variant: 'fast',
|
||||
temperature: 0.4,
|
||||
topP: 0.8,
|
||||
prompt: 'Write clear documentation.',
|
||||
mode: 'subagent',
|
||||
permission: [{ permission: 'bash', pattern: '*', action: 'ask' }],
|
||||
scope: 'project',
|
||||
disable: true,
|
||||
};
|
||||
|
||||
let recordedDraft: AgentDraftSnapshot | null = null;
|
||||
let selectedAgentName: string | null = null;
|
||||
let duplicateMenuClick: ClickHandler | null = null;
|
||||
let mobileDevice = true;
|
||||
|
||||
const agentStore: AgentStoreState = {
|
||||
selectedAgentName: null,
|
||||
agents: [sourceAgent],
|
||||
setAgentDraft: (draft) => {
|
||||
recordedDraft = draft;
|
||||
},
|
||||
setSelectedAgent: (name) => {
|
||||
selectedAgentName = name;
|
||||
},
|
||||
createAgent: async () => ({ ok: true }),
|
||||
deleteAgent: async () => ({ ok: true }),
|
||||
loadAgents: async () => {},
|
||||
};
|
||||
|
||||
function useAgentsStore<Selected>(selector: (state: AgentStoreState) => Selected): Selected {
|
||||
return selector(agentStore);
|
||||
}
|
||||
|
||||
function useShallow<Selector>(selector: Selector): Selector {
|
||||
return selector;
|
||||
}
|
||||
|
||||
mock.module('@/components/ui/button', () => ({
|
||||
Button: ({ children, onClick }: ClickableProps) => <button onClick={onClick}>{children}</button>,
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui/input', () => ({
|
||||
Input: () => <input />,
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui', () => ({
|
||||
toast: { error: () => {}, success: () => {}, warning: () => {} },
|
||||
}));
|
||||
|
||||
mock.module('@/lib/device', () => ({
|
||||
isMobileDeviceViaCSS: () => mobileDevice,
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: ChildrenProps) => <>{children}</>,
|
||||
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: ChildrenProps) => <div>{children}</div>,
|
||||
DialogFooter: ({ children }: ChildrenProps) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: ChildrenProps) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: ChildrenProps) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children, onClick }: ClickableProps) => {
|
||||
if (React.Children.toArray(children).includes('Duplicate')) {
|
||||
duplicateMenuClick = onClick ?? null;
|
||||
}
|
||||
return <button onClick={onClick}>{children}</button>;
|
||||
},
|
||||
DropdownMenuTrigger: ({ children }: ChildrenProps) => <>{children}</>,
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui/context-menu', () => ({
|
||||
ContextMenu: ({ children }: ChildrenProps) => <>{children}</>,
|
||||
ContextMenuContent: ({ children }: ChildrenProps) => <div>{children}</div>,
|
||||
ContextMenuItem: ({ children }: ChildrenProps) => <div>{children}</div>,
|
||||
ContextMenuTrigger: ({ children, render }: TriggerProps) => <>{render}{children}</>,
|
||||
}));
|
||||
|
||||
mock.module('@/hooks/useSettingsDirectory', () => ({
|
||||
useSettingsDirectory: () => '/workspace',
|
||||
}));
|
||||
|
||||
mock.module('@/stores/useAgentsStore', () => ({
|
||||
useAgentsStore,
|
||||
selectAgentsForDirectory: (state: AgentStoreState) => state.agents,
|
||||
isAgentBuiltIn: () => false,
|
||||
isAgentHidden: () => false,
|
||||
}));
|
||||
|
||||
mock.module('zustand/react/shallow', () => ({ useShallow }));
|
||||
|
||||
mock.module('@/lib/utils', () => ({
|
||||
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui/ScrollableOverlay', () => ({
|
||||
ScrollableOverlay: ({ children }: ChildrenProps) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
mock.module('@/components/sections/shared/SettingsProjectSelector', () => ({
|
||||
SettingsProjectSelector: () => null,
|
||||
}));
|
||||
|
||||
mock.module('@/components/sections/shared/SidebarGroup', () => ({
|
||||
SidebarGroup: ({ children }: ChildrenProps) => <>{children}</>,
|
||||
}));
|
||||
|
||||
mock.module('@/components/icon/Icon', () => ({
|
||||
Icon: () => null,
|
||||
}));
|
||||
|
||||
mock.module('@/lib/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => (key === 'settings.common.actions.duplicate' ? 'Duplicate' : key),
|
||||
}),
|
||||
}));
|
||||
|
||||
mock.module('@/components/sections/shared/SettingsSection', () => ({
|
||||
SETTINGS_PANEL_TITLE_CLASS: '',
|
||||
}));
|
||||
|
||||
const { AgentsSidebar } = await import('./AgentsSidebar');
|
||||
|
||||
function getDuplicateMenuClick(): ClickHandler {
|
||||
if (!duplicateMenuClick) {
|
||||
throw new Error('Expected the duplicate action to be rendered');
|
||||
}
|
||||
return duplicateMenuClick;
|
||||
}
|
||||
|
||||
describe('AgentsSidebar duplicate action', () => {
|
||||
test('notifies the mobile split-view parent once after preparing a prefilled agent draft', () => {
|
||||
recordedDraft = null;
|
||||
selectedAgentName = null;
|
||||
duplicateMenuClick = null;
|
||||
mobileDevice = true;
|
||||
let mobileTransitionCount = 0;
|
||||
|
||||
renderToStaticMarkup(
|
||||
<AgentsSidebar onItemSelect={() => { mobileTransitionCount += 1; }} />,
|
||||
);
|
||||
|
||||
getDuplicateMenuClick()({ stopPropagation: () => {} });
|
||||
|
||||
expect(recordedDraft).toEqual({
|
||||
name: 'writer-copy',
|
||||
scope: 'project',
|
||||
description: 'Writes concise documentation',
|
||||
model: 'openai/gpt-4.1',
|
||||
variant: 'fast',
|
||||
temperature: 0.4,
|
||||
top_p: 0.8,
|
||||
prompt: 'Write clear documentation.',
|
||||
mode: 'subagent',
|
||||
permission: { bash: 'ask' },
|
||||
disable: true,
|
||||
});
|
||||
expect(selectedAgentName).toBe('writer-copy');
|
||||
expect(mobileTransitionCount).toBe(1);
|
||||
});
|
||||
|
||||
test('does not require a mobile transition callback on desktop', () => {
|
||||
recordedDraft = null;
|
||||
selectedAgentName = null;
|
||||
duplicateMenuClick = null;
|
||||
mobileDevice = false;
|
||||
|
||||
renderToStaticMarkup(<AgentsSidebar />);
|
||||
|
||||
getDuplicateMenuClick()({ stopPropagation: () => {} });
|
||||
expect(selectedAgentName).toBe('writer-copy');
|
||||
});
|
||||
});
|
||||
@@ -250,6 +250,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
disable: draftAgent.disable,
|
||||
});
|
||||
setSelectedAgent(newName);
|
||||
onItemSelect?.();
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n";
|
||||
import { useGitHubAuthStore } from "@/stores/useGitHubAuthStore";
|
||||
|
||||
import { GitHubSettings } from "./GitHubSettings";
|
||||
|
||||
const serverAuthState = useGitHubAuthStore.getInitialState();
|
||||
|
||||
const resetServerAuthState = () => {
|
||||
Object.assign(serverAuthState, {
|
||||
status: null,
|
||||
isLoading: false,
|
||||
hasChecked: false,
|
||||
});
|
||||
};
|
||||
|
||||
const renderSettings = () =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<GitHubSettings />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("GitHubSettings", () => {
|
||||
beforeEach(resetServerAuthState);
|
||||
afterEach(resetServerAuthState);
|
||||
|
||||
test("stays hidden during the initial auth status load", () => {
|
||||
serverAuthState.isLoading = true;
|
||||
|
||||
expect(renderSettings()).toBe("");
|
||||
});
|
||||
|
||||
test("stays mounted while a checked status is refreshing, then shows reconnect state", () => {
|
||||
Object.assign(serverAuthState, {
|
||||
status: {
|
||||
connected: true,
|
||||
user: { login: "octocat" },
|
||||
},
|
||||
isLoading: true,
|
||||
hasChecked: true,
|
||||
});
|
||||
|
||||
const refreshingMarkup = renderSettings();
|
||||
expect(refreshingMarkup).toContain("octocat");
|
||||
expect(refreshingMarkup).toContain("Disconnect");
|
||||
|
||||
Object.assign(serverAuthState, {
|
||||
status: { connected: false },
|
||||
isLoading: false,
|
||||
hasChecked: true,
|
||||
});
|
||||
|
||||
const disconnectedMarkup = renderSettings();
|
||||
expect(disconnectedMarkup).toContain("Not Connected");
|
||||
expect(disconnectedMarkup).toContain("Connect GitHub");
|
||||
});
|
||||
});
|
||||
@@ -256,7 +256,7 @@ export const GitHubSettings: React.FC = () => {
|
||||
}
|
||||
}, [runtimeGitHub, setStatus, t]);
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading && !hasChecked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ const getFallbackInstallCommand = (provider: string, platform = getClientInstall
|
||||
if (platform === 'darwin') {
|
||||
return 'brew install cloudflared';
|
||||
}
|
||||
return 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/';
|
||||
return 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/';
|
||||
};
|
||||
|
||||
const createTunnelDependencyInstallInfo = (provider: string, checkData?: TunnelCheckResponse): TunnelDependencyInstallInfo => {
|
||||
|
||||
@@ -75,13 +75,13 @@ export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
|
||||
hasTitleChrome ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{titleLeading}
|
||||
<h1 className={cn(SETTINGS_PAGE_TITLE_CLASS, 'min-w-0 truncate')}>{title}</h1>
|
||||
<h1 data-settings-page-heading tabIndex={-1} className={cn(SETTINGS_PAGE_TITLE_CLASS, 'min-w-0 truncate')}>{title}</h1>
|
||||
{/* A status badge carries a fixed word; compressing it
|
||||
wraps the text inside its own pill. */}
|
||||
<span className="shrink-0">{titleAccessory}</span>
|
||||
</div>
|
||||
) : (
|
||||
<h1 className={SETTINGS_PAGE_TITLE_CLASS}>{title}</h1>
|
||||
<h1 data-settings-page-heading tabIndex={-1} className={SETTINGS_PAGE_TITLE_CLASS}>{title}</h1>
|
||||
)
|
||||
) : (
|
||||
title
|
||||
|
||||
@@ -359,7 +359,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const canSubmitClone = canAddProject && cloneRemoteUrl.trim().length > 0;
|
||||
const highlightedRow = rows[highlightedIndex] ?? null;
|
||||
const hasHighlightedBrowseItem = Boolean(
|
||||
highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled))
|
||||
highlightedRow && (highlightedRow.type === 'up' || highlightedRow.type === 'directory')
|
||||
);
|
||||
const submitModifierLabel = formatShortcutForDisplay('mod');
|
||||
const submitActionLabel = isAlreadyAdded
|
||||
@@ -414,11 +414,11 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
handleClose();
|
||||
}, [handleClose, isMobile, openNewSessionDraft, setSessionSwitcherOpen]);
|
||||
|
||||
const handleQuickAdd = React.useCallback((event: React.MouseEvent, path: string) => {
|
||||
const handleQuickAdd = React.useCallback(async (event: React.MouseEvent, path: string) => {
|
||||
event.stopPropagation();
|
||||
const normalized = normalizeDirectoryPath(path);
|
||||
if (normalized && addedProjectPaths.has(normalized)) return;
|
||||
const project = addProject(path);
|
||||
const project = await addProject(path);
|
||||
if (!project) {
|
||||
toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), {
|
||||
description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'),
|
||||
@@ -452,7 +452,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
} else if (shouldCreateSelection) {
|
||||
await opencodeClient.createDirectory(target, { asProject: true });
|
||||
}
|
||||
const project = addProject(selectedTarget);
|
||||
const project = await addProject(selectedTarget);
|
||||
if (!project) {
|
||||
toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), {
|
||||
description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'),
|
||||
@@ -483,7 +483,6 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
if (row.path) browseToDisplayPath(row.path);
|
||||
return;
|
||||
}
|
||||
if (row.disabled) return;
|
||||
browseToEntry(row);
|
||||
}, [browseToDisplayPath, browseToEntry]);
|
||||
|
||||
@@ -662,7 +661,6 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
disabled={row.type === 'directory' && row.disabled}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => executeRow(row)}
|
||||
@@ -670,7 +668,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg 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-selection text-interactive-selection-foreground',
|
||||
!isActive && 'hover:bg-interactive-hover/50',
|
||||
row.type === 'directory' && row.disabled && 'cursor-not-allowed opacity-45 hover:bg-transparent'
|
||||
row.type === 'directory' && row.disabled && 'opacity-45'
|
||||
)}
|
||||
>
|
||||
{row.type === 'up' ? (
|
||||
|
||||
@@ -1207,10 +1207,10 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingBranchRankedGroups.otherLocal.length > 0 && (
|
||||
{!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="typography-small font-semibold text-foreground px-2">
|
||||
{hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
|
||||
{t('session.newWorktree.localBranches')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{existingBranchRankedGroups.otherLocal.map((branch) => (
|
||||
@@ -1239,10 +1239,10 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingBranchRankedGroups.otherRemote.length > 0 && (
|
||||
{!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="typography-small font-semibold text-foreground px-2">
|
||||
{hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
|
||||
{t('session.newWorktree.remoteBranches')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{existingBranchRankedGroups.otherRemote.map((branch) => (
|
||||
@@ -1466,10 +1466,10 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceBranchRankedGroups.otherLocal.length > 0 && (
|
||||
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="typography-small font-semibold text-foreground px-2">
|
||||
{hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
|
||||
{t('session.newWorktree.localBranches')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{sourceBranchRankedGroups.otherLocal.map((branch) => (
|
||||
@@ -1493,10 +1493,10 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceBranchRankedGroups.otherRemote.length > 0 && (
|
||||
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="typography-small font-semibold text-foreground px-2">
|
||||
{hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
|
||||
{t('session.newWorktree.remoteBranches')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{sourceBranchRankedGroups.otherRemote.map((branch) => (
|
||||
@@ -1675,10 +1675,9 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingBranchRankedGroups.otherLocal.length > 0 && (
|
||||
{!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
|
||||
<>
|
||||
{hasExistingBranchQuery && <CommandSeparator />}
|
||||
<CommandGroup heading={hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}>
|
||||
<CommandGroup heading={t('session.newWorktree.localBranches')}>
|
||||
{existingBranchRankedGroups.otherLocal.map((branch) => (
|
||||
<CommandItem
|
||||
key={`local-${branch}`}
|
||||
@@ -1700,12 +1699,12 @@ export function NewWorktreeDialog({
|
||||
</>
|
||||
)}
|
||||
|
||||
{existingBranchRankedGroups.otherRemote.length > 0 && (
|
||||
{!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
|
||||
<>
|
||||
{(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && (
|
||||
{existingBranchRankedGroups.otherLocal.length > 0 && (
|
||||
<CommandSeparator />
|
||||
)}
|
||||
<CommandGroup heading={hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}>
|
||||
<CommandGroup heading={t('session.newWorktree.remoteBranches')}>
|
||||
{existingBranchRankedGroups.otherRemote.map((branch) => (
|
||||
<CommandItem
|
||||
key={`remote-${branch}`}
|
||||
@@ -1914,10 +1913,9 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceBranchRankedGroups.otherLocal.length > 0 && (
|
||||
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
|
||||
<>
|
||||
{hasSourceBranchQuery && <CommandSeparator />}
|
||||
<CommandGroup heading={hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}>
|
||||
<CommandGroup heading={t('session.newWorktree.localBranches')}>
|
||||
{sourceBranchRankedGroups.otherLocal.map((branch) => (
|
||||
<CommandItem
|
||||
key={`local-${branch}`}
|
||||
@@ -1934,12 +1932,12 @@ export function NewWorktreeDialog({
|
||||
</>
|
||||
)}
|
||||
|
||||
{sourceBranchRankedGroups.otherRemote.length > 0 && (
|
||||
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
|
||||
<>
|
||||
{(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && (
|
||||
{sourceBranchRankedGroups.otherLocal.length > 0 && (
|
||||
<CommandSeparator />
|
||||
)}
|
||||
<CommandGroup heading={hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}>
|
||||
<CommandGroup heading={t('session.newWorktree.remoteBranches')}>
|
||||
{sourceBranchRankedGroups.otherRemote.map((branch) => (
|
||||
<CommandItem
|
||||
key={`remote-${branch}`}
|
||||
|
||||
@@ -60,6 +60,18 @@ 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.
|
||||
|
||||
The panel owns the only source of truth for which project a plan belongs to,
|
||||
and it never lets the editor guess. `PlanView` receives the owner as
|
||||
`savedProjectPlan={{ projectRef, planId }}` — load and autosave both go to that
|
||||
exact project. An earlier version let the editor re-derive the project from the
|
||||
current directory, which silently opened an empty document for plans stored
|
||||
under the managed Chats owner (`openchamber:chats`), for plans opened from a
|
||||
worktree the directory lookup missed, and for plan tabs restored after a
|
||||
reload. Persisted plan tabs carry `projectPlanRef` for the same reason; a saved-plan
|
||||
tab persisted with an id but no owner is dropped on rehydrate rather than
|
||||
reopened against a guessed project. A plain session plan tab legitimately has
|
||||
neither an id nor an owner and is kept.
|
||||
|
||||
## Pins belong to one session
|
||||
|
||||
Notes and plans are project data, but attaching one writes its id to the current
|
||||
@@ -106,10 +118,16 @@ its own tool. It feeds this panel only — what a session is told about memory i
|
||||
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.
|
||||
`useProjectContextOwner` is the client authority shared by this panel and the
|
||||
memory sync. It resolves managed chat directories to the Chats root and a
|
||||
worktree to its project before either consumer touches a store. The server uses
|
||||
`agent-memory/project-resolution` for the same worktree rule. Keying by a
|
||||
worktree session directory would file memories under a project nothing reads.
|
||||
|
||||
Project memory is rendered only when the store's `projectPath` matches the
|
||||
panel owner. An owner switch hides the previous project's entries before the
|
||||
new request starts. A failed request marks the new owner unavailable instead of
|
||||
presenting that hidden list as authoritative empty memory.
|
||||
|
||||
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
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 { selectProjectMemoryForPath, useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
/**
|
||||
@@ -160,7 +160,7 @@ export const MemorySection: React.FC<{
|
||||
const [expandedId, setExpandedId] = React.useState<string | null>(null);
|
||||
|
||||
const globalEntries = useAgentMemoryStore((state) => state.global);
|
||||
const projectEntries = useAgentMemoryStore((state) => state.project);
|
||||
const projectEntries = useAgentMemoryStore((state) => selectProjectMemoryForPath(state, projectPath));
|
||||
const globalFailed = useAgentMemoryStore((state) => state.globalFailed);
|
||||
const projectFailed = useAgentMemoryStore((state) => state.projectFailed);
|
||||
const deleteEntry = useAgentMemoryStore((state) => state.deleteEntry);
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { parsePlanMarkdown, resolveProjectContextId, type ProjectPlanLink, type ProjectRef } from '@/lib/projectContextApi';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -23,8 +23,9 @@ export const PlansSection: React.FC<{
|
||||
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;
|
||||
/** Hosts without a ContextPanel (mobile) render their own plan viewer. The
|
||||
plan carries its owner so the host viewer never guesses the project. */
|
||||
onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
|
||||
pinnedPlanIds: ReadonlySet<string>;
|
||||
onTogglePinned: (planId: string, pinned: boolean) => Promise<boolean>;
|
||||
}> = ({ projectRef, plans, query, onOpenPlan, pinnedPlanIds, onTogglePinned }) => {
|
||||
@@ -155,7 +156,7 @@ export const PlansSection: React.FC<{
|
||||
const handleOpenPlan = React.useCallback(
|
||||
(plan: ProjectPlanLink) => {
|
||||
if (onOpenPlan) {
|
||||
onOpenPlan({ id: plan.id, title: plan.title });
|
||||
onOpenPlan({ id: plan.id, title: plan.title, projectRef });
|
||||
return;
|
||||
}
|
||||
const panelDirectory = currentDirectory?.trim() || projectRef.path.trim();
|
||||
@@ -165,11 +166,15 @@ export const PlansSection: React.FC<{
|
||||
openContextPanelTab(panelDirectory, {
|
||||
mode: 'plan',
|
||||
projectPlanId: plan.id,
|
||||
dedupeKey: `plan:${plan.id}`,
|
||||
projectPlanRef: projectRef,
|
||||
// Storage identity is derived from the project path, not the settings
|
||||
// id, so the tab identity uses the same derivation. Two projects
|
||||
// sharing a settings id but not a path must not merge plan tabs.
|
||||
dedupeKey: `plan:${resolveProjectContextId(projectRef)}:${plan.id}`,
|
||||
label: plan.title,
|
||||
});
|
||||
},
|
||||
[currentDirectory, onOpenPlan, openContextPanelTab, projectRef.path]
|
||||
[currentDirectory, onOpenPlan, openContextPanelTab, projectRef]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { selectProjectMemoryForPath, useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { countHighlightedMemories, memoryViewKey } from '@/lib/agentMemoryBadges';
|
||||
import { EMPTY_PROJECT_CONTEXT_ENTRY, useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -29,8 +29,9 @@ interface ProjectNotesTodoPanelProps {
|
||||
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;
|
||||
panel tab — hosts without ContextPanel (mobile) render their own viewer.
|
||||
The plan carries its owner so the host's viewer cannot guess wrong. */
|
||||
onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -133,7 +134,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
const memoryDisabledByServer = useAgentMemoryStore((state) => state.disabled);
|
||||
const memoryVisible = memoryEnabled && !memoryDisabledByServer;
|
||||
const globalMemory = useAgentMemoryStore((state) => state.global);
|
||||
const projectMemory = useAgentMemoryStore((state) => state.project);
|
||||
const projectMemory = useAgentMemoryStore(
|
||||
(state) => selectProjectMemoryForPath(state, projectRef?.path ?? null),
|
||||
);
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const storedTab = useUIStore((state) => state.projectContextTab);
|
||||
@@ -499,10 +502,10 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'plans' && openPlan ? (
|
||||
{activeTab === 'plans' && openPlan && projectRef ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<PlanView
|
||||
projectPlanId={openPlan.id}
|
||||
savedProjectPlan={{ projectRef, planId: openPlan.id }}
|
||||
onNavigatedToChat={() => setOpenPlan(null)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MEMORY_LIMITS } from '@/stores/types/sessionTypes';
|
||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { getBackgroundTrimLimit } from '@/stores/types/sessionTypes';
|
||||
import { getStreamPerfSnapshot, getVsCodeStreamPerfSnapshot, resetStreamPerf, type StreamPerfSnapshot } from '@/stores/utils/streamDebug';
|
||||
import { getRequestsInFlightSnapshot, resetRequestsInFlight, type RequestsInFlightSnapshot } from '@/stores/utils/requestsInFlight';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
@@ -18,7 +19,7 @@ interface DebugPanelProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type DebugTab = 'memory' | 'streaming';
|
||||
type DebugTab = 'memory' | 'streaming' | 'requests';
|
||||
|
||||
const formatDuration = (durationMs: number): string => {
|
||||
if (durationMs < 1000) {
|
||||
@@ -35,6 +36,10 @@ const formatDuration = (durationMs: number): string => {
|
||||
return `${minutes}m ${remainderSeconds}s`;
|
||||
};
|
||||
|
||||
// Fixed-width seconds format ("XX.XX s") for the percentile series so the
|
||||
// legend/labels don't jitter as values change. Pair with `tabular-nums`.
|
||||
const formatSeconds = (durationMs: number): string => `${(durationMs / 1000).toFixed(2)} s`;
|
||||
|
||||
const MetricCard: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => {
|
||||
return (
|
||||
<div
|
||||
@@ -99,7 +104,75 @@ const PerfSection: React.FC<{ title: string; snapshot: StreamPerfSnapshot; empty
|
||||
);
|
||||
};
|
||||
|
||||
const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
|
||||
type LineSeries = { samples: number[]; color: string; filled?: boolean };
|
||||
|
||||
const LineChart: React.FC<{
|
||||
series: LineSeries[];
|
||||
peak: number;
|
||||
windowSeconds: number;
|
||||
ariaLabel: string;
|
||||
maxLabel: string;
|
||||
}> = ({ series, peak, windowSeconds, ariaLabel, maxLabel }) => {
|
||||
const width = windowSeconds;
|
||||
const height = 56;
|
||||
const padTop = 4;
|
||||
const n = series.reduce((max, s) => Math.max(max, s.samples.length), 0);
|
||||
const scale = peak > 0 ? (height - padTop) / peak : 0;
|
||||
const xFor = (i: number): number => width - n + i;
|
||||
const yFor = (v: number): number => height - v * scale;
|
||||
const baseline = height;
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<span className="pointer-events-none absolute left-0 top-0 typography-meta text-[var(--surface-muted-foreground)]">{maxLabel}</span>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-14 w-full"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<line
|
||||
x1={0}
|
||||
y1={baseline}
|
||||
x2={width}
|
||||
y2={baseline}
|
||||
stroke="var(--interactive-border)"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{series.map((s, si) => {
|
||||
const sn = s.samples.length;
|
||||
if (sn === 0) return null;
|
||||
const points = s.samples.map((v, i) => `${xFor(i)},${yFor(v).toFixed(2)}`);
|
||||
const linePath = `M ${points.join(' L ')}`;
|
||||
return (
|
||||
<React.Fragment key={si}>
|
||||
{s.filled ? (
|
||||
<path
|
||||
d={`M ${xFor(0)},${baseline} L ${points.join(' L ')} L ${xFor(sn - 1)},${baseline} Z`}
|
||||
fill={`color-mix(in srgb, ${s.color} 18%, transparent)`}
|
||||
stroke="none"
|
||||
/>
|
||||
) : null}
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
|
||||
const { t } = useI18n();
|
||||
const [activeTab, setActiveTab] = React.useState<DebugTab>('memory');
|
||||
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle');
|
||||
@@ -110,6 +183,15 @@ const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
|
||||
const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount);
|
||||
const [streamSnapshot, setStreamSnapshot] = React.useState<StreamPerfSnapshot>(() => getStreamPerfSnapshot());
|
||||
const [vscodeStreamSnapshot, setVsCodeStreamSnapshot] = React.useState<StreamPerfSnapshot>(() => getVsCodeStreamPerfSnapshot());
|
||||
const [requestsSnapshot, setRequestsSnapshot] = React.useState<RequestsInFlightSnapshot>(() => getRequestsInFlightSnapshot());
|
||||
const ageLines = [
|
||||
{ label: 'p50', current: requestsSnapshot.ageP50, samples: requestsSnapshot.p50Samples, color: 'var(--status-success)' },
|
||||
{ label: 'p90', current: requestsSnapshot.ageP90, samples: requestsSnapshot.p90Samples, color: 'var(--status-info)' },
|
||||
{ label: 'p99', current: requestsSnapshot.ageP99, samples: requestsSnapshot.p99Samples, color: 'var(--status-warning)' },
|
||||
{ label: 'max', current: requestsSnapshot.ageMax, samples: requestsSnapshot.maxSamples, color: 'var(--status-error)' },
|
||||
];
|
||||
const countMax = requestsSnapshot.samples.reduce((m, v) => Math.max(m, v), 0);
|
||||
const percentileMax = ageLines.reduce((m, l) => l.samples.reduce((mm, v) => Math.max(mm, v), m), 0);
|
||||
const streamMetricCounts = React.useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
streamSnapshot.entries.forEach((entry) => {
|
||||
@@ -130,6 +212,7 @@ const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
|
||||
const refresh = () => {
|
||||
setStreamSnapshot(getStreamPerfSnapshot());
|
||||
setVsCodeStreamSnapshot(getVsCodeStreamPerfSnapshot());
|
||||
setRequestsSnapshot(getRequestsInFlightSnapshot());
|
||||
};
|
||||
|
||||
refresh();
|
||||
@@ -218,11 +301,10 @@ const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTab === 'memory' ? (
|
||||
<Icon name="database-2" className="h-4 w-4 text-[var(--surface-foreground)]" />
|
||||
) : (
|
||||
<Icon name="bar-chart-box" className="h-4 w-4 text-[var(--surface-foreground)]" />
|
||||
)}
|
||||
<Icon
|
||||
name={activeTab === 'memory' ? 'database-2' : activeTab === 'streaming' ? 'bar-chart-box' : 'pulse'}
|
||||
className="h-4 w-4 text-[var(--surface-foreground)]"
|
||||
/>
|
||||
<h3 className="typography-ui-label font-semibold text-[var(--surface-foreground)]">{t('memoryDebugPanel.title')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -244,6 +326,18 @@ const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{activeTab === 'requests' ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
resetRequestsInFlight();
|
||||
setRequestsSnapshot(getRequestsInFlightSnapshot());
|
||||
}}
|
||||
>
|
||||
<Icon name="refresh" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onClose ? (
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={onClose}>
|
||||
<Icon name="close" className="h-4 w-4" />
|
||||
@@ -272,6 +366,14 @@ const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
|
||||
>
|
||||
{t('memoryDebugPanel.tabs.streaming')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={activeTab === 'requests' ? 'secondary' : 'ghost'}
|
||||
className="flex-1"
|
||||
onClick={() => setActiveTab('requests')}
|
||||
>
|
||||
{t('memoryDebugPanel.tabs.requests')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'memory' ? (
|
||||
@@ -366,7 +468,7 @@ const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : activeTab === 'streaming' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-[var(--interactive-border)] px-3 py-2 typography-meta text-[var(--surface-muted-foreground)]">
|
||||
<span>
|
||||
@@ -409,6 +511,70 @@ const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2 typography-meta">
|
||||
<MetricCard label={t('memoryDebugPanel.requests.totalRequests')} value={`${requestsSnapshot.totalSettled} / ${requestsSnapshot.totalStarted}`} />
|
||||
<MetricCard
|
||||
label={t('memoryDebugPanel.requests.tracking')}
|
||||
value={requestsSnapshot.startedAt ? formatDuration(requestsSnapshot.durationMs) : t('memoryDebugPanel.common.idle')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{requestsSnapshot.samples.length === 0 ? (
|
||||
<div
|
||||
className="rounded-md p-3 typography-meta text-[var(--surface-muted-foreground)]"
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-muted) 45%, transparent)' }}
|
||||
>
|
||||
{t('memoryDebugPanel.requests.noSamples')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between typography-meta">
|
||||
<span className="text-[var(--surface-muted-foreground)]">{t('memoryDebugPanel.requests.inFlight')}</span>
|
||||
<span>
|
||||
<span className="font-medium text-[var(--surface-foreground)]">{requestsSnapshot.inFlight}</span>
|
||||
<span className="text-[var(--surface-muted-foreground)]"> · {t('memoryDebugPanel.requests.peak')} </span>
|
||||
<span className="font-medium text-[var(--surface-foreground)]">{requestsSnapshot.peak}</span>
|
||||
</span>
|
||||
</div>
|
||||
<LineChart
|
||||
series={[{ samples: requestsSnapshot.samples, color: 'var(--status-info)', filled: true }]}
|
||||
peak={countMax}
|
||||
windowSeconds={requestsSnapshot.windowSeconds}
|
||||
ariaLabel={t('memoryDebugPanel.requests.chartLabel', { peak: requestsSnapshot.peak })}
|
||||
maxLabel={`${countMax}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between typography-meta">
|
||||
<span className="text-[var(--surface-muted-foreground)]">{t('memoryDebugPanel.requests.duration')}</span>
|
||||
<span className="font-medium tabular-nums text-[var(--surface-foreground)]">{formatSeconds(requestsSnapshot.peakAgeMs)}</span>
|
||||
</div>
|
||||
<LineChart
|
||||
series={ageLines.map((line) => ({ samples: line.samples, color: line.color }))}
|
||||
peak={percentileMax}
|
||||
windowSeconds={requestsSnapshot.windowSeconds}
|
||||
ariaLabel={t('memoryDebugPanel.requests.percentileChartLabel')}
|
||||
maxLabel={formatSeconds(percentileMax)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 typography-meta">
|
||||
{ageLines.map((line) => (
|
||||
<span key={line.label} className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: line.color }} />
|
||||
<span className="text-[var(--surface-muted-foreground)]">{line.label}</span>
|
||||
<span className="font-medium tabular-nums text-[var(--surface-foreground)]">{formatSeconds(line.current)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between typography-meta text-[var(--surface-muted-foreground)]">
|
||||
<span>{t('memoryDebugPanel.requests.windowHint', { seconds: requestsSnapshot.windowSeconds })}</span>
|
||||
<span>{t('memoryDebugPanel.requests.now')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,10 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
||||
import { fetchProjectPlan, parsePlanMarkdown } from '@/lib/projectContextApi';
|
||||
import { fetchProjectPlan, parsePlanMarkdown, resolveProjectContextId, type SavedProjectPlanTarget } from '@/lib/projectContextApi';
|
||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||
import { createPlanSaveQueue } from '@/lib/planSaveQueue';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
|
||||
import { TodoSendDialog, type TodoSendExecution } from '@/components/session/TodoSendDialog';
|
||||
@@ -49,9 +52,12 @@ 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;
|
||||
/** Saved project plan to open, with the project that owns it. The owner is
|
||||
part of the prop so the view never guesses it from the current directory:
|
||||
plan tabs outlive directory changes (persisted context tabs, mobile
|
||||
overlays), and for managed chats the owner is not a registered project a
|
||||
directory lookup could ever find. */
|
||||
savedProjectPlan?: SavedProjectPlanTarget | 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;
|
||||
@@ -149,12 +155,16 @@ const resolveProjectRefForDirectory = (
|
||||
return match ? { id: match.id, path: match.path } : null;
|
||||
};
|
||||
|
||||
const subscribeActiveRuntimeKey = (onStoreChange: () => void): (() => void) => {
|
||||
return subscribeRuntimeEndpointChanged(() => onStoreChange());
|
||||
};
|
||||
|
||||
type SelectedLineRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPlanId = null, onNavigatedToChat }) => {
|
||||
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, savedProjectPlan = null, onNavigatedToChat }) => {
|
||||
const { t } = useI18n();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const createSession = useSessionUIStore((state) => state.createSession);
|
||||
@@ -170,6 +180,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const runtimeApis = useRuntimeAPIs();
|
||||
const activeRuntimeKey = React.useSyncExternalStore(subscribeActiveRuntimeKey, getRuntimeKey, getRuntimeKey);
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
|
||||
@@ -190,9 +201,37 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
() => resolveProjectRefForDirectory(projectDirectory, projects, activeProjectId),
|
||||
[activeProjectId, projectDirectory, projects],
|
||||
);
|
||||
// Destructured to primitives so the load/save effects key on stable values
|
||||
// instead of a descriptor object rebuilt on every parent render.
|
||||
const savedPlanProjectId = savedProjectPlan?.projectRef.id ?? null;
|
||||
const savedPlanProjectPath = savedProjectPlan?.projectRef.path ?? null;
|
||||
const savedPlanProjectRef = React.useMemo(
|
||||
() => savedPlanProjectId && savedPlanProjectPath
|
||||
? { id: savedPlanProjectId, path: savedPlanProjectPath }
|
||||
: null,
|
||||
[savedPlanProjectId, savedPlanProjectPath],
|
||||
);
|
||||
const savedPlanId = savedProjectPlan?.planId ?? null;
|
||||
// Stable logical identity, composed from primitives: an effect keyed on the
|
||||
// descriptor object would reload — and flush — the same plan whenever a
|
||||
// parent rebuilds the owner object with identical values.
|
||||
const savedPlanKey = savedPlanProjectRef && savedPlanId
|
||||
? JSON.stringify(['saved-plan', activeRuntimeKey, resolveProjectContextId(savedPlanProjectRef), savedPlanId])
|
||||
: null;
|
||||
// Managed chats have no project directory to create a session in: their
|
||||
// sessions live in per-session directories under the chats root, which
|
||||
// createSession cannot prepare. Until a managed-chat send path exists,
|
||||
// Improve/Implement stay unavailable for plans stored under the Chats
|
||||
// owner — an OpenCode session created directly in the shared root would
|
||||
// break the managed-chats model.
|
||||
const isManagedChatPlan = savedPlanProjectRef?.id === CHAT_DRAFT_PROJECT_ID;
|
||||
const canCreateWorktree = React.useMemo(
|
||||
() => (currentProjectRef ? gitDirectories.get(currentProjectRef.path)?.isGitRepo === true : false),
|
||||
[currentProjectRef, gitDirectories],
|
||||
() => {
|
||||
// Worktree creation follows the session the plan would be sent to.
|
||||
const sendTarget = savedPlanProjectRef ?? currentProjectRef;
|
||||
return sendTarget ? gitDirectories.get(sendTarget.path)?.isGitRepo === true : false;
|
||||
},
|
||||
[currentProjectRef, gitDirectories, savedPlanProjectRef],
|
||||
);
|
||||
const [pendingPlanSend, setPendingPlanSend] = React.useState<PendingPlanSend | null>(null);
|
||||
const [isPlanSendSubmitting, setIsPlanSendSubmitting] = React.useState(false);
|
||||
@@ -202,7 +241,6 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
// `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) {
|
||||
@@ -214,6 +252,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS();
|
||||
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||
const [saveError, setSaveError] = React.useState<string | null>(null);
|
||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||
const planFileLabel = React.useMemo(() => {
|
||||
return displayPath ? displayPath.split('/').pop() || t('planView.file.defaultName') : t('planView.file.defaultName');
|
||||
}, [displayPath, t]);
|
||||
@@ -381,9 +420,96 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
return extensions;
|
||||
}, [currentTheme, resolvedPath, editorFontSize]);
|
||||
|
||||
// Pending-save bookkeeping for the open document. One ref record, not state:
|
||||
// debounced writes and close-time flushes must read the newest buffer and
|
||||
// revision without another render. `editRevision` advances on every editor
|
||||
// change; `savedRevision` only after a successful write of that exact
|
||||
// revision, so a slow in-flight save can never mark newer edits as saved.
|
||||
// `key` and `runtimeKey` make every write self-identifying: content never
|
||||
// crosses documents or runtimes, no matter when a queued write settles.
|
||||
const docRef = React.useRef<{
|
||||
key: string | null;
|
||||
target: SavedProjectPlanTarget | { filePath: string } | null;
|
||||
content: string;
|
||||
editRevision: number;
|
||||
savedRevision: number;
|
||||
runtimeKey: string;
|
||||
}>({ key: null, target: null, content: '', editRevision: 0, savedRevision: 0, runtimeKey: '' });
|
||||
const saveQueue = React.useState(createPlanSaveQueue)[0];
|
||||
|
||||
// Filesystem writes keep the runtime adapter precedence the view always
|
||||
// used: the active RuntimeAPIs first, the registry as fallback.
|
||||
const writeDocument = React.useCallback(async (target: NonNullable<typeof docRef.current['target']>, text: string): Promise<void> => {
|
||||
if ('filePath' in target) {
|
||||
const files = runtimeApis.files ?? getRegisteredRuntimeAPIs()?.files;
|
||||
if (files?.writeFile) {
|
||||
const result = await files.writeFile(target.filePath, text);
|
||||
if (!result?.success) {
|
||||
throw new Error('Plan file write failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const response = await runtimeFetch('/api/fs/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target.filePath, content: text }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to write plan file (${response.status})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const saved = await useProjectContextStore.getState().savePlan(target.projectRef, target.planId, text);
|
||||
if (!saved) {
|
||||
throw new Error('Plan save rejected: the plan no longer exists');
|
||||
}
|
||||
}, [runtimeApis.files]);
|
||||
const writeDocumentRef = React.useRef(writeDocument);
|
||||
writeDocumentRef.current = writeDocument;
|
||||
|
||||
// Queue any unflushed edits. Runs on document switches and on unmount, both
|
||||
// of which cancel the debounced save — without this the last 350ms of typing
|
||||
// is silently dropped. The queue orders it behind any write already in
|
||||
// flight for the same document, and the captured runtime key stops content
|
||||
// from one host being written into another after a runtime switch.
|
||||
const scheduleSave = React.useCallback(() => {
|
||||
const doc = docRef.current;
|
||||
if (!doc.key || !doc.target || doc.editRevision <= doc.savedRevision) {
|
||||
return;
|
||||
}
|
||||
const captured = {
|
||||
key: doc.key,
|
||||
target: doc.target,
|
||||
content: doc.content,
|
||||
revision: doc.editRevision,
|
||||
runtimeKey: doc.runtimeKey,
|
||||
write: writeDocumentRef.current,
|
||||
};
|
||||
saveQueue.schedule(captured.key, captured.revision, async () => {
|
||||
if (getRuntimeKey() !== captured.runtimeKey) {
|
||||
// The runtime switched while this write waited: writing through the
|
||||
// new connection would land one host's edits on another.
|
||||
return;
|
||||
}
|
||||
await captured.write(captured.target, captured.content);
|
||||
const current = docRef.current;
|
||||
if (current.key === captured.key) {
|
||||
current.savedRevision = Math.max(current.savedRevision, captured.revision);
|
||||
// A recovered save clears the stale failure banner.
|
||||
setSaveError(null);
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (docRef.current.key === captured.key) {
|
||||
setSaveError(error instanceof Error ? error.message : 'Plan save failed');
|
||||
}
|
||||
});
|
||||
}, [saveQueue]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Saved project plans opened via context panel should work even when session plan mode is off.
|
||||
if (!planModeEnabled && !targetPath && !projectPlanId) {
|
||||
if (!planModeEnabled && !targetPath && !savedPlanId) {
|
||||
scheduleSave();
|
||||
docRef.current = { key: null, target: null, content: '', editRevision: 0, savedRevision: 0, runtimeKey: '' };
|
||||
setResolvedPath(null);
|
||||
setLoadedProjectPlanId(null);
|
||||
setContent('');
|
||||
@@ -416,31 +542,49 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
// Flush the outgoing document before the bookkeeping is replaced, so
|
||||
// edits typed within the debounce window survive a plan switch. React
|
||||
// reuses this component instance across saved-plan tabs.
|
||||
scheduleSave();
|
||||
docRef.current = { key: null, target: null, content: '', editRevision: 0, savedRevision: 0, runtimeKey: '' };
|
||||
setResolvedPath(null);
|
||||
setLoadedProjectPlanId(null);
|
||||
setContent('');
|
||||
setSaveError(null);
|
||||
setLoadError(null);
|
||||
|
||||
if (projectPlanId) {
|
||||
if (!currentProjectRef) {
|
||||
return;
|
||||
}
|
||||
if (savedPlanId && savedPlanProjectRef && savedPlanKey) {
|
||||
// A plan re-opened while its own flush is still writing must read the
|
||||
// post-write state, not race it. The queue reset afterwards is safe:
|
||||
// every write for this key has settled, and the reloaded document
|
||||
// restarts its revision counter at zero.
|
||||
await saveQueue.pendingFor(savedPlanKey);
|
||||
if (cancelled) return;
|
||||
saveQueue.reset(savedPlanKey);
|
||||
setLoading(true);
|
||||
try {
|
||||
const plan = await fetchProjectPlan(currentProjectRef, projectPlanId);
|
||||
const plan = await fetchProjectPlan(savedPlanProjectRef, savedPlanId);
|
||||
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'));
|
||||
setLoadError('Plan not found');
|
||||
return;
|
||||
}
|
||||
docRef.current = {
|
||||
key: savedPlanKey,
|
||||
target: { projectRef: savedPlanProjectRef, planId: savedPlanId },
|
||||
content: plan.raw,
|
||||
editRevision: 0,
|
||||
savedRevision: 0,
|
||||
runtimeKey: activeRuntimeKey,
|
||||
};
|
||||
setContent(plan.raw);
|
||||
setLoadedProjectPlanId(projectPlanId);
|
||||
setLoadedProjectPlanId(savedPlanId);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setSaveError(error instanceof Error ? error.message : t('planView.error.loadFailed'));
|
||||
setLoadError(error instanceof Error ? error.message : 'Plan load failed');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
@@ -448,10 +592,22 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
}
|
||||
|
||||
if (targetPath) {
|
||||
const fileKey = JSON.stringify(['plan-file', activeRuntimeKey, targetPath]);
|
||||
await saveQueue.pendingFor(fileKey);
|
||||
if (cancelled) return;
|
||||
saveQueue.reset(fileKey);
|
||||
setLoading(true);
|
||||
try {
|
||||
const text = await readText(targetPath);
|
||||
if (cancelled) return;
|
||||
docRef.current = {
|
||||
key: fileKey,
|
||||
target: { filePath: targetPath },
|
||||
content: text,
|
||||
editRevision: 0,
|
||||
savedRevision: 0,
|
||||
runtimeKey: activeRuntimeKey,
|
||||
};
|
||||
setResolvedPath(targetPath);
|
||||
setContent(text);
|
||||
} catch {
|
||||
@@ -477,10 +633,9 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
const homePath = resolveTilde(buildHomePlanPath(session.time.created, session.slug), homeDirectory || null);
|
||||
|
||||
let resolved: string | null = null;
|
||||
let text: string | null = null;
|
||||
|
||||
try {
|
||||
text = await readText(repoPath);
|
||||
await readText(repoPath);
|
||||
resolved = repoPath;
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -488,7 +643,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
|
||||
if (!resolved) {
|
||||
try {
|
||||
text = await readText(homePath);
|
||||
await readText(homePath);
|
||||
resolved = homePath;
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -497,12 +652,26 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (!resolved || text === null) {
|
||||
if (!resolved) {
|
||||
setResolvedPath(null);
|
||||
setContent('');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionFileKey = JSON.stringify(['plan-file', activeRuntimeKey, resolved]);
|
||||
await saveQueue.pendingFor(sessionFileKey);
|
||||
if (cancelled) return;
|
||||
const text = await readText(resolved);
|
||||
if (cancelled) return;
|
||||
saveQueue.reset(sessionFileKey);
|
||||
docRef.current = {
|
||||
key: sessionFileKey,
|
||||
target: { filePath: resolved },
|
||||
content: text,
|
||||
editRevision: 0,
|
||||
savedRevision: 0,
|
||||
runtimeKey: activeRuntimeKey,
|
||||
};
|
||||
setResolvedPath(resolved);
|
||||
setContent(text);
|
||||
} catch {
|
||||
@@ -519,55 +688,42 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentProjectRef, homeDirectory, planModeEnabled, projectPlanId, runtimeApis.files, sessionDirectory, session?.slug, session?.time?.created, t, targetPath]);
|
||||
}, [activeRuntimeKey, homeDirectory, planModeEnabled, runtimeApis.files, savedPlanId, savedPlanKey, savedPlanProjectRef, saveQueue, scheduleSave, session?.slug, session?.time?.created, sessionDirectory, targetPath]);
|
||||
|
||||
// Synchronous buffer tracking: if an edit and an unmount land in the same
|
||||
// batch, the passive content effect would never run and a flush would save
|
||||
// a stale buffer.
|
||||
const handleContentChange = React.useCallback((next: string) => {
|
||||
docRef.current.content = next;
|
||||
docRef.current.editRevision += 1;
|
||||
setContent(next);
|
||||
}, []);
|
||||
|
||||
// The debounced write and the close/switch flush go through the same queue
|
||||
// (scheduleSave), so two saves of one document can never complete out of
|
||||
// order and a flush never duplicates a debounce of the same revision.
|
||||
React.useEffect(() => {
|
||||
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) {
|
||||
throw new Error(t('planView.error.writeFailed'));
|
||||
}
|
||||
} else {
|
||||
const response = await runtimeFetch('/api/fs/write', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: resolvedPath, content }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(t('planView.error.writePlanFileFailed', { status: response.status }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : t('planView.error.saveFailed'));
|
||||
}
|
||||
const controller = window.setTimeout(() => {
|
||||
scheduleSave();
|
||||
}, 350);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(controller);
|
||||
};
|
||||
}, [content, currentProjectRef, loadedProjectPlanId, resolvedPath, runtimeApis.files, savePlan, t]);
|
||||
}, [content, loadedProjectPlanId, resolvedPath, scheduleSave]);
|
||||
|
||||
// Closing the view inside the 350ms debounce window would drop the last
|
||||
// edits: the cleanup above cancels the timer. Same for switching documents,
|
||||
// which the load effect handles before replacing the bookkeeping.
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
scheduleSave();
|
||||
};
|
||||
}, [scheduleSave]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
@@ -584,7 +740,11 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
|
||||
const handleConfirmPlanSend = React.useCallback(
|
||||
async (execution: TodoSendExecution) => {
|
||||
if (!currentProjectRef || !pendingPlanSend) {
|
||||
// A saved plan sends against its own project — the one it is stored
|
||||
// under — not against whatever directory the viewer is currently in.
|
||||
// For filesystem plans those are the same directory.
|
||||
const sendTargetProject = savedPlanProjectRef ?? currentProjectRef;
|
||||
if (!sendTargetProject || !pendingPlanSend || isManagedChatPlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -601,32 +761,45 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
plan_path: resolvedPath ?? '',
|
||||
},
|
||||
);
|
||||
const syntheticParts = [{ synthetic: true as const, text: instructionsText }];
|
||||
// Saved project plans have no file path for the agent to read. Without
|
||||
// this the instructions say "read that file" with an empty path and the
|
||||
// plan contents never reach the session, so the plan substance rides
|
||||
// along in the synthetic message instead.
|
||||
const planSubstance = resolvedPath
|
||||
? instructionsText
|
||||
: [
|
||||
instructionsText,
|
||||
'',
|
||||
'The plan is not stored as a file in the repository and has no file path. Its full current contents follow below this note and are the source of truth for the plan. Where the instructions above refer to the plan file, treat the plan as stored in OpenChamber project knowledge (it is edited through the OpenChamber UI): propose plan revisions as plan text in the chat rather than editing a file.',
|
||||
'',
|
||||
content,
|
||||
].join('\n');
|
||||
const syntheticParts = [{ synthetic: true as const, text: planSubstance }];
|
||||
setIsPlanSendSubmitting(true);
|
||||
|
||||
try {
|
||||
routeToChat();
|
||||
|
||||
let sessionId: string | null = null;
|
||||
let directoryHint: string | null = currentProjectRef.path;
|
||||
let directoryHint: string | null = sendTargetProject.path;
|
||||
|
||||
if (pendingPlanSend.target === 'worktree') {
|
||||
if (!canCreateWorktree) {
|
||||
return;
|
||||
}
|
||||
const created = await createWorktreeSessionForNewBranch(currentProjectRef.path, generateBranchName());
|
||||
const created = await createWorktreeSessionForNewBranch(sendTargetProject.path, generateBranchName());
|
||||
if (!created?.id) {
|
||||
return;
|
||||
}
|
||||
sessionId = created.id;
|
||||
directoryHint = created.path;
|
||||
} else {
|
||||
const sessionResult = await createSession(undefined, currentProjectRef.path, null);
|
||||
const sessionResult = await createSession(undefined, sendTargetProject.path, null);
|
||||
if (!sessionResult?.id) {
|
||||
return;
|
||||
}
|
||||
sessionId = sessionResult.id;
|
||||
directoryHint = sessionResult.directory ?? currentProjectRef.path;
|
||||
directoryHint = sessionResult.directory ?? sendTargetProject.path;
|
||||
initializeNewOpenChamberSession(sessionResult.id, useConfigStore.getState().agents ?? []);
|
||||
}
|
||||
|
||||
@@ -664,8 +837,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
// source. Here we only compose header + full content.
|
||||
const goalObjective = execution.runAsGoal === true
|
||||
? [
|
||||
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`,
|
||||
'Re-read that file for full details — it is the source of truth.',
|
||||
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ' (the full plan follows)'}.`,
|
||||
resolvedPath
|
||||
? 'Re-read that file for full details — it is the source of truth.'
|
||||
: 'The full plan follows in this message and is the source of truth.',
|
||||
'',
|
||||
content,
|
||||
].join('\n')
|
||||
@@ -687,7 +862,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
setIsPlanSendSubmitting(false);
|
||||
}
|
||||
},
|
||||
[canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession]
|
||||
[canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, isManagedChatPlan, pendingPlanSend, resolvedPath, routeToChat, savedPlanProjectRef, sendMessage, sendPromptTitle, setCurrentSession]
|
||||
);
|
||||
|
||||
const blockWidgets = React.useMemo(() => {
|
||||
@@ -716,6 +891,11 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-1.5 flex-shrink-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="typography-ui-label font-medium truncate">{parsedTitle}</div>
|
||||
{loadError ? (
|
||||
<div className="typography-micro text-[color:var(--status-error)] truncate" title={loadError}>
|
||||
{t('planView.error.loadFailed')}
|
||||
</div>
|
||||
) : null}
|
||||
{saveError ? (
|
||||
<div className="typography-micro text-[color:var(--status-error)] truncate" title={saveError}>
|
||||
{t('planView.error.saveFailed')}
|
||||
@@ -733,7 +913,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
aria-label={t('planView.actions.improvePlanAria')}
|
||||
disabled={!content.trim()}
|
||||
disabled={!content.trim() || isManagedChatPlan}
|
||||
>
|
||||
<Icon name="loop-right-ai" className="size-4" />
|
||||
</Button>
|
||||
@@ -742,7 +922,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
<TooltipContent sideOffset={8}>{t('planView.actions.improve')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'improve', target: 'session' })}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setPendingPlanSend({ action: 'improve', target: 'session' })}
|
||||
disabled={isManagedChatPlan}
|
||||
>
|
||||
{t('planView.actions.sendToNewSession')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -762,7 +945,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
aria-label={t('planView.actions.implementPlanAria')}
|
||||
disabled={!content.trim()}
|
||||
disabled={!content.trim() || isManagedChatPlan}
|
||||
>
|
||||
<Icon name="code-ai" className="size-4" />
|
||||
</Button>
|
||||
@@ -771,7 +954,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
<TooltipContent sideOffset={8}>{t('planView.actions.implement')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'implement', target: 'session' })}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setPendingPlanSend({ action: 'implement', target: 'session' })}
|
||||
disabled={isManagedChatPlan}
|
||||
>
|
||||
{t('planView.actions.sendToNewSession')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -853,7 +1039,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
}
|
||||
}}
|
||||
target={pendingPlanSend?.target ?? 'session'}
|
||||
projectDirectory={currentProjectRef?.path ?? null}
|
||||
projectDirectory={savedPlanProjectRef?.path ?? currentProjectRef?.path ?? null}
|
||||
submitting={isPlanSendSubmitting}
|
||||
allowRunAsGoal
|
||||
onConfirm={handleConfirmPlanSend}
|
||||
@@ -885,7 +1071,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
|
||||
<div className="relative h-full" ref={editorWrapperRef}>
|
||||
<CodeMirrorEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
onChange={handleContentChange}
|
||||
readOnly={false}
|
||||
className="h-full"
|
||||
extensions={editorExtensions}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import React, { act } from 'react';
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
type ChildrenProps = { children?: React.ReactNode };
|
||||
type AgentsSidebarProps = { onItemSelect?: () => void };
|
||||
type SettingsPageLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
showSaveStatus?: boolean;
|
||||
};
|
||||
|
||||
interface FakeNode {
|
||||
nodeType: number;
|
||||
nodeName: string;
|
||||
tagName: string;
|
||||
namespaceURI: string;
|
||||
ownerDocument: FakeDocument;
|
||||
parentNode: FakeNode | null;
|
||||
childNodes: FakeNode[];
|
||||
style: { setProperty: () => void; getPropertyValue: () => string };
|
||||
classList: FakeClassList;
|
||||
attributes: Map<string, string>;
|
||||
textContent: string;
|
||||
nodeValue: string | null;
|
||||
focusOptions?: FocusOptions;
|
||||
appendChild: (child: FakeNode) => FakeNode;
|
||||
insertBefore: (child: FakeNode, before: FakeNode | null) => FakeNode;
|
||||
removeChild: (child: FakeNode) => FakeNode;
|
||||
setAttribute: (name: string, value: string) => void;
|
||||
removeAttribute: (name: string) => void;
|
||||
getAttribute: (name: string) => string | null;
|
||||
hasAttribute: (name: string) => boolean;
|
||||
addEventListener: () => void;
|
||||
removeEventListener: () => void;
|
||||
contains: (child: FakeNode | null) => boolean;
|
||||
querySelector: (selector: string) => FakeNode | null;
|
||||
focus: (options?: FocusOptions) => void;
|
||||
}
|
||||
|
||||
interface FakeDocument {
|
||||
nodeType: number;
|
||||
nodeName: string;
|
||||
defaultView: FakeWindow | null;
|
||||
body: FakeNode | null;
|
||||
documentElement: FakeNode | null;
|
||||
activeElement: FakeNode | null;
|
||||
createElement: (tag: string) => FakeNode & Element;
|
||||
createElementNS: (_namespace: string, tag: string) => FakeNode & Element;
|
||||
createTextNode: (text: string) => FakeNode & Element;
|
||||
addEventListener: () => void;
|
||||
removeEventListener: () => void;
|
||||
}
|
||||
|
||||
interface FakeWindow {
|
||||
document: FakeDocument;
|
||||
navigator: { userAgent: string; platform: string; maxTouchPoints: number };
|
||||
history: { state: null; back: () => void; pushState: () => void };
|
||||
location: { href: string };
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => number;
|
||||
cancelAnimationFrame: (frame: number) => void;
|
||||
addEventListener: () => void;
|
||||
removeEventListener: () => void;
|
||||
HTMLIFrameElement: typeof FakeElement;
|
||||
HTMLFrameSetElement: typeof FakeElement;
|
||||
HTMLInputElement: typeof FakeElement;
|
||||
HTMLTextAreaElement: typeof FakeElement;
|
||||
HTMLSelectElement: typeof FakeElement;
|
||||
HTMLOptionElement: typeof FakeElement;
|
||||
HTMLAnchorElement: typeof FakeElement;
|
||||
}
|
||||
|
||||
type GlobalStubValue = FakeDocument | FakeWindow | FakeWindow['navigator'] | FakeWindow['location'] | typeof FakeElement | boolean;
|
||||
|
||||
class FakeElement {}
|
||||
|
||||
class FakeClassList {
|
||||
private readonly classes = new Set<string>();
|
||||
|
||||
add(...classes: string[]) {
|
||||
classes.forEach((className) => this.classes.add(className));
|
||||
}
|
||||
|
||||
remove(...classes: string[]) {
|
||||
classes.forEach((className) => this.classes.delete(className));
|
||||
}
|
||||
|
||||
contains(className: string) {
|
||||
return this.classes.has(className);
|
||||
}
|
||||
}
|
||||
|
||||
function makeNode(tag: string, ownerDocument: FakeDocument, nodeType = 1): FakeNode & Element {
|
||||
const attributes = new Map<string, string>();
|
||||
const properties: FakeNode = {
|
||||
nodeType,
|
||||
nodeName: nodeType === 3 ? '#text' : tag.toUpperCase(),
|
||||
tagName: nodeType === 3 ? '#text' : tag.toUpperCase(),
|
||||
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||
ownerDocument,
|
||||
parentNode: null,
|
||||
childNodes: [],
|
||||
style: {
|
||||
setProperty: () => {},
|
||||
getPropertyValue: () => '',
|
||||
},
|
||||
classList: new FakeClassList(),
|
||||
attributes,
|
||||
textContent: '',
|
||||
nodeValue: null,
|
||||
appendChild(child) {
|
||||
this.childNodes.push(child);
|
||||
child.parentNode = this;
|
||||
return child;
|
||||
},
|
||||
insertBefore(child, before) {
|
||||
const index = before ? this.childNodes.indexOf(before) : -1;
|
||||
if (index === -1) {
|
||||
this.childNodes.push(child);
|
||||
} else {
|
||||
this.childNodes.splice(index, 0, child);
|
||||
}
|
||||
child.parentNode = this;
|
||||
return child;
|
||||
},
|
||||
removeChild(child) {
|
||||
const index = this.childNodes.indexOf(child);
|
||||
if (index !== -1) {
|
||||
this.childNodes.splice(index, 1);
|
||||
}
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
attributes.set(name, value);
|
||||
},
|
||||
removeAttribute(name) {
|
||||
attributes.delete(name);
|
||||
},
|
||||
getAttribute(name) {
|
||||
return attributes.get(name) ?? null;
|
||||
},
|
||||
hasAttribute(name) {
|
||||
return attributes.has(name);
|
||||
},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
contains(child) {
|
||||
if (child === this) {
|
||||
return true;
|
||||
}
|
||||
return this.childNodes.some((nodeChild) => nodeChild.contains(child));
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector !== '[data-settings-page-heading]') {
|
||||
return null;
|
||||
}
|
||||
if (this.hasAttribute('data-settings-page-heading')) {
|
||||
return this;
|
||||
}
|
||||
for (const child of this.childNodes) {
|
||||
const match = child.querySelector(selector);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
focus(options) {
|
||||
this.focusOptions = options;
|
||||
this.ownerDocument.activeElement = this;
|
||||
},
|
||||
};
|
||||
const node: FakeNode & Element = Object.assign(Object.create(FakeElement.prototype), properties);
|
||||
return node;
|
||||
}
|
||||
|
||||
function installDomStub() {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = (name: string, value: GlobalStubValue) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let nextFrame = 1;
|
||||
const documentStub: FakeDocument = {
|
||||
nodeType: 9,
|
||||
nodeName: '#document',
|
||||
defaultView: null,
|
||||
body: null,
|
||||
documentElement: null,
|
||||
activeElement: null,
|
||||
createElement: (tag) => makeNode(tag, documentStub),
|
||||
createElementNS: (_namespace, tag) => makeNode(tag, documentStub),
|
||||
createTextNode: (text) => {
|
||||
const node = makeNode('#text', documentStub, 3);
|
||||
node.nodeValue = text;
|
||||
node.textContent = text;
|
||||
return node;
|
||||
},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
const windowStub: FakeWindow = {
|
||||
document: documentStub,
|
||||
navigator: { userAgent: 'test', platform: 'test', maxTouchPoints: 0 },
|
||||
history: { state: null, back: () => {}, pushState: () => {} },
|
||||
location: { href: 'http://localhost/' },
|
||||
requestAnimationFrame: (callback) => {
|
||||
const frame = nextFrame;
|
||||
nextFrame += 1;
|
||||
frames.set(frame, callback);
|
||||
return frame;
|
||||
},
|
||||
cancelAnimationFrame: (frame) => {
|
||||
frames.delete(frame);
|
||||
},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
HTMLIFrameElement: FakeElement,
|
||||
HTMLFrameSetElement: FakeElement,
|
||||
HTMLInputElement: FakeElement,
|
||||
HTMLTextAreaElement: FakeElement,
|
||||
HTMLSelectElement: FakeElement,
|
||||
HTMLOptionElement: FakeElement,
|
||||
HTMLAnchorElement: FakeElement,
|
||||
};
|
||||
documentStub.defaultView = windowStub;
|
||||
documentStub.body = makeNode('body', documentStub);
|
||||
documentStub.documentElement = makeNode('html', documentStub);
|
||||
documentStub.activeElement = documentStub.body;
|
||||
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', windowStub);
|
||||
setGlobal('navigator', windowStub.navigator);
|
||||
setGlobal('location', windowStub.location);
|
||||
setGlobal('Element', FakeElement);
|
||||
setGlobal('HTMLElement', FakeElement);
|
||||
setGlobal('HTMLIFrameElement', FakeElement);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
return {
|
||||
container: documentStub.createElement('div'),
|
||||
document: documentStub,
|
||||
frameCount: () => frames.size,
|
||||
flushFrames: () => {
|
||||
const callbacks = Array.from(frames.values());
|
||||
frames.clear();
|
||||
callbacks.forEach((callback) => callback(Date.now()));
|
||||
},
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, name, descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Empty = () => null;
|
||||
const uiStore = {
|
||||
settingsPage: 'agents',
|
||||
isSettingsDialogOpen: true,
|
||||
setSettingsPage: () => {},
|
||||
};
|
||||
type UiStoreValue = (typeof uiStore)[keyof typeof uiStore];
|
||||
const agentsMeta = { slug: 'agents', title: 'Agents', group: 'opencode', kind: 'split' };
|
||||
let sidebarOnItemSelect: (() => void) | undefined;
|
||||
let SettingsPageLayout: React.ComponentType<SettingsPageLayoutProps> | null = null;
|
||||
|
||||
mock.module('@/lib/utils', () => ({
|
||||
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
|
||||
getModifierLabel: () => 'Ctrl',
|
||||
}));
|
||||
mock.module('@/stores/useUIStore', () => ({
|
||||
useUIStore: (selector: (state: typeof uiStore) => UiStoreValue) => selector(uiStore),
|
||||
}));
|
||||
mock.module('@/hooks/useSettingsDirectory', () => ({ useSettingsDirectory: () => '/workspace' }));
|
||||
mock.module('@/stores/useProjectsStore', () => ({
|
||||
useProjectsStore: (selector: (state: { activeProjectId: null }) => null) => selector({ activeProjectId: null }),
|
||||
}));
|
||||
mock.module('@/stores/useAgentsStore', () => ({
|
||||
refreshAfterOpenCodeRestart: async () => {},
|
||||
useAgentsStore: { getState: () => ({ loadAgents: async () => {} }) },
|
||||
}));
|
||||
mock.module('@/stores/useCommandsStore', () => ({ useCommandsStore: { getState: () => ({ loadCommands: async () => {} }) } }));
|
||||
mock.module('@/stores/useMcpConfigStore', () => ({ useMcpConfigStore: { getState: () => ({ loadMcpConfigs: async () => {} }) } }));
|
||||
mock.module('@/stores/useSnippetsStore', () => ({ useSnippetsStore: { getState: () => ({ loadSnippets: async () => {} }) } }));
|
||||
mock.module('@/stores/useSkillsStore', () => ({ useSkillsStore: { getState: () => ({ loadSkills: async () => {} }) } }));
|
||||
mock.module('@/stores/useSkillsCatalogStore', () => ({ useSkillsCatalogStore: { getState: () => ({ loadCatalog: async () => {} }) } }));
|
||||
mock.module('@/stores/useConfigStore', () => ({ useConfigStore: { getState: () => ({ providers: [], setSelectedProvider: () => {} }) } }));
|
||||
mock.module('@/stores/usePendingOpenCodeRestartStore', () => ({
|
||||
selectPendingOpenCodeRestartCount: () => 0,
|
||||
usePendingOpenCodeRestartStore: () => 0,
|
||||
}));
|
||||
mock.module('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: ChildrenProps) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: ChildrenProps) => <>{children}</>,
|
||||
}));
|
||||
mock.module('@/components/ui/ErrorBoundary', () => ({ ErrorBoundary: ({ children }: ChildrenProps) => <>{children}</> }));
|
||||
mock.module('@/components/ui/ScrollableOverlay', () => ({ ScrollableOverlay: ({ children }: ChildrenProps) => <div>{children}</div> }));
|
||||
mock.module('@/components/sections/shared/SettingsSection', () => ({
|
||||
SETTINGS_DESCRIPTION_CLASS: '',
|
||||
SETTINGS_PAGE_TITLE_CLASS: '',
|
||||
SETTINGS_SECTION_TITLE_CLASS: '',
|
||||
}));
|
||||
mock.module('@/lib/persistence', () => ({
|
||||
getSettingsSaveState: () => 'idle',
|
||||
subscribeToSettingsSaveState: () => () => {},
|
||||
}));
|
||||
mock.module('@/components/icon/Icon', () => ({ Icon: Empty }));
|
||||
mock.module('@/components/icons/McpIcon', () => ({ McpIcon: Empty }));
|
||||
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
mock.module('@/lib/device', () => ({
|
||||
useDeviceInfo: () => ({ isMobile: false }),
|
||||
}));
|
||||
mock.module('@/lib/desktop', () => ({
|
||||
getDesktopHomeDirectory: async () => null,
|
||||
isDesktopLocalOriginActive: () => false,
|
||||
isDesktopShell: () => false,
|
||||
isVSCodeRuntime: () => false,
|
||||
isWebRuntime: () => true,
|
||||
}));
|
||||
mock.module('@/lib/platform', () => ({ isWindowsArm64: () => false }));
|
||||
mock.module('@/lib/settings/metadata', () => ({
|
||||
SETTINGS_PAGE_METADATA: [agentsMeta],
|
||||
getSettingsNavIcon: () => 'settings-3',
|
||||
getSettingsPageMeta: (slug: string) => slug === 'agents' ? agentsMeta : null,
|
||||
resolveSettingsSlug: (slug: string) => slug === 'agents' ? 'agents' : 'home',
|
||||
}));
|
||||
mock.module('@/lib/settings/search', () => ({ buildSettingsSearchResults: () => [] }));
|
||||
mock.module('@/components/views/OpenCodeReloadFooterAction', () => ({ OpenCodeReloadFooterAction: Empty }));
|
||||
mock.module('@/components/sections/agents/AgentsSidebar', () => ({
|
||||
AgentsSidebar: ({ onItemSelect }: AgentsSidebarProps) => {
|
||||
sidebarOnItemSelect = onItemSelect;
|
||||
return <button type="button" onClick={onItemSelect}>Duplicate</button>;
|
||||
},
|
||||
}));
|
||||
mock.module('@/components/sections/agents/AgentsPage', () => ({
|
||||
AgentsPage: () => {
|
||||
const Layout = SettingsPageLayout;
|
||||
if (!Layout) {
|
||||
throw new Error('SettingsPageLayout must load before SettingsView');
|
||||
}
|
||||
return <Layout title="New agent" showSaveStatus={false}><div /></Layout>;
|
||||
},
|
||||
}));
|
||||
|
||||
for (const [module, exports] of [
|
||||
['@/components/sections/behavior/BehaviorPage', ['BehaviorPage']],
|
||||
['@/components/sections/commands/CommandsSidebar', ['CommandsSidebar']],
|
||||
['@/components/sections/commands/CommandsPage', ['CommandsPage']],
|
||||
['@/components/sections/mcp/McpSidebar', ['McpSidebar']],
|
||||
['@/components/sections/mcp/McpPage', ['McpPage']],
|
||||
['@/components/sections/plugins', ['PluginsSidebar', 'PluginsPage']],
|
||||
['@/components/sections/skills/SkillsSidebar', ['SkillsSidebar']],
|
||||
['@/components/sections/skills/SkillsPage', ['SkillsPage']],
|
||||
['@/components/sections/projects/ProjectsSidebar', ['ProjectsSidebar']],
|
||||
['@/components/sections/projects/ProjectsPage', ['ProjectsPage']],
|
||||
['@/components/sections/remote-instances/RemoteInstancesPage', ['RemoteInstancesPage']],
|
||||
['@/components/sections/providers/ProvidersSidebar', ['ProvidersSidebar']],
|
||||
['@/components/sections/providers/ProvidersPage', ['ProvidersPage']],
|
||||
['@/components/sections/usage/UsageSidebar', ['UsageSidebar']],
|
||||
['@/components/sections/usage/UsagePage', ['UsagePage']],
|
||||
['@/components/sections/magic-prompts/MagicPromptsSidebar', ['MagicPromptsSidebar']],
|
||||
['@/components/sections/magic-prompts/MagicPromptsPage', ['MagicPromptsPage']],
|
||||
['@/components/sections/snippets/SnippetsSidebar', ['SnippetsSidebar']],
|
||||
['@/components/sections/snippets/SnippetsPage', ['SnippetsPage']],
|
||||
['@/components/sections/git-identities/GitPage', ['GitPage']],
|
||||
['@/components/sections/integrations/IntegrationsPage', ['IntegrationsPage']],
|
||||
['@/components/sections/openchamber/OpenChamberPage', ['OpenChamberPage']],
|
||||
['@/components/sections/openchamber/AboutSettings', ['AboutSettings']],
|
||||
] as const) {
|
||||
mock.module(module, () => Object.fromEntries(exports.map((name) => [name, Empty])));
|
||||
}
|
||||
|
||||
SettingsPageLayout = (await import('../sections/shared/SettingsPageLayout')).SettingsPageLayout;
|
||||
const { SettingsView } = await import('./SettingsView');
|
||||
|
||||
describe('SettingsView mobile split-page focus', () => {
|
||||
test('focuses the rendered editor heading after a mobile sidebar selection', async () => {
|
||||
const dom = installDomStub();
|
||||
const root: Root = createRoot(dom.container);
|
||||
sidebarOnItemSelect = undefined;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(<SettingsView forceMobile initialMobileStage="page-sidebar" />);
|
||||
});
|
||||
|
||||
expect(sidebarOnItemSelect).toBeDefined();
|
||||
await act(async () => {
|
||||
sidebarOnItemSelect?.();
|
||||
});
|
||||
|
||||
const heading = dom.container.querySelector('[data-settings-page-heading]');
|
||||
expect(heading).not.toBeNull();
|
||||
expect(heading?.getAttribute('tabindex')).toBe('-1');
|
||||
expect(dom.document.activeElement).toBe(dom.document.body);
|
||||
expect(dom.frameCount()).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
dom.flushFrames();
|
||||
});
|
||||
|
||||
expect(dom.document.activeElement).toBe(heading);
|
||||
expect(heading?.focusOptions).toEqual({ preventScroll: true });
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not pass the mobile selection callback to desktop split pages', async () => {
|
||||
const dom = installDomStub();
|
||||
const root: Root = createRoot(dom.container);
|
||||
sidebarOnItemSelect = undefined;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(<SettingsView forceMobile={false} />);
|
||||
});
|
||||
|
||||
expect(sidebarOnItemSelect).toBe(undefined);
|
||||
expect(dom.frameCount()).toBe(0);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -214,6 +214,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const [pendingSearchItemId, setPendingSearchItemId] = React.useState<string | null>(null);
|
||||
const [activeSearchResultIndex, setActiveSearchResultIndex] = React.useState(0);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const shouldFocusMobilePageContentRef = React.useRef(false);
|
||||
const searchResultRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const activeSearchResultIndexRef = React.useRef(0);
|
||||
const keyboardSearchNavigationRef = React.useRef(false);
|
||||
@@ -764,12 +765,30 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
}, [runtimeCtx.isVSCode]);
|
||||
|
||||
const handleMobilePageSidebarItemSelect = React.useCallback(() => {
|
||||
shouldFocusMobilePageContentRef.current = true;
|
||||
setMobileStage('page-content');
|
||||
if (settingsSlug === 'skills.installed') {
|
||||
pushMobileSplitDetailHistory(settingsSlug);
|
||||
}
|
||||
}, [pushMobileSplitDetailHistory, settingsSlug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || mobileStage !== 'page-content' || !shouldFocusMobilePageContentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldFocusMobilePageContentRef.current = false;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
containerRef.current
|
||||
?.querySelector<HTMLElement>('[data-settings-page-heading]')
|
||||
?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [isMobile, mobileStage, settingsSlug]);
|
||||
|
||||
const handleBack = React.useCallback(() => {
|
||||
if (backButtonTargetsPageSidebar) {
|
||||
const currentDetail = typeof window !== 'undefined'
|
||||
|
||||
@@ -22,8 +22,6 @@ import { useI18n } from '@/lib/i18n';
|
||||
|
||||
/** Max file size in bytes (10MB) */
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
/** Max number of concurrent runs */
|
||||
const MAX_MODELS = 5;
|
||||
|
||||
/** Attached file for agent manager */
|
||||
interface AttachedFile {
|
||||
@@ -132,11 +130,8 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
}, [projectRef]);
|
||||
|
||||
const handleAddModel = React.useCallback((model: ModelSelectionWithId) => {
|
||||
if (selectedModels.length >= MAX_MODELS) {
|
||||
return;
|
||||
}
|
||||
setSelectedModels((prev) => [...prev, model]);
|
||||
}, [selectedModels.length]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveModel = React.useCallback((index: number) => {
|
||||
setSelectedModels((prev) => prev.filter((_, i) => i !== index));
|
||||
@@ -529,7 +524,6 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
onUpdate={handleUpdateModel}
|
||||
minModels={1}
|
||||
addButtonLabel={t('agentManager.empty.models.addModel')}
|
||||
maxModels={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user