diff --git a/desktop/app.go b/desktop/app.go index c8a21f3f7..434ce2596 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -579,6 +579,21 @@ func (a *App) SubmitDisplayToTab(tabID, display, input string) { ctrl.SubmitDisplay(display, input) } +// SubmitDisplayToTabWithRefs submits a message with display text and persists +// the user's past:chat references into the sidecar so they survive reload. +func (a *App) SubmitDisplayToTabWithRefs(tabID, display, input string, references json.RawMessage) { + ctrl := a.ctrlByTabID(tabID) + if ctrl == nil { + return + } + ctrl.SubmitDisplay(display, input) + dir := ctrl.SessionDir() + if dir == "" { + dir = config.SessionDir() + } + _ = recordSessionReferences(dir, ctrl.SessionPath(), input, references) +} + func (a *App) bindControllerDisplayRecorder(ctrl *control.Controller) { if ctrl == nil { return @@ -1505,6 +1520,7 @@ type HistoryMessage struct { Messages int `json:"messages,omitempty"` Summary string `json:"summary,omitempty"` Archive string `json:"archive,omitempty"` + References json.RawMessage `json:"references,omitempty"` } type HistoryToolCall struct { @@ -1524,10 +1540,14 @@ func (a *App) HistoryForTab(tabID string) []HistoryMessage { return []HistoryMessage{} } msgs := ctrl.History() - return historyMessages(msgs, sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath())) + dir := ctrl.SessionDir() + if dir == "" { + dir = config.SessionDir() + } + return historyMessages(msgs, sessionDisplayResolver(dir, ctrl.SessionPath()), sessionReferencesResolver(dir, ctrl.SessionPath())) } -func historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage { +func historyMessages(msgs []provider.Message, resolveUserContent func(string) string, resolveUserRefs func(string) json.RawMessage) []HistoryMessage { out := make([]HistoryMessage, 0, len(msgs)) for _, m := range msgs { content := m.Content @@ -1552,6 +1572,11 @@ func historyMessages(msgs []provider.Message, resolveUserContent func(string) st reasoning = m.ReasoningContent } hm := HistoryMessage{Role: string(m.Role), Content: content, Reasoning: reasoning} + if m.Role == provider.RoleUser && resolveUserRefs != nil { + if refs := resolveUserRefs(m.Content); len(refs) > 0 { + hm.References = refs + } + } if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 { hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls)) for i, tc := range m.ToolCalls { @@ -1579,7 +1604,7 @@ func previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) { if err != nil { return nil, err } - return historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath)), nil + return historyMessages(loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath), sessionReferencesResolver(sessionDir, path)), nil } type previewEventRecord struct { diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index f24fb8154..b0019efcf 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -60,6 +60,7 @@ import { type Mode, type ProjectNode, type SessionMeta, + type SessionReference, type SettingsTab, type SettingsView, type TabMeta, @@ -1377,7 +1378,7 @@ export default function App() { // (/skill, /hooks, /mcp) — goes straight to Submit, which the controller // resolves (a turn, or a listing Notice). const handleSend = useCallback( - async (displayText: string, submitText = displayText) => { + async (displayText: string, submitText = displayText, references?: SessionReference[]) => { const trimmed = displayText.trim(); // "!" runs a shell command directly, bypassing the model. if (trimmed.startsWith("!")) { @@ -1449,7 +1450,7 @@ export default function App() { await setControllerCollaborationMode(collaborationMode); await setControllerToolApprovalMode(toolApprovalMode); if (goal.trim()) await setControllerGoal(goal); - send(trimmed, submitText.trim()); + send(trimmed, submitText.trim(), references); }, [applyGoal, closeTransientOverlays, collaborationMode, goal, send, runShell, notice, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, steer, switchModel, t, toolApprovalMode], ); diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index 26ead899a..c1ffbf895 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -355,7 +355,7 @@ export function Composer({ modelLabel: string; tabId?: string; effort?: EffortInfo; - onSend: (displayText: string, submitText?: string) => void; + onSend: (displayText: string, submitText?: string, references?: SessionReference[]) => void; // Returns the un-sent text when cancelling before the server replied (so it can // be restored to the input); undefined for a normal cancel. onCancel: () => string | undefined; @@ -818,7 +818,7 @@ export function Composer({ if (disabled || submittingRef.current) return; const trimmedText = text.trim(); if (pendingPaste > 0) return; - if (!trimmedText && attachments.length === 0 && workspaceRefs.length === 0) { + if (!trimmedText && attachments.length === 0 && workspaceRefs.length === 0 && sessionRefs.length === 0) { if (goalModeOn && !activeGoal) { setComposerPrompt(t("composer.goalInputRequired")); requestAnimationFrame(() => taRef.current?.focus()); @@ -846,7 +846,12 @@ export function Composer({ const sessionContext = sessionRefs.length === 0 ? "" : await buildSessionContext(sessionRefs); const baseSubmitText = [expandPastedBlocks(trimmedText), refs].filter(Boolean).join(trimmedText && refs ? " " : ""); const submitText = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText; - onSend(displayText, submitText); + // Snapshot sessionRefs for UI display in the user message bubble. Pass + // separately from submitText (which is model-facing context). After the + // snapshot, clear sessionRefs as before — the snapshot is what the bubble + // will render. + const references = sessionRefs.length > 0 ? sessionRefs.slice() : undefined; + onSend(displayText, submitText, references); setText(""); clearAttachments(); setWorkspaceRefs([]); @@ -1917,7 +1922,7 @@ export function Composer({ diff --git a/desktop/frontend/src/components/Message.tsx b/desktop/frontend/src/components/Message.tsx index 86b494a27..fa0940cf4 100644 --- a/desktop/frontend/src/components/Message.tsx +++ b/desktop/frontend/src/components/Message.tsx @@ -7,7 +7,7 @@ import { parseAttachmentRefsForDisplay, sortDisplayAttachments } from "../lib/at import { app } from "../lib/bridge"; import { useT } from "../lib/i18n"; import type { Item, MessageActionScope } from "../lib/useController"; -import type { CheckpointMeta } from "../lib/types"; +import type { CheckpointMeta, SessionReference } from "../lib/types"; type AssistantItem = Extract; export type TurnActionMenu = "summary" | "rewind"; @@ -60,15 +60,26 @@ function attachmentIcon(kind: "image" | "file" | "folder") { return ; } +// Extract the basename of a session .md path. Tolerates both / and \ separators +// and trims any trailing ones before splitting. Returns the original string when +// no separator is present (e.g. a bare filename). +function baseName(p: string): string { + const trimmed = p.replace(/[\\/]+$/, ""); + const parts = trimmed.split(/[\\/]/); + return parts[parts.length - 1] || trimmed; +} + export function UserMessage({ text, failed, + references, turn, anchorId, id, }: { text: string; failed?: boolean; + references?: SessionReference[]; turn?: number; anchorId?: string; id?: string; @@ -148,6 +159,21 @@ export function UserMessage({ ))} )} + {references && references.length > 0 && ( +
+
{t("msg.referencedSessionsLabel")}
+ {references.map((ref) => ( +
+ + {ref.title} + {typeof ref.turns === "number" && ( + {t("msg.referenceTurns", { count: ref.turns })} + )} + {baseName(ref.path)} +
+ ))} +
+ )} ); diff --git a/desktop/frontend/src/components/Transcript.tsx b/desktop/frontend/src/components/Transcript.tsx index 6efb6b191..eb77fad4b 100644 --- a/desktop/frontend/src/components/Transcript.tsx +++ b/desktop/frontend/src/components/Transcript.tsx @@ -420,7 +420,7 @@ export function Transcript({ const tn = userTurn.get(it.id); activeTurn = tn; out.push( - , + , ); break; } @@ -700,7 +700,7 @@ function WarmTurnItems({ const tn = userTurnMap.get(it.id); activeTurn = tn; nodes.push( - , + , ); break; } diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index 3a33a77fd..8fdc39553 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -92,6 +92,7 @@ export interface AppBindings { SubmitToTab(tabID: string, input: string): Promise; SubmitDisplay(display: string, input: string): Promise; SubmitDisplayToTab(tabID: string, display: string, input: string): Promise; + SubmitDisplayToTabWithRefs(tabID: string, display: string, input: string, references: string): Promise; RunShell(command: string): Promise; RunShellForTab(tabID: string, command: string): Promise; Steer(text: string): Promise; @@ -1375,6 +1376,9 @@ function makeMockApp(): AppBindings { async SubmitDisplayToTab(_tabID, display, input) { await withMockTabScope(_tabID, () => this.SubmitDisplay(display, input)); }, + async SubmitDisplayToTabWithRefs(_tabID, display, input, _references) { + await this.SubmitDisplay(display, input); + }, async RunShell(command) { cancelled = false; emitMockTurnStarted(); diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index a6312e66f..494cab8ec 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -218,6 +218,7 @@ export interface HistoryMessage { messages?: number; summary?: string; archive?: string; + references?: SessionReference[] | string; } export interface HistoryToolCall { diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 36b08c40a..b6d5a66ea 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -22,6 +22,7 @@ import type { Mode, QuestionAnswer, SessionMeta, + SessionReference, TabMeta, ToolApprovalMode, WireApproval, @@ -37,7 +38,7 @@ export type MessageActionScope = "fork" | "summ-from" | "summ-upto" | "conversat export type MessageActionState = { turn: number; scope: MessageActionScope }; export type Item = - | { kind: "user"; id: string; text: string; failed?: boolean } + | { kind: "user"; id: string; text: string; failed?: boolean; references?: SessionReference[] } | { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean } | { kind: "phase"; id: string; text: string } | { kind: "notice"; id: string; level: "info" | "warn"; text: string } @@ -83,6 +84,7 @@ interface State { currentAssistant?: string; live?: LiveStream; pendingUser?: string; + pendingReferences?: SessionReference[]; discardTurn?: boolean; turnStartAt: number; turnTokens: number; @@ -166,7 +168,7 @@ function isReadOnlyTool(name: string): boolean { type Action = | { type: "event"; e: WireEvent } - | { type: "user"; text: string; seq: number } + | { type: "user"; text: string; seq: number; references?: SessionReference[] } | { type: "unsend" } | { type: "send_failed"; error: string } | { type: "backend_status"; running: boolean } @@ -228,7 +230,8 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str } if (m.role === "user") { if (m.content.trim() === "") continue; - items.push({ kind: "user", id: `${idPrefix}${seq}`, text: m.content }); + const refs = typeof m.references === "string" ? (() => { try { return JSON.parse(m.references); } catch { return undefined; } })() : m.references; + items.push({ kind: "user", id: `${idPrefix}${seq}`, text: m.content, references: Array.isArray(refs) ? refs : undefined }); seq++; continue; } @@ -299,8 +302,9 @@ function flushPendingUser(s: State): State { return { ...s, seq: s.seq + 1, - items: [...s.items, { kind: "user", id: `u${s.seq}`, text: s.pendingUser }], + items: [...s.items, { kind: "user", id: `u${s.seq}`, text: s.pendingUser, references: s.pendingReferences }], pendingUser: undefined, + pendingReferences: undefined, }; } @@ -444,16 +448,17 @@ export function reducer(s: State, a: Action): State { return { ...s, seq: seq + 1, - items: [...s.items, { kind: "user", id: `u${seq}`, text: a.text }], + items: [...s.items, { kind: "user", id: `u${seq}`, text: a.text, references: a.references }], running: true, turnStartAt: Date.now(), turnTokens: 0, turnTotalTokens: 0, pendingUser: a.text, + pendingReferences: a.references, discardTurn: false, }; } - case "unsend": return { ...s, pendingUser: undefined, discardTurn: true, running: false, live: undefined }; + case "unsend": return { ...s, pendingUser: undefined, pendingReferences: undefined, discardTurn: true, running: false, live: undefined }; case "send_failed": { if (s.pendingUser === undefined) return s; let idx = -1; @@ -463,7 +468,7 @@ export function reducer(s: State, a: Action): State { } const items = idx >= 0 ? s.items.map((it, i) => (i === idx ? { ...it, failed: true } : it)) : s.items; const notice: Item = { kind: "notice", id: `n${s.seq}`, level: "warn", text: a.error }; - return { ...s, pendingUser: undefined, running: false, turnActive: false, live: undefined, seq: s.seq + 1, items: [...items, notice] }; + return { ...s, pendingUser: undefined, pendingReferences: undefined, running: false, turnActive: false, live: undefined, seq: s.seq + 1, items: [...items, notice] }; } case "backend_status": { if (a.running === s.running) return s; @@ -734,15 +739,22 @@ export function useController() { return () => window.clearTimeout(timer); }, [activeTabId, reconcileTabRuntime, activeState.running, activeState.live]); - const send = useCallback((displayText: string, submitText = displayText) => { + const send = useCallback((displayText: string, submitText = displayText, references?: SessionReference[]) => { const submitForTab = (tabId: string) => { const seq = getOrCreateState(statesRef.current, tabId).seq; - dispatchTo(tabId, { type: "user", text: displayText, seq }); + dispatchTo(tabId, { type: "user", text: displayText, seq, references }); const display = displayText.trim(); const submit = submitText.trim(); - (display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit)).catch((error) => { - dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` }); - }); + const refsJson = references && references.length > 0 ? JSON.stringify(references) : ""; + if (refsJson) { + app.SubmitDisplayToTabWithRefs(tabId, display, submit, refsJson).catch((error) => { + dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` }); + }); + } else { + (display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit)).catch((error) => { + dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` }); + }); + } }; const tabId = activeTabIdRef.current ?? activeTabId; if (tabId) { diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index ade6843f0..71632de6a 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -1139,6 +1139,9 @@ export const en = { "rewind.busyCode": "Rewinding code…", "rewind.busyBoth": "Rewinding code and conversation…", "msg.copied": "Copied", + "msg.referencedSessions": "Referenced sessions", + "msg.referencedSessionsLabel": "Referenced sessions:", + "msg.referenceTurns": "· {count} turns", // tool card summaries "tool.stepOne": "{n} step", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 12df8afaf..5fed4c5c2 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -1141,6 +1141,9 @@ export const zh: Record = { "rewind.busyCode": "正在回滚代码…", "rewind.busyBoth": "正在回滚代码和对话…", "msg.copied": "已复制", + "msg.referencedSessions": "引用的会话", + "msg.referencedSessionsLabel": "引用的会话:", + "msg.referenceTurns": "· {count} 轮", // 工具卡片摘要 "tool.stepOne": "{n} 步", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index e1b7abe67..1ce8bc160 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -2925,7 +2925,13 @@ a[href] { user-select: text; -webkit-user-select: text; } -.msg--user { +/* User message row: a thin flex container that pins the right-aligned shell + to the right edge of the transcript. The selector is intentionally more + specific than `.transcript > *` (which centres every direct child) so the + user bubble always wins the cascade, regardless of file ordering or CSS + tool re-ordering. `max-width: var(--maxw)` matches the transcript cap; + the actual bubble is then constrained by `.msg__user-shell`. */ +.transcript > .msg.msg--user { display: flex; justify-content: flex-end; color: var(--fg); @@ -2936,7 +2942,6 @@ a[href] { border: 1px solid var(--border-soft); border-radius: 14px; padding: 10px 16px; - position: relative; } .rewind { position: absolute; @@ -3045,6 +3050,57 @@ a[href] { word-break: break-word; font-weight: 450; line-height: 1.65; + text-align: left; +} +/* past-chat reference cards rendered below the user message text + inside the bubble. The bubble is a flex column with the text and + references stacking vertically. */ +.msg__references { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 2px; +} +.msg__references-label { + color: var(--fg-dim); + font-size: 11px; + letter-spacing: 0.02em; +} +.msg__reference-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border)); + background: color-mix(in srgb, var(--accent) 7%, var(--panel)); + border-radius: 6px; + font-size: 12px; + color: var(--fg); +} +.msg__reference-icon { + color: var(--accent); + flex-shrink: 0; +} +.msg__reference-title { + font-weight: 500; + color: var(--fg); + word-break: break-word; +} +.msg__reference-turns { + color: var(--fg-faint); + font-size: 11px; + flex-shrink: 0; +} +.msg__reference-path { + color: var(--fg-faint); + font-size: 11px; + font-family: var(--mono); + margin-left: auto; + word-break: break-all; + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .msg--im-source .msg__body { min-width: min(520px, 82%); diff --git a/desktop/history_test.go b/desktop/history_test.go index bd895ecd7..bb77946ac 100644 --- a/desktop/history_test.go +++ b/desktop/history_test.go @@ -28,7 +28,7 @@ func TestHistoryMessagesIncludeAssistantReasoning(t *testing.T) { t.Fatalf("unexpected user content passed to resolver: %q", content) } return "display prompt" - }) + }, nil) if len(got) != len(msgs) { t.Fatalf("history length = %d, want %d", len(got), len(msgs)) diff --git a/desktop/sessions.go b/desktop/sessions.go index 6266caf4f..04839b0fb 100644 --- a/desktop/sessions.go +++ b/desktop/sessions.go @@ -254,6 +254,12 @@ func purgeTrashedSessionFile(dir, path string) error { return err } } + if rm := loadSessionReferences(dir); rm[key] != nil { + delete(rm, key) + if err := saveSessionReferences(dir, rm); err != nil { + return err + } + } return nil } @@ -495,3 +501,71 @@ func sessionDisplayResolver(dir, sessionPath string) func(content string) string func resolveSessionDisplay(dir, sessionPath, content string) string { return sessionDisplayResolver(dir, sessionPath)(content) } + +// --- references sidecar (.references.json) --- + +const sessionReferencesFile = ".references.json" + +type sessionReferencesMap map[string]map[string]json.RawMessage + +func sessionReferencesPath(dir string) string { return filepath.Join(dir, sessionReferencesFile) } + +func loadSessionReferences(dir string) sessionReferencesMap { + m := sessionReferencesMap{} + b, err := os.ReadFile(sessionReferencesPath(dir)) + if err != nil { + return m + } + _ = json.Unmarshal(b, &m) + return m +} + +func saveSessionReferences(dir string, m sessionReferencesMap) error { + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".references.*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + if _, err := tmp.Write(b); err != nil { + tmp.Close() + os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return err + } + return fileutil.ReplaceFile(tmpPath, sessionReferencesPath(dir)) +} + +func recordSessionReferences(dir, sessionPath, content string, refs json.RawMessage) error { + if strings.TrimSpace(sessionPath) == "" || len(refs) == 0 { + return nil + } + m := loadSessionReferences(dir) + key := filepath.Base(sessionPath) + if m[key] == nil { + m[key] = map[string]json.RawMessage{} + } + m[key][messageDisplayKey(content)] = refs + return saveSessionReferences(dir, m) +} + +func sessionReferencesResolver(dir, sessionPath string) func(string) json.RawMessage { + byHash := loadSessionReferences(dir)[filepath.Base(sessionPath)] + return func(content string) json.RawMessage { + if byHash != nil { + if refs := byHash[messageDisplayKey(content)]; len(refs) > 0 { + return refs + } + } + return nil + } +}