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
@@ -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