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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d323b51a0a
commit
a12b9be443
@@ -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
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import type { Message, Part, Session } from '@opencode-ai/sdk/v2';
|
||||
import type { StartBtwInput } from './btw';
|
||||
|
||||
let forkSessionImpl: (sessionId: string, messageId?: string, directory?: string | null) => Promise<Session>;
|
||||
let getSessionMessagesImpl: (id: string, limit?: number, directory?: string | null) => Promise<Array<{ info: Message; parts: Part[] }>>;
|
||||
@@ -213,6 +214,38 @@ describe('startBtwSession', () => {
|
||||
expect(sentParts).toEqual([[{ text: BTW_BOUNDARY_INSTRUCTION, synthetic: true }]]);
|
||||
});
|
||||
|
||||
test('the first question keeps inline comment context', async () => {
|
||||
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
|
||||
const commentPart: NonNullable<StartBtwInput['additionalParts']>[number] = {
|
||||
text: 'Comment on `src/auth.ts` lines 4-4:\n```ts\nauth();\n```\n\ncheck this',
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
openchamberContext: {
|
||||
kind: 'code-comment',
|
||||
source: 'file',
|
||||
fileLabel: 'src/auth.ts',
|
||||
startLine: 4,
|
||||
endLine: 4,
|
||||
language: 'ts',
|
||||
code: 'auth();',
|
||||
text: 'check this',
|
||||
},
|
||||
},
|
||||
};
|
||||
let sentParts: unknown;
|
||||
sendMessageImpl = (...args) => {
|
||||
sentParts = args[6];
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
await startBtwSession({ ...startInput, additionalParts: [commentPart] });
|
||||
|
||||
expect(sentParts).toEqual([
|
||||
{ text: BTW_BOUNDARY_INSTRUCTION, synthetic: true },
|
||||
commentPart,
|
||||
]);
|
||||
});
|
||||
|
||||
test('an empty parent produces a marker without a boundary', async () => {
|
||||
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
|
||||
getSessionMessagesImpl = () => Promise.resolve([]);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useBtwStore } from '@/stores/useBtwStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getSyncChildStores, getSyncMessages, registerSessionDirectory } from '@/sync/sync-refs';
|
||||
import { Binary } from '@/sync/binary';
|
||||
import type { ContextPartMetadata } from '@/lib/messages/contextParts';
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
|
||||
/**
|
||||
* `/btw <question>`: fork the main session into a temporary session and send
|
||||
@@ -28,6 +30,13 @@ export type StartBtwInput = {
|
||||
modelID: string;
|
||||
agent?: string;
|
||||
variant?: string;
|
||||
attachments?: AttachedFile[];
|
||||
additionalParts?: Array<{
|
||||
text: string;
|
||||
attachments?: AttachedFile[];
|
||||
synthetic?: boolean;
|
||||
metadata?: ContextPartMetadata;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -196,12 +205,12 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
|
||||
input.providerID,
|
||||
input.modelID,
|
||||
input.agent,
|
||||
[],
|
||||
input.attachments ?? [],
|
||||
undefined,
|
||||
// The very first question already needs the boundary: the fork is at
|
||||
// its most dangerous here, with the parent's in-flight plan as the
|
||||
// newest thing in its context.
|
||||
btwBoundaryParts(),
|
||||
[...btwBoundaryParts(), ...(input.additionalParts ?? [])],
|
||||
input.variant,
|
||||
'normal',
|
||||
{ sessionId: forked.id, directory: sessionDirectory },
|
||||
|
||||
@@ -121,6 +121,43 @@ describe('round-trip through part metadata', () => {
|
||||
expect(readContextPart(part)).toEqual(payload);
|
||||
});
|
||||
|
||||
test('code comments also carry OpenCode Desktop metadata', () => {
|
||||
const payload = contextPayloadFromDraft(draft());
|
||||
const part = asPart(payload);
|
||||
expect(part.metadata.opencodeComment).toEqual({
|
||||
path: 'src/app.ts',
|
||||
selection: { startLine: 3, endLine: 5, startChar: 0, endChar: 0 },
|
||||
comment: 'fix this',
|
||||
preview: 'const x = 1;',
|
||||
origin: 'review',
|
||||
});
|
||||
expect(readContextPart(part)).toEqual(payload);
|
||||
});
|
||||
|
||||
test('reads OpenCode Desktop metadata when canonical metadata is absent', () => {
|
||||
expect(readContextPart({
|
||||
type: 'text',
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: 'src/other.ts',
|
||||
selection: { startLine: 8, endLine: 9 },
|
||||
comment: 'check this',
|
||||
preview: 'value',
|
||||
origin: 'review',
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
kind: 'code-comment',
|
||||
source: 'diff',
|
||||
fileLabel: 'src/other.ts',
|
||||
startLine: 8,
|
||||
endLine: 9,
|
||||
language: '',
|
||||
code: 'value',
|
||||
text: 'check this',
|
||||
});
|
||||
});
|
||||
|
||||
test('non-text parts, missing metadata, and malformed payloads read as null', () => {
|
||||
expect(readContextPart({ type: 'file', metadata: {} })).toBeNull();
|
||||
expect(readContextPart({ type: 'text' })).toBeNull();
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { appendTerminalContexts } from './terminalContext';
|
||||
|
||||
export const CONTEXT_METADATA_KEY = 'openchamberContext';
|
||||
const OPENCODE_COMMENT_METADATA_KEY = 'opencodeComment';
|
||||
|
||||
export type CodeCommentContext = {
|
||||
kind: 'code-comment';
|
||||
@@ -115,7 +116,18 @@ export type ContextPartPayload =
|
||||
| GitHubPrContext
|
||||
| LinearIssueContext;
|
||||
|
||||
export type ContextPartMetadata = { [K in typeof CONTEXT_METADATA_KEY]: ContextPartPayload };
|
||||
type OpenCodeCommentMetadata = {
|
||||
path: string;
|
||||
selection?: { startLine: number; endLine: number; startChar?: number; endChar?: number };
|
||||
comment: string;
|
||||
preview?: string;
|
||||
origin?: 'file' | 'review';
|
||||
};
|
||||
|
||||
export type ContextPartMetadata = {
|
||||
[CONTEXT_METADATA_KEY]: ContextPartPayload;
|
||||
[OPENCODE_COMMENT_METADATA_KEY]?: OpenCodeCommentMetadata;
|
||||
};
|
||||
|
||||
export type ContextPart = {
|
||||
text: string;
|
||||
@@ -177,10 +189,25 @@ export function formatContextText(payload: ContextPartPayload): string {
|
||||
*/
|
||||
export function createContextPart(payload: ContextPartPayload, text?: string): ContextPart {
|
||||
const resolvedText = text ?? formatContextText(payload);
|
||||
const metadata: ContextPartMetadata = { [CONTEXT_METADATA_KEY]: payload };
|
||||
if (payload.kind === 'code-comment') {
|
||||
metadata[OPENCODE_COMMENT_METADATA_KEY] = {
|
||||
path: payload.fileLabel,
|
||||
selection: {
|
||||
startLine: payload.startLine,
|
||||
endLine: payload.endLine,
|
||||
startChar: 0,
|
||||
endChar: 0,
|
||||
},
|
||||
comment: payload.text,
|
||||
preview: payload.code,
|
||||
origin: payload.source === 'diff' ? 'review' : 'file',
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: resolvedText,
|
||||
synthetic: true,
|
||||
metadata: { [CONTEXT_METADATA_KEY]: payload },
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -319,7 +346,28 @@ const contextPayloadSchema = z.discriminatedUnion('kind', [
|
||||
* Part metadata carrying a context payload, for parsing at a trust boundary
|
||||
* (a queued message coming back from the server, for instance).
|
||||
*/
|
||||
export const contextPartMetadataSchema = z.object({ [CONTEXT_METADATA_KEY]: contextPayloadSchema });
|
||||
const openCodeCommentSchema = z.object({
|
||||
path: z.string(),
|
||||
selection: z.object({
|
||||
startLine: z.number().finite(),
|
||||
endLine: z.number().finite(),
|
||||
startChar: z.number().finite().optional(),
|
||||
endChar: z.number().finite().optional(),
|
||||
}).optional(),
|
||||
comment: z.string(),
|
||||
preview: z.string().optional(),
|
||||
origin: z.enum(['file', 'review']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Part metadata carrying a context payload, for parsing at a trust boundary
|
||||
* (a queued message coming back from the server, for instance). The OpenCode
|
||||
* Desktop mirror rides along so a queued comment keeps it too.
|
||||
*/
|
||||
export const contextPartMetadataSchema = z.object({
|
||||
[CONTEXT_METADATA_KEY]: contextPayloadSchema,
|
||||
[OPENCODE_COMMENT_METADATA_KEY]: openCodeCommentSchema.optional(),
|
||||
});
|
||||
|
||||
/** The subset of a message part that context read-back inspects. */
|
||||
export type ContextCarrierPart = { type: string } & Pick<TextPart, 'metadata'>;
|
||||
@@ -332,7 +380,32 @@ export type ContextCarrierPart = { type: string } & Pick<TextPart, 'metadata'>;
|
||||
export function readContextPart(part: ContextCarrierPart): ContextPartPayload | null {
|
||||
if (part.type !== 'text') return null;
|
||||
const parsed = contextPayloadSchema.safeParse(part.metadata?.[CONTEXT_METADATA_KEY]);
|
||||
return parsed.success ? parsed.data : null;
|
||||
if (parsed.success) return parsed.data;
|
||||
|
||||
const compatible = openCodeCommentSchema.safeParse(part.metadata?.[OPENCODE_COMMENT_METADATA_KEY]);
|
||||
if (compatible.success) {
|
||||
const comment = compatible.data;
|
||||
if (!comment.selection) {
|
||||
return {
|
||||
kind: 'file-quote',
|
||||
fileLabel: comment.path,
|
||||
quote: comment.preview ?? '',
|
||||
text: comment.comment,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'code-comment',
|
||||
source: comment.origin === 'review' ? 'diff' : 'file',
|
||||
fileLabel: comment.path,
|
||||
startLine: comment.selection.startLine,
|
||||
endLine: comment.selection.endLine,
|
||||
language: '',
|
||||
code: comment.preview ?? '',
|
||||
text: comment.comment,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a message carries any user-attached context part. */
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { useInlineCommentDraftStore } from './useInlineCommentDraftStore';
|
||||
|
||||
const comment = {
|
||||
source: 'file' as const,
|
||||
fileLabel: 'src/app.ts:12',
|
||||
startLine: 12,
|
||||
endLine: 12,
|
||||
code: 'const x = 1',
|
||||
language: 'typescript',
|
||||
text: 'fix this',
|
||||
};
|
||||
const target = { directory: '/repo', sessionKey: 'session-1' };
|
||||
|
||||
const store = () => useInlineCommentDraftStore.getState();
|
||||
|
||||
describe('caller-provided draft ids', () => {
|
||||
afterEach(() => { useInlineCommentDraftStore.setState({ drafts: {}, touchedAt: {} }); });
|
||||
|
||||
test('a caller that owns its own view of the draft chooses the id', () => {
|
||||
// The VS Code editor thread mints the id so it can track its draft without
|
||||
// waiting for a round trip.
|
||||
const id = store().addDraft(target, { ...comment, id: 'icd-editor-thread' });
|
||||
expect(id).toBe('icd-editor-thread');
|
||||
expect(store().getDrafts(target)[0].id).toBe('icd-editor-thread');
|
||||
});
|
||||
|
||||
test('the chosen id is what removal and lookup accept', () => {
|
||||
store().addDraft(target, { ...comment, id: 'icd-editor-thread' });
|
||||
store().removeDraft(target, 'icd-editor-thread');
|
||||
expect(store().getDrafts(target)).toEqual([]);
|
||||
});
|
||||
|
||||
test('omitting the id still generates one', () => {
|
||||
const id = store().addDraft(target, comment);
|
||||
expect(/^icd-\d+-\w+$/.test(id ?? '')).toBe(true);
|
||||
});
|
||||
|
||||
test('a blank id is ignored rather than stored', () => {
|
||||
const id = store().addDraft(target, { ...comment, id: ' ' });
|
||||
expect(/^icd-\d+-\w+$/.test(id ?? '')).toBe(true);
|
||||
});
|
||||
|
||||
test('a colliding id is refused, so edits cannot retarget another draft', () => {
|
||||
const first = store().addDraft(target, { ...comment, id: 'icd-duplicate' });
|
||||
const second = store().addDraft(target, { ...comment, id: 'icd-duplicate', text: 'different' });
|
||||
|
||||
expect(first).toBe('icd-duplicate');
|
||||
expect(second).not.toBe('icd-duplicate');
|
||||
|
||||
const drafts = store().getDrafts(target);
|
||||
expect(drafts).toHaveLength(2);
|
||||
expect(new Set(drafts.map((draft) => draft.id)).size).toBe(2);
|
||||
});
|
||||
|
||||
test('the same id may be reused once its draft is gone', () => {
|
||||
store().addDraft(target, { ...comment, id: 'icd-reused' });
|
||||
store().removeDraft(target, 'icd-reused');
|
||||
expect(store().addDraft(target, { ...comment, id: 'icd-reused' })).toBe('icd-reused');
|
||||
});
|
||||
|
||||
test('an id taken in another session does not collide', () => {
|
||||
const other = { directory: '/repo', sessionKey: 'session-2' };
|
||||
store().addDraft(target, { ...comment, id: 'icd-shared' });
|
||||
expect(store().addDraft(other, { ...comment, id: 'icd-shared' })).toBe('icd-shared');
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,11 @@ interface InlineCommentDraftState {
|
||||
}
|
||||
|
||||
interface InlineCommentDraftActions {
|
||||
addDraft: (target: InlineCommentDraftTarget, draft: Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'>) => string | null;
|
||||
// Returns the new draft id, or null when the draft is rejected (unresolved
|
||||
// target, bounds eviction, or an empty terminal-context selection).
|
||||
// `id` lets an external owner (the VS Code editor comment thread) choose the
|
||||
// draft id up front. Omitted by every in-app caller, which gets a generated one.
|
||||
addDraft: (target: InlineCommentDraftTarget, draft: Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'> & { id?: string }) => string | null;
|
||||
updateDraft: (target: InlineCommentDraftTarget, draftId: string, updates: Partial<Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'>>) => void;
|
||||
removeDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
|
||||
clearDrafts: (target: InlineCommentDraftTarget) => void;
|
||||
@@ -241,7 +245,15 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
|
||||
addDraft: (target, draft) => {
|
||||
const key = getCurrentKey(target);
|
||||
if (!key || (draft.source === 'terminal' && !draft.code.trim())) return null;
|
||||
const id = `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
// A caller that owns its own view of the draft (the VS Code editor
|
||||
// thread) supplies the id so it can correlate without a round trip.
|
||||
// A colliding id would silently retarget edits and removals at an
|
||||
// unrelated draft, so it is refused rather than reused.
|
||||
const requestedId = draft.id?.trim();
|
||||
const idIsTaken = Boolean(requestedId) && (get().drafts[key] ?? []).some((item) => item.id === requestedId);
|
||||
const id = requestedId && !idIsTaken
|
||||
? requestedId
|
||||
: `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const nextDraft: InlineCommentDraft = { ...draft, sessionKey: target.sessionKey, id, createdAt: Date.now() };
|
||||
let accepted = false;
|
||||
set((state) => {
|
||||
|
||||
@@ -291,6 +291,7 @@ Rules:
|
||||
9. `SessionLiveActivity` has three answers and `unknown` is never `idle`. `getSessionLiveActivity` reports `active` when any child store or the global session-status index holds a non-idle status, `idle` only when a child store actually covers the session's directory, and `unknown` otherwise — child stores are evicted for background directories, and the global index keeps only non-idle entries, so absence of a status is not proof of idleness. Callers that gate a destructive action (worktree moves) must refuse on `unknown`.
|
||||
10. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo.
|
||||
11. Starting a session from an assistant answer carries the source session ID, rendered directory, and answer text into the action. It must not rediscover that context from the globally active child store or the OpenCode client's fallback directory: the visible session may belong to an existing worktree while the active provider directory points elsewhere. New isolated worktrees resolve their registered parent project from that captured directory, preferring recorded worktree metadata when available. The dialog offers creation only after the project root is confirmed as a Git repository, and the creation boundary repeats that check so stale or bypassed UI state cannot run Git commands against a non-repository directory; failures leave the dialog open and visible.
|
||||
12. OpenCode commands and skills keep the authoritative `session.command` route when their only additional part is explicitly tagged session knowledge. Every other additional part, including unstructured synthetic conflict instructions, requires the prompt route; primary file attachments remain supported by `session.command`. Because session knowledge cannot be forwarded through the command route, it remains pending for the session's next prompt instead of being marked as delivered.
|
||||
|
||||
Examples of global-store updates performed in `session-actions.ts`:
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { createContextPart } from '@/lib/messages/contextParts';
|
||||
|
||||
/**
|
||||
* Unit tests for session worktree routing through the authoritative store.
|
||||
@@ -956,6 +957,135 @@ describe('routeMessage skill invocation', () => {
|
||||
expect(sendCommandCalls[0].arguments).toBe('focus on auth');
|
||||
});
|
||||
|
||||
test('preserves context parts and skill invocation on the prompt route', async () => {
|
||||
useSkillsStore.setState({
|
||||
skills: [{ name: 'grill-with-docs', path: '/skills/grill-with-docs/SKILL.md', scope: 'user', source: 'opencode' }],
|
||||
});
|
||||
const additionalParts = [createContextPart({
|
||||
kind: 'code-comment',
|
||||
source: 'file',
|
||||
fileLabel: 'src/auth.ts',
|
||||
startLine: 4,
|
||||
endLine: 4,
|
||||
language: 'ts',
|
||||
code: 'auth();',
|
||||
text: 'check this',
|
||||
})];
|
||||
|
||||
await routeMessage({
|
||||
sessionId: 'session-skill',
|
||||
directory: '/skills/project',
|
||||
content: '/grill-with-docs focus on auth',
|
||||
providerID: 'provider-a',
|
||||
modelID: 'model-a',
|
||||
additionalParts,
|
||||
});
|
||||
|
||||
expect(sendCommandCalls).toHaveLength(0);
|
||||
expect(sendMessageCalls).toHaveLength(1);
|
||||
expect(sendMessageCalls[0].additionalParts[0]).toEqual(additionalParts[0]);
|
||||
expect(sendMessageCalls[0].additionalParts[1]).toMatchObject({ synthetic: true });
|
||||
expect(sendMessageCalls[0].additionalParts[1].text).toContain('grill-with-docs skill');
|
||||
});
|
||||
|
||||
test('expands a contextual command template on the prompt route', async () => {
|
||||
useCommandsStore.setState({
|
||||
commands: [{ name: 'inspect', template: 'Inspect $ARGUMENTS carefully.' }],
|
||||
});
|
||||
|
||||
await routeMessage({
|
||||
sessionId: 'session-command',
|
||||
directory: '/skills/project',
|
||||
content: '/inspect auth flow',
|
||||
providerID: 'provider-a',
|
||||
modelID: 'model-a',
|
||||
additionalParts: [createContextPart({
|
||||
kind: 'code-comment',
|
||||
source: 'file',
|
||||
fileLabel: 'src/auth.ts',
|
||||
startLine: 4,
|
||||
endLine: 4,
|
||||
language: 'ts',
|
||||
code: 'auth();',
|
||||
text: 'check this',
|
||||
})],
|
||||
});
|
||||
|
||||
expect(sendCommandCalls).toHaveLength(0);
|
||||
expect(sendMessageCalls).toHaveLength(1);
|
||||
expect(sendMessageCalls[0].text).toBe('Inspect auth flow carefully.');
|
||||
expect(sendMessageCalls[0].additionalParts[0].metadata.openchamberContext.kind).toBe('code-comment');
|
||||
});
|
||||
|
||||
test('keeps session.command when the only extra part is pinned knowledge', async () => {
|
||||
useSkillsStore.setState({
|
||||
skills: [{ name: 'grill-with-docs', path: '/skills/grill-with-docs/SKILL.md', scope: 'user', source: 'opencode' }],
|
||||
});
|
||||
|
||||
const route = await routeMessage({
|
||||
sessionId: 'session-skill',
|
||||
directory: '/skills/project',
|
||||
content: '/grill-with-docs focus on auth',
|
||||
providerID: 'provider-a',
|
||||
modelID: 'model-a',
|
||||
additionalParts: [{ text: 'Pinned project knowledge', synthetic: true, systemContext: 'session-knowledge' }],
|
||||
});
|
||||
|
||||
expect(sendCommandCalls).toHaveLength(1);
|
||||
expect(sendCommandCalls[0].command).toBe('grill-with-docs');
|
||||
expect(sendCommandCalls[0].arguments).toBe('focus on auth');
|
||||
expect(sendMessageCalls).toHaveLength(0);
|
||||
expect(route).toBe('command');
|
||||
});
|
||||
|
||||
test('keeps primary file attachments on the command route', async () => {
|
||||
useSkillsStore.setState({
|
||||
skills: [{ name: 'grill-with-docs', path: '/skills/grill-with-docs/SKILL.md', scope: 'user', source: 'opencode' }],
|
||||
});
|
||||
const files = [{
|
||||
type: 'file',
|
||||
mime: 'text/plain',
|
||||
url: 'file:///projects/alpha/auth.txt',
|
||||
filename: 'auth.txt',
|
||||
}];
|
||||
|
||||
const route = await routeMessage({
|
||||
sessionId: 'session-skill',
|
||||
directory: '/skills/project',
|
||||
content: '/grill-with-docs focus on auth',
|
||||
providerID: 'provider-a',
|
||||
modelID: 'model-a',
|
||||
files,
|
||||
additionalParts: [{ text: 'Pinned project knowledge', synthetic: true, systemContext: 'session-knowledge' }],
|
||||
});
|
||||
|
||||
expect(route).toBe('command');
|
||||
expect(sendCommandCalls).toHaveLength(1);
|
||||
expect(sendCommandCalls[0].files).toEqual(files);
|
||||
expect(sendMessageCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('keeps unmarked synthetic instructions on the prompt route', async () => {
|
||||
useSkillsStore.setState({
|
||||
skills: [{ name: 'grill-with-docs', path: '/skills/grill-with-docs/SKILL.md', scope: 'user', source: 'opencode' }],
|
||||
});
|
||||
const instructions = [{ text: 'Resolve the prepared conflict first.', synthetic: true }];
|
||||
|
||||
const route = await routeMessage({
|
||||
sessionId: 'session-skill',
|
||||
directory: '/skills/project',
|
||||
content: '/grill-with-docs focus on auth',
|
||||
providerID: 'provider-a',
|
||||
modelID: 'model-a',
|
||||
additionalParts: instructions,
|
||||
});
|
||||
|
||||
expect(route).toBe('prompt');
|
||||
expect(sendCommandCalls).toHaveLength(0);
|
||||
expect(sendMessageCalls).toHaveLength(1);
|
||||
expect(sendMessageCalls[0].additionalParts[0]).toEqual(instructions[0]);
|
||||
});
|
||||
|
||||
test('sends an unknown slash token as a plain message', async () => {
|
||||
await routeMessage({
|
||||
sessionId: 'session-skill',
|
||||
|
||||
@@ -126,7 +126,7 @@ export function expandSlashCommandGoalObjective(content: string, commands: GoalC
|
||||
// Send routing — shell mode, slash commands, or normal prompt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function routeMessage(params: {
|
||||
export async function routeMessage(params: {
|
||||
runtimeKey?: string
|
||||
sessionId: string
|
||||
directory?: string | null
|
||||
@@ -138,19 +138,22 @@ export function routeMessage(params: {
|
||||
variant?: string
|
||||
inputMode?: "normal" | "shell"
|
||||
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
|
||||
additionalParts?: Array<{ text: string; synthetic?: boolean; metadata?: ContextPartMetadata; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }>
|
||||
additionalParts?: Array<{ text: string; synthetic?: boolean; metadata?: ContextPartMetadata; files?: Array<{ type: "file"; mime: string; url: string; filename: string }>; systemContext?: 'session-knowledge' }>
|
||||
delivery?: 'steer'
|
||||
}): Promise<void> {
|
||||
}): Promise<'command' | 'prompt' | 'shell'> {
|
||||
const requestDirectory = params.directory ?? undefined
|
||||
let promptContent = params.content
|
||||
let promptAdditionalParts = params.additionalParts
|
||||
if (params.inputMode === "shell") {
|
||||
return opencodeClient.shellSession({
|
||||
await opencodeClient.shellSession({
|
||||
runtimeKey: params.runtimeKey,
|
||||
sessionId: params.sessionId,
|
||||
directory: requestDirectory,
|
||||
agent: params.agent ?? "",
|
||||
model: { providerID: params.providerID, modelID: params.modelID },
|
||||
command: params.content,
|
||||
}).then(() => undefined)
|
||||
})
|
||||
return 'shell'
|
||||
}
|
||||
|
||||
// Slash commands — fire and forget, SSE delivers messages and status
|
||||
@@ -165,41 +168,65 @@ export function routeMessage(params: {
|
||||
// OpenCode registers every skill as a command (source: "skill"), but the
|
||||
// commands store filters skills out and the synced command list is only
|
||||
// hydrated at bootstrap. Consult the live skills store so a skill selected
|
||||
// from the slash menu is invoked via session.command (injecting its
|
||||
// content) instead of being sent as a literal "/name" message (#1605).
|
||||
const isCommand = syncCommands.find((c) => c.name === cmdName)
|
||||
// from the slash menu keeps its invocation semantics (#1605).
|
||||
const matchedCommand = syncCommands.find((c) => c.name === cmdName)
|
||||
|| storeCommands.find((c) => c.name === cmdName)
|
||||
|| useSkillsStore.getState().skills.some((s) => s.name === cmdName)
|
||||
const matchedSkill = useSkillsStore.getState().skills.find((s) => s.name === cmdName)
|
||||
|
||||
if (isCommand) {
|
||||
return optimisticSend({
|
||||
runtimeKey: params.runtimeKey,
|
||||
sessionId: params.sessionId,
|
||||
content: params.content,
|
||||
providerID: params.providerID,
|
||||
modelID: params.modelID,
|
||||
agent: params.agent,
|
||||
directory: requestDirectory,
|
||||
files: params.files,
|
||||
send: (messageID) => opencodeClient.sendCommand({
|
||||
if (matchedCommand || matchedSkill) {
|
||||
// Pinned project knowledge is the only additional part that does not
|
||||
// change command semantics. Other synthetic parts may carry prepared
|
||||
// user work (for example conflict instructions) and must not be dropped.
|
||||
const additionalPartsRequirePrompt = params.additionalParts?.some((part) => (
|
||||
part.systemContext !== 'session-knowledge'
|
||||
)) ?? false
|
||||
if (!additionalPartsRequirePrompt) {
|
||||
await optimisticSend({
|
||||
runtimeKey: params.runtimeKey,
|
||||
id: params.sessionId,
|
||||
sessionId: params.sessionId,
|
||||
content: params.content,
|
||||
providerID: params.providerID,
|
||||
modelID: params.modelID,
|
||||
command: cmdName,
|
||||
arguments: tail.join(" "),
|
||||
agent: params.agent,
|
||||
variant: params.variant,
|
||||
files: params.files,
|
||||
messageId: messageID,
|
||||
directory: requestDirectory,
|
||||
}).then(() => {}),
|
||||
})
|
||||
files: params.files,
|
||||
send: (messageID) => opencodeClient.sendCommand({
|
||||
runtimeKey: params.runtimeKey,
|
||||
id: params.sessionId,
|
||||
providerID: params.providerID,
|
||||
modelID: params.modelID,
|
||||
command: cmdName,
|
||||
arguments: tail.join(" "),
|
||||
agent: params.agent,
|
||||
variant: params.variant,
|
||||
files: params.files,
|
||||
messageId: messageID,
|
||||
directory: requestDirectory,
|
||||
}).then(() => {}),
|
||||
})
|
||||
return 'command'
|
||||
}
|
||||
|
||||
// session.command accepts file parts only. Keep structured context on
|
||||
// the prompt route, expanding templates locally when available and
|
||||
// preserving skill invocation as an explicit synthetic instruction.
|
||||
if (matchedCommand?.template?.trim()) {
|
||||
promptContent = expandSlashCommandGoalObjective(params.content, [matchedCommand])
|
||||
}
|
||||
if (matchedSkill) {
|
||||
promptAdditionalParts = [
|
||||
...(params.additionalParts ?? []),
|
||||
{
|
||||
text: `The user explicitly invoked the ${cmdName} skill. Use the corresponding skill tool to handle this request.`,
|
||||
synthetic: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal prompt — optimistic insert so message appears instantly
|
||||
return optimisticSend({
|
||||
await optimisticSend({
|
||||
runtimeKey: params.runtimeKey,
|
||||
sessionId: params.sessionId,
|
||||
content: params.content,
|
||||
@@ -213,17 +240,23 @@ export function routeMessage(params: {
|
||||
id: params.sessionId,
|
||||
providerID: params.providerID,
|
||||
modelID: params.modelID,
|
||||
text: params.content,
|
||||
text: promptContent,
|
||||
agent: params.agent,
|
||||
agentMentions: params.agentMentionName ? [{ name: params.agentMentionName }] : undefined,
|
||||
variant: params.variant,
|
||||
files: params.files,
|
||||
additionalParts: params.additionalParts,
|
||||
additionalParts: promptAdditionalParts?.map((part) => ({
|
||||
text: part.text,
|
||||
synthetic: part.synthetic,
|
||||
metadata: part.metadata,
|
||||
files: part.files,
|
||||
})),
|
||||
delivery: params.delivery,
|
||||
messageId: messageID,
|
||||
directory: requestDirectory,
|
||||
}).then(() => {}),
|
||||
})
|
||||
return 'prompt'
|
||||
}
|
||||
|
||||
type CapturedSendTarget = {
|
||||
@@ -365,7 +398,7 @@ export type SessionUIState = {
|
||||
agent?: string,
|
||||
attachments?: AttachedFile[],
|
||||
agentMentionName?: string,
|
||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }>,
|
||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata; systemContext?: 'session-knowledge' }>,
|
||||
variant?: string,
|
||||
inputMode?: "normal" | "shell",
|
||||
options?: SendMessageOptions,
|
||||
@@ -1583,7 +1616,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
agent?: string,
|
||||
attachments?: AttachedFile[],
|
||||
agentMentionName?: string,
|
||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }>,
|
||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata; systemContext?: 'session-knowledge' }>,
|
||||
variant?: string,
|
||||
inputMode?: "normal" | "shell",
|
||||
options?: SendMessageOptions,
|
||||
@@ -1662,7 +1695,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
}, options?.draftSnapshot)
|
||||
if (!createdDraftSession) throw new Error("Failed to create session")
|
||||
|
||||
const draftParts = createdDraftSession.syntheticParts?.length
|
||||
const draftParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata; systemContext?: 'session-knowledge' }> | undefined = createdDraftSession.syntheticParts?.length
|
||||
? [...(additionalParts || []), ...createdDraftSession.syntheticParts]
|
||||
: additionalParts
|
||||
// The server decides what this session still owes and assembles it; the
|
||||
@@ -1671,8 +1704,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
createdDraftSession.directory,
|
||||
createdDraftSession.sessionId,
|
||||
)
|
||||
const draftPrefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }> =
|
||||
draftKnowledge.text ? [{ text: draftKnowledge.text, synthetic: true }] : []
|
||||
const draftPrefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata; systemContext?: 'session-knowledge' }> =
|
||||
draftKnowledge.text ? [{ text: draftKnowledge.text, synthetic: true, systemContext: 'session-knowledge' }] : []
|
||||
// Left undefined when nothing was added, as before: an empty array is not
|
||||
// the same as no additional parts to everything downstream.
|
||||
const mergedAdditionalParts = draftPrefixParts.length > 0
|
||||
@@ -1691,7 +1724,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
}))
|
||||
|
||||
await applyArmedGoal(createdDraftSession.sessionId, createdDraftSession.directory)
|
||||
await routeMessage({
|
||||
const messageRoute = await routeMessage({
|
||||
sessionId: createdDraftSession.sessionId,
|
||||
directory: createdDraftSession.directory,
|
||||
content,
|
||||
@@ -1707,6 +1740,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
text: p.text,
|
||||
synthetic: p.synthetic,
|
||||
metadata: p.metadata,
|
||||
systemContext: p.systemContext,
|
||||
files: p.attachments?.map((a: AttachedFile) => ({
|
||||
type: "file" as const,
|
||||
mime: a.mimeType,
|
||||
@@ -1717,7 +1751,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
})
|
||||
// Recorded only after the send resolves: a failed send must carry the
|
||||
// pinned context again rather than assume the agent already saw it.
|
||||
if (draftKnowledge.text) {
|
||||
if (draftKnowledge.text && messageRoute === 'prompt') {
|
||||
void reportSessionKnowledgeDelivered(
|
||||
createdDraftSession.directory,
|
||||
createdDraftSession.sessionId,
|
||||
@@ -1794,13 +1828,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
// Prepended so it reads as background before the message it accompanies,
|
||||
// and empty unless the session is actually missing it.
|
||||
const knowledge = await fetchSessionKnowledge(currentSessionDirectory, targetSessionId || "")
|
||||
const prefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }> =
|
||||
knowledge.text ? [{ text: knowledge.text, synthetic: true }] : []
|
||||
const prefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata; systemContext?: 'session-knowledge' }> =
|
||||
knowledge.text ? [{ text: knowledge.text, synthetic: true, systemContext: 'session-knowledge' }] : []
|
||||
const partsWithPinnedContext = prefixParts.length > 0
|
||||
? [...prefixParts, ...(additionalParts || [])]
|
||||
: additionalParts
|
||||
|
||||
await routeMessage({
|
||||
const messageRoute = await routeMessage({
|
||||
runtimeKey: capturedTarget?.runtimeKey,
|
||||
sessionId: targetSessionId || "",
|
||||
directory: currentSessionDirectory,
|
||||
@@ -1817,6 +1851,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
text: p.text,
|
||||
synthetic: p.synthetic,
|
||||
metadata: p.metadata,
|
||||
systemContext: p.systemContext,
|
||||
files: p.attachments?.map((a) => ({
|
||||
type: "file" as const,
|
||||
mime: a.mimeType,
|
||||
@@ -1825,7 +1860,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
})),
|
||||
})),
|
||||
})
|
||||
if (knowledge.text) {
|
||||
if (knowledge.text && messageRoute === 'prompt') {
|
||||
void reportSessionKnowledgeDelivered(currentSessionDirectory, targetSessionId || "", knowledge.signature)
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user