Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions desktop/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
type Mode,
type ProjectNode,
type SessionMeta,
type SessionReference,
type SettingsTab,
type SettingsView,
type TabMeta,
Expand Down Expand Up @@ -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();
// "!<cmd>" runs a shell command directly, bypassing the model.
if (trimmed.startsWith("!")) {
Expand Down Expand Up @@ -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],
);
Expand Down
13 changes: 9 additions & 4 deletions desktop/frontend/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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([]);
Expand Down Expand Up @@ -1917,7 +1922,7 @@ export function Composer({
<button
className="composer__btn composer__btn--send"
onClick={submit}
disabled={submitting || pendingPaste > 0 || ((!text.trim() && attachments.length === 0 && workspaceRefs.length === 0) && !(goalModeOn && !activeGoal)) || disabled}
disabled={submitting || pendingPaste > 0 || ((!text.trim() && attachments.length === 0 && workspaceRefs.length === 0 && sessionRefs.length === 0) && !(goalModeOn && !activeGoal)) || disabled}
>
<ArrowUp size={16} />
</button>
Expand Down
28 changes: 27 additions & 1 deletion desktop/frontend/src/components/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item, { kind: "assistant" }>;
export type TurnActionMenu = "summary" | "rewind";
Expand Down Expand Up @@ -60,15 +60,26 @@ function attachmentIcon(kind: "image" | "file" | "folder") {
return <FileText size={15} />;
}

// 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;
Expand Down Expand Up @@ -148,6 +159,21 @@ export function UserMessage({
))}
</div>
)}
{references && references.length > 0 && (
<div className="msg__references" aria-label={t("msg.referencedSessions")}>
<div className="msg__references-label">{t("msg.referencedSessionsLabel")}</div>
{references.map((ref) => (
<div key={ref.path} className="msg__reference-item" title={ref.path}>
<MessageSquare size={13} className="msg__reference-icon" />
<span className="msg__reference-title">{ref.title}</span>
{typeof ref.turns === "number" && (
<span className="msg__reference-turns">{t("msg.referenceTurns", { count: ref.turns })}</span>
)}
<span className="msg__reference-path">{baseName(ref.path)}</span>
</div>
))}
</div>
)}
</div>
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions desktop/frontend/src/components/Transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ export function Transcript({
const tn = userTurn.get(it.id);
activeTurn = tn;
out.push(
<UserMessage key={it.id} id={it.id} text={it.text} failed={it.failed} turn={tn} anchorId={questionAnchorId(it.id)} />,
<UserMessage key={it.id} id={it.id} text={it.text} failed={it.failed} references={it.references} turn={tn} anchorId={questionAnchorId(it.id)} />,
);
break;
}
Expand Down Expand Up @@ -700,7 +700,7 @@ function WarmTurnItems({
const tn = userTurnMap.get(it.id);
activeTurn = tn;
nodes.push(
<UserMessage key={it.id} text={it.text} failed={it.failed} turn={tn} anchorId={questionAnchorId(it.id)} />,
<UserMessage key={it.id} id={it.id} text={it.text} failed={it.failed} references={it.references} turn={tn} anchorId={questionAnchorId(it.id)} />,
);
break;
}
Expand Down
4 changes: 4 additions & 0 deletions desktop/frontend/src/lib/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export interface AppBindings {
SubmitToTab(tabID: string, input: string): Promise<void>;
SubmitDisplay(display: string, input: string): Promise<void>;
SubmitDisplayToTab(tabID: string, display: string, input: string): Promise<void>;
SubmitDisplayToTabWithRefs(tabID: string, display: string, input: string, references: string): Promise<void>;
RunShell(command: string): Promise<void>;
RunShellForTab(tabID: string, command: string): Promise<void>;
Steer(text: string): Promise<void>;
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export interface HistoryMessage {
messages?: number;
summary?: string;
archive?: string;
references?: SessionReference[] | string;
}

export interface HistoryToolCall {
Expand Down
36 changes: 24 additions & 12 deletions desktop/frontend/src/lib/useController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
Mode,
QuestionAnswer,
SessionMeta,
SessionReference,
TabMeta,
ToolApprovalMode,
WireApproval,
Expand All @@ -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 }
Expand Down Expand Up @@ -83,6 +84,7 @@ interface State {
currentAssistant?: string;
live?: LiveStream;
pendingUser?: string;
pendingReferences?: SessionReference[];
discardTurn?: boolean;
turnStartAt: number;
turnTokens: number;
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions desktop/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions desktop/frontend/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,9 @@ export const zh: Record<DictKey, string> = {
"rewind.busyCode": "正在回滚代码…",
"rewind.busyBoth": "正在回滚代码和对话…",
"msg.copied": "已复制",
"msg.referencedSessions": "引用的会话",
"msg.referencedSessionsLabel": "引用的会话:",
"msg.referenceTurns": "· {count} 轮",

// 工具卡片摘要
"tool.stepOne": "{n} 步",
Expand Down
Loading
Loading