feat(chat): Comments and review in VS Code, like the OpenChamber desktop app (#1724)

* feat(chat): render code comments as cards instead of fenced text

* fix(vscode): route Add Comment to the active session editor panel

* fix(chat): persist queued inline comments and tighten file-chip path matching

* feat(vscode): comment on code from the editor

* fix(chat): keep attached context in the message and broadcast comment removal

* fix(vscode): hold every pending comment and gate both entry points on the workspace

* fix(vscode): let only the owning surface decide its comment threads

* fix(vscode): drop a comment removed while its delivery was still in flight

* test(vscode): cover the in-flight comment removal guard

* test(vscode): cover comment removal reaching every chat surface

* fix(vscode): give up on a comment the chat never confirmed holding

* fix(vscode): retract a comment everywhere before reporting it discarded

* fix(chat): preserve queued comment cards

* fix: preserve inline comment context across send paths

* fix(chat): preserve command routing with context

* fix(chat): keep unavailable actions on normal send path

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Felipe Gené
2026-09-05 12:28:17 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent d323b51a0a
commit a12b9be443
35 changed files with 2192 additions and 131 deletions
+128 -74
View File
@@ -144,7 +144,7 @@ import {
buildCommandVariables,
canRunCommand,
findMagicPromptCommand,
parseSlashCommand,
planLocalSlashCommand,
} from './composer/submit/slashCommands';
import { useAutocompletePosition } from './composer/state/useAutocompletePosition';
import { useMessageHistory } from './composer/state/useMessageHistory';
@@ -1011,6 +1011,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const handleQueueMessage = React.useCallback(async () => {
const inputSnapshot = getCurrentInputSnapshot();
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
// A local command is run, not queued: the queue delivers text to the
// model, and `/compact` or `/btw` mean nothing there.
if (planLocalSlashCommand(inputSnapshot.message, inputMode, hasDrafts, true)) {
void handleSubmitRef.current();
return;
}
const queueRuntimeKey = getRuntimeKey();
const queueTarget = messageQueueTarget;
const queueSessionId = currentSessionId;
@@ -1119,7 +1126,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
return;
}
recordLinkedReferences(queueSessionId, queueTarget.directory, linked);
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, prepareDocumentMentions, extractInlineFileMentions, agents, currentDirectory, consumePendingSyntheticParts, inlineDraftTarget, consumeDrafts, linkedIssue, linkedPr, linkedLinearIssue, scrollToLatest, clearAttachedFiles, isMobile, addToQueue, currentProviderId, currentModelId, currentAgentName, currentVariant, t]);
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inputMode, hasDrafts, attachedFiles, sanitizeAttachmentsForSend, prepareDocumentMentions, extractInlineFileMentions, agents, currentDirectory, consumePendingSyntheticParts, inlineDraftTarget, consumeDrafts, linkedIssue, linkedPr, linkedLinearIssue, scrollToLatest, clearAttachedFiles, isMobile, addToQueue, currentProviderId, currentModelId, currentAgentName, currentVariant, t]);
/** Put the context a queued message was captured with back on the composer chips. */
const restoreQueuedContext = React.useCallback((context: readonly QueuedContextPart[]) => {
@@ -1241,6 +1248,51 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
return;
}
// Local slash commands are planned before anything is taken or
// consumed. An action command must leave the queue and the attached
// context where they are; a prompt command must send that context with
// the prompt it produces. A command the composer cannot run here is not
// a local command at all and goes out as typed.
let commandPlan = !queuedOnly && inputSnapshot.hasContent
? planLocalSlashCommand(inputSnapshot.message, inputMode, hasDrafts, Boolean(currentSessionId))
: null;
if (commandPlan?.kind === 'prompt') {
const magicCommand = findMagicPromptCommand(commandPlan.command.name);
const commandIsAvailable = commandPlan.command.name === 'btw'
? Boolean(currentSessionId)
: magicCommand !== null && canRunCommand(magicCommand, {
hasSession: Boolean(currentSessionId),
hasDraft: newSessionDraftOpen,
});
if (!commandIsAvailable) commandPlan = null;
}
if (commandPlan?.command.name === 'handoff-review' && (isMobile || isVSCodeRuntime())) commandPlan = null;
// A failed send returns the typed prompt no matter WHY it failed —
// auth, network, server, anything. Losing a long prompt to a toast is
// the one outcome this handler must never produce. The mentions are
// snapshotted here because sending clears them before it can fail.
const confirmedMentionsSnapshot = new Set(confirmedMentionsRef.current);
const restoreComposerText = () => {
if (queuedOnly || !inputSnapshot.message) return;
for (const mention of confirmedMentionsSnapshot) confirmedMentionsRef.current.add(mention);
if (currentChatDraftIdentityRef.current !== chatDraftIdentity) {
// The user switched sessions mid-send: restore into that
// session's persisted draft, not the visible composer.
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
return;
}
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
if (!currentInput || currentInput === inputSnapshot.message) {
setMessage(inputSnapshot.message);
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
} else {
// New typing already lives in the composer; the failed prompt
// joins it instead of clobbering either text.
useInputStore.getState().setPendingInputText(inputSnapshot.message, 'append');
}
};
// The projection knows the captured send configuration; the full
// messages are taken from the queue only once nothing below can still
// bail out, so an early return leaves the queue untouched.
@@ -1268,7 +1320,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
// queued-message auto-send hook delivers it as the next turn once the
// rejected turn winds down and the session returns to idle. This avoids
// aborting the turn (which would surface an "aborted" notice).
if (currentSessionId && !queuedOnly && autoReviewRunning && !isBtwActive) {
if (currentSessionId && !queuedOnly && autoReviewRunning && !isBtwActive && !commandPlan) {
void handleQueueMessage();
return;
}
@@ -1276,7 +1328,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
// btw mode: the child fork's blocking prompts are answered inside the
// panel; the composer send goes straight to the fork (routeMessage
// queues if the fork's own turn is busy).
if (currentSessionId && !queuedOnly && !isBtwActive) {
if (currentSessionId && !queuedOnly && !isBtwActive && !commandPlan) {
// Sending is authoritative for blocking prompts: deny pending
// permissions and dismiss open questions for the session subtree,
// then queue the message once if either was open. The deny/clear
@@ -1296,6 +1348,41 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
}
}
// Action commands change session or UI state and send nothing. The
// command text goes; the queue and whatever the composer had attached
// stay exactly where they are.
if (commandPlan?.kind === 'action' && currentSessionId) {
const actionName = commandPlan.command.name;
setMessage('');
confirmedMentionsRef.current.clear();
persistDraftImmediately(chatDraftIdentity, '');
messageHistory.reset();
setExpandedInput(false);
if (isMobile) composerRef.current?.blur();
try {
if (actionName === 'undo') {
await useSessionUIStore.getState().handleSlashUndo(currentSessionId);
scrollToBottom?.();
} else if (actionName === 'redo') {
await useSessionUIStore.getState().handleSlashRedo(currentSessionId);
scrollToBottom?.();
} else if (actionName === 'timeline') {
setTimelineDialogOpen(true);
} else if (actionName === 'handoff-review') {
setReviewDialogOpen(true);
} else if (actionName === 'compact') {
await sessionActions.waitForConnectionOrThrow();
const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined;
await opencodeClient.summarizeSession(currentSessionId, providerIdToSend, modelIdToSend, compactDirectory);
}
} catch (error) {
restoreComposerText();
if (actionName !== 'compact') throw error;
toast.error(getSubmitErrorMessage(error, t('chat.chatInput.toast.compactFailed')));
}
return;
}
let sendMessageOptions: {
target?: NonNullable<typeof capturedTarget>;
sessionId?: string;
@@ -1338,7 +1425,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
// skips anything already in flight, and a message already being
// delivered stays out of this send so it cannot go out twice.
let queuedMessagesToSend: QueuedMessage[] = [];
if (capturedTarget && hasQueuedMessages) {
if (capturedTarget && hasQueuedMessages && !commandPlan) {
try {
queuedMessagesToSend = await takeForSend(capturedTarget, queuedMessageId);
} catch (error) {
@@ -1357,6 +1444,27 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const drafts: InlineCommentDraft[] = consumedDraftTarget
? consumeDrafts(consumedDraftTarget)
: [];
const restoreConsumedDrafts = () => {
if (consumedDraftTarget && drafts.length > 0) {
useInlineCommentDraftStore.getState().restoreDrafts(consumedDraftTarget, drafts);
}
};
// Everything a prompt command consumed comes back if it fails: the
// attached context, the typed text, and the files.
const restoreConsumedInput = () => {
restoreConsumedDrafts();
if (syntheticParts?.length) {
const inputState = useInputStore.getState();
inputState.setPendingSyntheticParts([...syntheticParts, ...(inputState.pendingSyntheticParts ?? [])]);
}
restoreComposerText();
if (!queuedOnly && attachedFiles.length > 0) {
const inputState = useInputStore.getState();
const present = new Set(inputState.attachedFiles.map((attachment) => attachment.id));
const missing = attachedFiles.filter((attachment) => !present.has(attachment.id));
if (missing.length > 0) inputState.setAttachedFiles([...inputState.attachedFiles, ...missing]);
}
};
const availableSkillNames = new Set(
selectSkillsForDirectory(useSkillsStore.getState(), currentDirectory).map((skill) => skill.name),
@@ -1417,44 +1525,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
composerRef.current?.blur();
}
// Local slash commands, normal mode only.
const parsedCommand = inputMode === 'normal' ? parseSlashCommand(primaryText) : null;
if (parsedCommand) {
const { name: commandName, argument } = parsedCommand;
// Prompt commands render a visible prompt (or fork a btw question) and
// send it with everything the composer had attached.
if (commandPlan?.kind === 'prompt') {
const { name: commandName, argument } = commandPlan.command;
// Commands that manipulate session state or open UI rather than
// sending a message.
if (commandName === 'undo' && currentSessionId) {
await useSessionUIStore.getState().handleSlashUndo(currentSessionId);
scrollToBottom?.();
return;
}
if (commandName === 'redo' && currentSessionId) {
await useSessionUIStore.getState().handleSlashRedo(currentSessionId);
scrollToBottom?.();
return;
}
if (commandName === 'timeline' && currentSessionId) {
setTimelineDialogOpen(true);
return;
}
if (commandName === 'handoff-review' && currentSessionId && !isMobile && !isVSCodeRuntime()) {
setReviewDialogOpen(true);
return;
}
if (commandName === 'compact' && currentSessionId) {
try {
await sessionActions.waitForConnectionOrThrow();
const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined;
await opencodeClient.summarizeSession(currentSessionId, currentProviderId, currentModelId, compactDirectory);
} catch (error) {
toast.error(getSubmitErrorMessage(error, t('chat.chatInput.toast.compactFailed')));
}
return;
}
if (commandName === 'btw' && currentSessionId) {
const question = argument.trim();
if (!question) {
restoreConsumedInput();
toast.error(t('chat.btw.toast.emptyArgument'));
return;
}
@@ -1462,6 +1541,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|| currentDirectory
|| null;
if (!targetDirectory) {
restoreConsumedInput();
toast.error(t('chat.btw.toast.createFailed'));
return;
}
@@ -1479,22 +1559,21 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
modelID: modelIdToSend,
agent: agentNameToSend,
variant: variantToSend,
attachments: primaryAttachments,
additionalParts,
});
scrollToBottom?.();
} catch (error) {
restoreConsumedInput();
toast.error(getSubmitErrorMessage(error, t('chat.btw.toast.createFailed')));
}
return;
}
// The rest render a visible prompt plus synthetic instructions and
// send them as one message.
// send them as one message, the attached context riding along.
const command = findMagicPromptCommand(commandName);
const commandIsAvailable = command !== null && canRunCommand(command, {
hasSession: Boolean(currentSessionId),
hasDraft: newSessionDraftOpen,
});
if (command && commandIsAvailable) {
if (command) {
const variables = buildCommandVariables(command, argument);
try {
await sessionActions.waitForConnectionOrThrow();
@@ -1505,15 +1584,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
providerIdToSend,
modelIdToSend,
agentNameToSend,
[],
primaryAttachments,
agentMentionName,
[{ text: instructionsText, synthetic: true }],
[...additionalParts, { text: instructionsText, synthetic: true }],
variantToSend,
inputMode,
sendMessageOptions,
);
scrollToBottom?.();
} catch (error) {
restoreConsumedInput();
toast.error(getSubmitErrorMessage(error, t(command.errorToastKey)));
}
return;
@@ -1567,12 +1647,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
inputMode,
sendMessageOptions,
);
const restoreConsumedDrafts = () => {
if (consumedDraftTarget && drafts.length > 0) {
useInlineCommentDraftStore.getState().restoreDrafts(consumedDraftTarget, drafts);
}
};
void sendPromise.then(() => {
// On a draft there is no session yet in this closure: the send path
// creates one and makes it current before resolving, so the id is
@@ -1605,27 +1679,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
console.error('Message send failed:', rawMessage || error);
restoreConsumedDrafts();
// A failed send returns the typed prompt no matter WHY it failed —
// auth, network, server, anything. Losing a long prompt to a toast
// is the one outcome this handler must never produce.
if (inputSnapshot.message) {
if (currentChatDraftIdentityRef.current !== chatDraftIdentity) {
// The user switched sessions mid-send: restore into that
// session's persisted draft, not the visible composer.
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
} else {
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
if (!currentInput || currentInput === inputSnapshot.message) {
setMessage(inputSnapshot.message);
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
} else {
// New typing already lives in the composer; the failed
// prompt joins it instead of clobbering either text.
useInputStore.getState().setPendingInputText(inputSnapshot.message, 'append');
}
}
}
restoreComposerText();
const isSoftNetworkError =
normalized.includes('timeout') ||
@@ -153,10 +153,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
{ id: 'openchamber:undo', name: 'undo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.undoDescription'), isBuiltIn: true },
{ id: 'openchamber:redo', name: 'redo', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.redoDescription'), isBuiltIn: true },
{ id: 'openchamber:timeline', name: 'timeline', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.timelineDescription'), isBuiltIn: true },
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
]
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
@@ -167,6 +167,16 @@ and the send path reading the same grammar.
mention, file mentions, and skill instruction were resolved when it was
queued, never at delivery — and its context follows it before the next
queued message.
- Local slash commands are planned by `submit/slashCommands.ts` before any
attached context is consumed. Commands that act on session or UI state
(`/undo`, `/redo`, `/compact`, `/timeline`, `/handoff-review`) take only
their command text and leave comments, files, and linked context attached;
commands that produce a prompt (`/btw` and the magic prompts) send that
context with the prompt they produce. Session actions are planned only when
a session exists, so typing one into a new-session draft stays on the normal
send path. A local command is never queued as text: queueing runs it
instead. A failed prompt command restores everything it consumed: text,
confirmed mentions, files, comment drafts, and pending synthetic context.
- `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory,
session) identity. Writes are debounced while typing but forced at every edge
where the page may stop running, because a pending timer is not a saved
@@ -334,8 +334,8 @@ describe('capturing composer context for the queue', () => {
expect(context.map((part) => part.kind)).toEqual(['context', 'synthetic', 'context', 'context', 'context', 'instruction']);
expect(context[0]?.kind).toBe('context');
expect(context[0]?.text).toContain('Comment on `src/app.ts` lines 3-5 (modified):');
expect(context[0]?.kind === 'context' ? context[0].metadata : null)
.toEqual({ [CONTEXT_METADATA_KEY]: contextPayloadFromDraft(commentDraft()) });
expect(context[0]?.kind === 'context' ? context[0].metadata[CONTEXT_METADATA_KEY] : null)
.toEqual(contextPayloadFromDraft(commentDraft()));
expect(context[3]).toEqual({
kind: 'context',
text: 'pr-diff',
@@ -6,6 +6,7 @@ import {
findMagicPromptCommand,
MAGIC_PROMPT_COMMANDS,
parseSlashCommand,
planLocalSlashCommand,
} from '../slashCommands';
describe('parseSlashCommand', () => {
@@ -62,6 +63,36 @@ describe('findMagicPromptCommand', () => {
});
});
describe('planLocalSlashCommand', () => {
test('an action command retains an attached inline comment', () => {
expect(planLocalSlashCommand('/compact', 'normal', true, true)).toEqual({
command: { name: 'compact', argument: '' },
kind: 'action',
attachedContext: 'retain',
});
});
test('prompt commands send attached context instead of disabling command parsing', () => {
expect(planLocalSlashCommand('/summary auth', 'normal', true, true)).toEqual({
command: { name: 'summary', argument: 'auth' },
kind: 'prompt',
attachedContext: 'send',
});
expect(planLocalSlashCommand('/btw why?', 'normal', true, true)?.kind).toBe('prompt');
});
test('session actions stay on the normal send path for a new-session draft', () => {
for (const command of ['compact', 'undo', 'redo', 'timeline']) {
expect(planLocalSlashCommand(`/${command}`, 'normal', false, false)).toBeNull();
}
});
test('shell mode and server-owned commands stay outside local planning', () => {
expect(planLocalSlashCommand('/compact', 'shell', true, true)).toBeNull();
expect(planLocalSlashCommand('/project-command', 'normal', true, true)).toBeNull();
});
});
describe('canRunCommand', () => {
const summary = findMagicPromptCommand('summary')!;
const explore = findMagicPromptCommand('explore')!;
@@ -136,6 +136,20 @@ export interface ParsedSlashCommand {
argument: string;
}
export type LocalSlashCommandPlan = {
command: ParsedSlashCommand;
kind: 'action' | 'prompt';
attachedContext: 'none' | 'retain' | 'send';
};
const LOCAL_ACTION_COMMANDS = new Set([
'undo',
'redo',
'timeline',
'handoff-review',
'compact',
]);
/**
* Read the leading slash command out of a message, if there is one. Only the
* first word counts as the command; the rest is its argument.
@@ -154,6 +168,42 @@ export function parseSlashCommand(text: string): ParsedSlashCommand | null {
};
}
/**
* Plan commands owned by the composer before attached context is consumed.
* Action commands leave that context in the composer; prompt commands send it.
* Unknown commands return null so the OpenCode command router remains authoritative.
*/
export function planLocalSlashCommand(
text: string,
inputMode: 'normal' | 'shell' | undefined,
hasAttachedContext: boolean,
hasSession: boolean,
): LocalSlashCommandPlan | null {
if (inputMode !== 'normal') return null;
const command = parseSlashCommand(text);
if (!command) return null;
if (LOCAL_ACTION_COMMANDS.has(command.name)) {
if (!hasSession) return null;
return {
command,
kind: 'action',
attachedContext: hasAttachedContext ? 'retain' : 'none',
};
}
if (command.name === 'btw' || findMagicPromptCommand(command.name)) {
return {
command,
kind: 'prompt',
attachedContext: hasAttachedContext ? 'send' : 'none',
};
}
return null;
}
/** The prompt-pair command for `name`, or null when it is not one. */
export function findMagicPromptCommand(name: string): MagicPromptCommand | null {
return COMMANDS_BY_NAME.get(name) ?? null;
@@ -173,7 +223,7 @@ export function canRunCommand(
export function buildCommandVariables(
command: MagicPromptCommand,
argument: string,
): { visible: Record<string, string>; instructions: Record<string, string> } {
) {
const built = command.buildVariables?.(argument) ?? {};
return {
visible: built.visible ?? {},
@@ -117,10 +117,43 @@ const shouldKeepSyntheticUserText = (text: string, planModeEnabled: boolean): bo
return false;
};
const redundantCommentFileUrls = (parts: Part[]): Set<string> => {
const comments = parts
.map((part) => readContextPart(part))
.filter((payload) => payload?.kind === 'code-comment');
if (comments.length === 0) return new Set();
const redundant = new Set<string>();
for (const part of parts) {
if (part.type !== 'file') continue;
const { url } = part;
const range = url.match(/[?&]start=(\d+)&end=(\d+)/);
if (!range) continue;
const encodedPath = url.replace(/^file:\/\//, '').split('?')[0];
let path = encodedPath;
try {
path = decodeURIComponent(encodedPath);
} catch {
// Keep the encoded path; malformed URLs must not break rendering.
}
path = path.replace(/\\/g, '/');
const matches = comments.some((comment) => {
const commentPath = comment.fileLabel.replace(/\\/g, '/');
return comment.startLine === Number(range[1])
&& comment.endLine === Number(range[2])
&& (path === commentPath || path.endsWith(`/${commentPath}`));
});
if (matches) redundant.add(url);
}
return redundant;
};
export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEnabled?: boolean }): Part[] => {
const planModeEnabled = options?.planModeEnabled === true;
const redundantFileUrls = redundantCommentFileUrls(parts);
return parts
.filter((part) => {
if (part.type === 'file' && redundantFileUrls.has(part.url)) return false;
const synthetic = (part as { synthetic?: boolean }).synthetic === true;
if (!synthetic) return true;
if (part.type !== 'text') return false;