From b97f2d05bb9d810c125d1c2f075eaf3ca58d4235 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 18:32:45 +0300 Subject: [PATCH] fix(chat): restore a message's attached context on revert and fork Review comments, quotes, terminal selections and annotations were consumed at send and never put back, so reverting pulled the message into the composer without the context it was sent with. Claude-Session: https://claude.ai/code/session_01TwLFeTfBnWdvbg9XyezZQx --- CHANGELOG.md | 25 +++---- packages/ui/src/lib/messages/contextParts.ts | 75 ++++++++++++++++++++ packages/ui/src/sync/session-actions.ts | 46 ++++++++++++ 3 files changed, 134 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 909fb3be..3d881e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- **Linear integration:** connect a Linear workspace in Settings → Integrations, browse its issues in the context rail with status, priority, assignee, and team filters, and start a session or worktree straight from an issue. Sessions started that way post started, completed, and failed comments on the issue, each linking back to the session; chat can also attach an issue to the next send (thanks to @AlexKutas). -- **Voice: the voice follows the language of the text.** With "Match the voice to the language of the text" (Settings → Voice, on by default) the local provider switches to a model for the reply's language — Kokoro for Chinese/English and Piper models for Ukrainian, German, French, Spanish, Italian, Portuguese, Polish, Russian, Dutch, Czech, Turkish, and Swedish, downloaded on first use — and macOS say switches to an installed voice of that language. The local voice picker lists every installed model's voices. -- **Git: projects made of several repositories.** A project whose root is not itself a Git repository — a folder of plugins, a workspace of services — now works with the repositories inside it. The Git tab opens the first one it finds, a picker next to the branch dropdown switches between them, and the diff, pull request, walkthrough, and mobile Changes views follow the same choice; the work status card shows the chosen repository's branch and changes with the folder named under the branch (thanks to @jaygupta17). -- **Chat:** sessions opened from the sidebar stay at the latest message, and switching between sessions no longer causes conversation jumps, partial rendering, crossfades, or tab-title shifts. -- Chat: command and skill autocomplete in a Chat (a session that belongs to no project) lists that chat's own commands and skills instead of the project last selected in the sidebar, and file mentions in a new chat draft no longer search the previous project. -- Files: Ctrl/Cmd+F opens the find bar in the Markdown preview even when nothing inside the preview has focus. -- GitHub: account connection has moved to Settings → Integrations. The pull-request panel now includes account controls, and its context-rail icon appears only when GitHub is connected. -- Chat: a turn that OpenCode stopped no longer ends with nothing on screen — what OpenCode reported shows under the last message, and a message an idle session has left unanswered is named as such. The status report (Ctrl/Cmd+Shift+L) now lists the last session errors, rejected sends, the managed OpenCode process's last error, and where the log files are. -- Git: the commit graph no longer leaves a gap in a lane when the same branch is merged twice (thanks to @Naputt1). -- Settings: the theme is now remembered per OpenChamber instance. Two windows connected to different instances no longer swap themes with each other or overwrite each other's choice on every settings sync; each window boots with the theme of the instance it points at (thanks to @kydorn). -- Scheduled tasks: a task's Goal and Auto-accept settings no longer disappear after a run when another OpenChamber process — an older desktop, CLI, or VS Code build — shares the same project config. Every process now rewrites only what it changed and leaves the rest of each task exactly as stored. -- Desktop: on Windows and Linux the close button sits flush against the window edge, so the exact top-right corner closes the window, and its hover color follows the theme (thanks to @kydorn). +- **Linear integration:** connect a workspace in Settings → Integrations, browse and filter issues, and start a session or worktree from an issue. OpenChamber reports session progress back to Linear and can attach an issue to the next chat message (thanks to @AlexKutas). +- **Voice:** local text-to-speech and macOS say now choose a voice that matches the reply's language. Additional local models download on first use, and the voice picker lists voices from every installed model. +- **Git:** projects containing several repositories can now switch between them from the Git tab. Diff, pull request, walkthrough, mobile Changes, and work status follow the selected repository (thanks to @jaygupta17). +- **Chat:** sessions opened from the sidebar stay at the latest message, and switching sessions no longer causes jumps, partial rendering, crossfades, or tab-title shifts. +- Chat: command, skill, and file autocomplete in projectless chats no longer uses the previously selected project. +- Chat: reverting to a message, or forking from one, now brings its attached context back to the composer — review comments, chat and file quotes, terminal selections, and browser annotations are no longer lost. +- Chat: stopped and unanswered turns now explain what happened. The status report includes recent session, send, and managed OpenCode errors, plus log locations. +- Files: Ctrl/Cmd+F opens search in the Markdown preview even when the preview is not focused. +- GitHub: account connection has moved to Settings → Integrations. The pull-request panel includes account controls, and its context-rail icon appears only when connected. +- Git: the commit graph no longer leaves a lane gap when the same branch is merged twice (thanks to @Naputt1). +- Settings: themes are now remembered per OpenChamber instance, so windows connected to different instances keep their own theme (thanks to @kydorn). +- Scheduled tasks: Goal, Auto-accept, and other task settings are preserved when older OpenChamber builds share the same project config. +- Desktop: on Windows and Linux, the close button reaches the top-right corner and follows the theme on hover (thanks to @kydorn). ## [1.21.1] - 2026-08-29 diff --git a/packages/ui/src/lib/messages/contextParts.ts b/packages/ui/src/lib/messages/contextParts.ts index 8ec46a2c..4b6b78a9 100644 --- a/packages/ui/src/lib/messages/contextParts.ts +++ b/packages/ui/src/lib/messages/contextParts.ts @@ -333,3 +333,78 @@ export function readContextPart(part: ContextCarrierPart): ContextPartPayload | export function hasContextParts(parts: ContextCarrierPart[]): boolean { return parts.some((part) => readContextPart(part) !== null); } + +/** + * The composer draft a context payload came from, so reverting or forking a + * message can put its attached context back on the chips instead of dropping + * it. Linked issues/PRs have no draft form — they are owned by their own + * pickers — so they map to null. + */ +export function draftFromContextPayload( + payload: ContextPartPayload, +): Omit | null { + switch (payload.kind) { + case 'code-comment': { + const draft: Omit = { + source: payload.source, + fileLabel: payload.fileLabel, + startLine: payload.startLine, + endLine: payload.endLine, + code: payload.code, + language: payload.language, + text: payload.text, + }; + if (payload.side) draft.side = payload.side; + return draft; + } + case 'terminal': + return { + source: 'terminal', + fileLabel: payload.terminalLabel, + startLine: payload.startLine, + endLine: payload.endLine, + code: payload.output, + language: '', + text: '', + terminalId: payload.terminalId, + }; + case 'browser-annotation': + return { + source: 'preview-annotation', + fileLabel: payload.pageUrl, + startLine: 0, + endLine: 0, + code: payload.prompt, + language: '', + text: payload.text, + }; + case 'pr-comment': + return { source: 'pr-comment', fileLabel: payload.label, startLine: 0, endLine: 0, code: payload.body, language: '', text: payload.text }; + case 'pr-check': + return { source: 'pr-check', fileLabel: payload.label, startLine: 0, endLine: 0, code: payload.output, language: '', text: payload.text }; + case 'file-quote': + return { + source: 'file-quote', + fileLabel: payload.fileLabel, + startLine: payload.startLine ?? 0, + endLine: payload.endLine ?? 0, + code: payload.quote, + language: '', + text: payload.text, + }; + case 'chat-quote': + return { + source: 'chat-quote', + fileLabel: payload.messageId ?? '', + startLine: 0, + endLine: 0, + code: payload.quote, + language: '', + text: payload.text, + }; + case 'github-issue': + case 'github-pr': + case 'linear-issue': + return null; + } +} diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index b1e5aa2f..c208e0cc 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -16,6 +16,8 @@ import { registerSessionDirectory } from "./sync-refs" import { useGlobalSessionStatusStore } from "./global-session-status" import { recordSendFailure } from "./send-failure-log" import { isSyntheticPart } from "@/lib/messages/synthetic" +import { draftFromContextPayload, readContextPart, type ContextCarrierPart } from "@/lib/messages/contextParts" +import { useInlineCommentDraftStore, type InlineCommentDraftTarget } from "@/stores/useInlineCommentDraftStore" import { materializeSessionSnapshots } from "./materialization" import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize" import { sessionEvents } from "@/lib/sessionEvents" @@ -598,6 +600,32 @@ function restoreFilePartsToInput(fileParts: Array>): voi } } +/** + * Put a message's attached context (review comments, quotes, terminal + * selections, annotations) back on the composer chips. + * + * Context rides out as synthetic parts carrying structured metadata, so a + * reverted or forked message can be rebuilt into the drafts it came from. + * Without this the context is simply gone: the message is pulled back into the + * composer with its text and files, but the comments attached to it are not. + * + * The target's existing drafts are replaced, matching how text and file + * attachments are restored — the composer ends up as the message was sent. + */ +function restoreContextPartsToInput( + parts: readonly ContextCarrierPart[], + target: InlineCommentDraftTarget, +): void { + const store = useInlineCommentDraftStore.getState() + store.clearDrafts(target) + for (const part of parts) { + const payload = readContextPart(part) + if (!payload) continue + const draft = draftFromContextPayload(payload) + if (draft) store.addDraft(target, draft) + } +} + /** * Server-confirmed directory that owns a session, from the session record * (`directory`, then `project.worktree`). Mirrors the authoritative source in @@ -1985,6 +2013,7 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro const targetMsg = messages.find((m) => m.id === messageId) let messageText = "" let submittedFileParts: Array> = [] + let submittedContextParts: readonly ContextCarrierPart[] = [] if (targetMsg && targetMsg.role === "user") { const parts = state.part[messageId] ?? [] const textParts = parts.filter((p) => p.type === "text" && !isSyntheticPart(p)) @@ -1996,6 +2025,9 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro // Exclude synthetic file parts (server-generated file content that should // not be restored to the composer). submittedFileParts = parts.filter((p) => p.type === "file" && !isSyntheticPart(p)) as Array> + // Attached context (review comments, quotes, terminal selections) rides in + // synthetic text parts and belongs back on the composer chips. + submittedContextParts = parts } // Optimistically set only the revert marker. Keep messages and parts in the @@ -2023,6 +2055,10 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro const prevInputAttachments = [...useInputStore.getState().attachedFiles] const prevInputText = useInputStore.getState().pendingInputText const prevInputMode = useInputStore.getState().pendingInputMode + const draftTarget: InlineCommentDraftTarget | null = directory + ? { directory, sessionKey: sessionId } + : null + const prevDrafts = draftTarget ? useInlineCommentDraftStore.getState().getDrafts(draftTarget) : [] // Restore reverted message text and file attachments to input if (messageText) { @@ -2036,6 +2072,7 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro // Clear existing attachments first — previous revert's attachments // must not carry over, even when the current message has no files. restoreFilePartsToInput(submittedFileParts) + if (draftTarget) restoreContextPartsToInput(submittedContextParts, draftTarget) // Call SDK and merge authoritative result into store try { @@ -2070,6 +2107,10 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro pendingInputMode: prevInputMode, attachedFiles: prevInputAttachments, }) + if (draftTarget) { + useInlineCommentDraftStore.getState().clearDrafts(draftTarget) + useInlineCommentDraftStore.getState().restoreDrafts(draftTarget, prevDrafts) + } throw err } } @@ -2192,6 +2233,11 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro } // Clear existing attachments and restore file parts from the forked message. restoreFilePartsToInput(fileParts) + // The forked session is a fresh draft target, so the attached context of the + // forked message follows the text into its composer. + if (directory) { + restoreContextPartsToInput(parts, { directory, sessionKey: forkedSession.id }) + } } export async function fetchMessagesForSession(sessionID: string, directory?: string | null): Promise {