Skip to content

Commit 6983422

Browse files
committed
feat: implement session history management and API for retrieving chat transcripts
1 parent fc6b73a commit 6983422

4 files changed

Lines changed: 190 additions & 16 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Session History API Route - /api/sessions/[sessionId]/history
3+
*
4+
* Returns the message history for a session, used when switching sessions
5+
* in the UI to restore the chat transcript.
6+
*/
7+
8+
import { NextRequest, NextResponse } from "next/server";
9+
import { getHttpSessionStore } from "@/core/acp/http-session-store";
10+
11+
export const dynamic = "force-dynamic";
12+
13+
export async function GET(
14+
_request: NextRequest,
15+
{ params }: { params: Promise<{ sessionId: string }> }
16+
) {
17+
const { sessionId } = await params;
18+
const store = getHttpSessionStore();
19+
const history = store.getHistory(sessionId);
20+
21+
return NextResponse.json(
22+
{ history },
23+
{ headers: { "Cache-Control": "no-store" } }
24+
);
25+
}
26+

src/client/components/chat-panel.tsx

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,14 +132,137 @@ export function ChatPanel({
132132
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
133133
}, [visibleMessages]);
134134

135-
// When active session changes, swap visible transcript
135+
// Track which sessions we've already loaded history for
136+
const loadedHistoryRef = useRef<Set<string>>(new Set());
137+
138+
// Fetch and process session history when switching sessions
139+
const fetchSessionHistory = useCallback(async (sessionId: string) => {
140+
// Skip if already loaded
141+
if (loadedHistoryRef.current.has(sessionId)) return;
142+
if (messagesBySession[sessionId]?.length) {
143+
// Already have messages from SSE
144+
loadedHistoryRef.current.add(sessionId);
145+
return;
146+
}
147+
148+
try {
149+
const res = await fetch(`/api/sessions/${sessionId}/history`, { cache: "no-store" });
150+
const data = await res.json();
151+
const history = Array.isArray(data?.history) ? data.history as AcpSessionNotification[] : [];
152+
153+
if (history.length === 0) {
154+
loadedHistoryRef.current.add(sessionId);
155+
return;
156+
}
157+
158+
// Process history into messages
159+
const messages: ChatMessage[] = [];
160+
let streamingMsgId: string | null = null;
161+
let streamingThoughtId: string | null = null;
162+
let lastKind: string | null = null;
163+
164+
for (const notification of history) {
165+
const update = (notification.update ?? notification) as Record<string, unknown>;
166+
const kind = update.sessionUpdate as string | undefined;
167+
if (!kind) continue;
168+
169+
const extractText = (): string => {
170+
const content = update.content as { type: string; text?: string } | undefined;
171+
if (content?.text) return content.text;
172+
if (typeof update.text === "string") return update.text;
173+
return "";
174+
};
175+
176+
switch (kind) {
177+
case "agent_message_chunk": {
178+
const text = extractText();
179+
if (!text) break;
180+
streamingThoughtId = null;
181+
const shouldCreateNew = lastKind !== "agent_message_chunk";
182+
if (shouldCreateNew) streamingMsgId = null;
183+
if (!streamingMsgId) {
184+
streamingMsgId = uuidv4();
185+
messages.push({ id: streamingMsgId, role: "assistant", content: text, timestamp: new Date() });
186+
} else {
187+
const idx = messages.findIndex((m) => m.id === streamingMsgId);
188+
if (idx >= 0) messages[idx] = { ...messages[idx], content: messages[idx].content + text };
189+
}
190+
break;
191+
}
192+
case "agent_thought_chunk": {
193+
const text = extractText();
194+
if (!text) break;
195+
const shouldCreateNewThought = lastKind !== "agent_thought_chunk";
196+
if (shouldCreateNewThought) streamingThoughtId = null;
197+
if (!streamingThoughtId) {
198+
streamingThoughtId = uuidv4();
199+
messages.push({ id: streamingThoughtId, role: "thought", content: text, timestamp: new Date() });
200+
} else {
201+
const idx = messages.findIndex((m) => m.id === streamingThoughtId);
202+
if (idx >= 0) messages[idx] = { ...messages[idx], content: messages[idx].content + text };
203+
}
204+
break;
205+
}
206+
case "user_message": {
207+
const text = extractText();
208+
if (text) messages.push({ id: uuidv4(), role: "user", content: text, timestamp: new Date() });
209+
streamingMsgId = null;
210+
streamingThoughtId = null;
211+
break;
212+
}
213+
case "tool_call": {
214+
const title = (update.title as string) ?? "tool";
215+
const status = (update.status as string) ?? "completed";
216+
const toolKind = update.kind as string | undefined;
217+
const rawInput = (typeof update.rawInput === "object" && update.rawInput !== null)
218+
? update.rawInput as Record<string, unknown>
219+
: undefined;
220+
const contentParts: string[] = [];
221+
if (update.rawInput) {
222+
contentParts.push(`Input:\n${typeof update.rawInput === "string" ? update.rawInput : JSON.stringify(update.rawInput, null, 2)}`);
223+
}
224+
const toolContent = update.content as Array<{ type: string; text?: string }> | undefined;
225+
if (Array.isArray(toolContent)) {
226+
for (const c of toolContent) if (c.text) contentParts.push(c.text);
227+
}
228+
messages.push({
229+
id: uuidv4(),
230+
role: "tool",
231+
content: contentParts.join("\n\n") || title,
232+
toolName: title,
233+
toolStatus: status,
234+
toolKind,
235+
toolRawInput: rawInput,
236+
timestamp: new Date(),
237+
});
238+
break;
239+
}
240+
}
241+
lastKind = kind;
242+
}
243+
244+
loadedHistoryRef.current.add(sessionId);
245+
if (messages.length > 0) {
246+
setMessagesBySession((prev) => ({
247+
...prev,
248+
[sessionId]: messages,
249+
}));
250+
}
251+
} catch {
252+
// ignore errors
253+
}
254+
}, [messagesBySession]);
255+
256+
// When active session changes, swap visible transcript and load history
136257
useEffect(() => {
137258
if (!activeSessionId) {
138259
setVisibleMessages([]);
139260
return;
140261
}
262+
// Load history if not yet loaded
263+
fetchSessionHistory(activeSessionId);
141264
setVisibleMessages(messagesBySession[activeSessionId] ?? []);
142-
}, [activeSessionId, messagesBySession]);
265+
}, [activeSessionId, messagesBySession, fetchSessionHistory]);
143266

144267
const fetchSessions = useCallback(async () => {
145268
try {
@@ -734,6 +857,7 @@ export function ChatPanel({
734857
<div className="flex gap-2 items-end">
735858
<TiptapInput
736859
onSend={handleSend}
860+
onStop={acp.cancel}
737861
placeholder={
738862
connected
739863
? activeSessionId

src/client/components/tiptap-input.tsx

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,8 @@ interface SessionItem {
444444

445445
interface TiptapInputProps {
446446
onSend: (text: string, context: InputContext) => void;
447+
/** Called when user clicks stop button during loading */
448+
onStop?: () => void;
447449
placeholder?: string;
448450
disabled?: boolean;
449451
loading?: boolean;
@@ -463,6 +465,7 @@ interface TiptapInputProps {
463465

464466
export function TiptapInput({
465467
onSend,
468+
onStop,
466469
placeholder = "Type a message...",
467470
disabled = false,
468471
loading = false,
@@ -974,24 +977,30 @@ export function TiptapInput({
974977
<span className="mx-1.5">&middot;</span>
975978
<kbd className="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 font-mono">/</kbd> skill
976979
</span>
977-
<button
978-
type="button"
979-
onClick={() => handleSendRef.current()}
980-
disabled={disabled || loading}
981-
className="shrink-0 w-7 h-7 flex items-center justify-center rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
982-
title="Send"
983-
>
984-
{loading ? (
985-
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
986-
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
987-
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
980+
{loading ? (
981+
<button
982+
type="button"
983+
onClick={() => onStop?.()}
984+
className="shrink-0 w-7 h-7 flex items-center justify-center rounded-lg bg-red-600 hover:bg-red-700 text-white transition-colors"
985+
title="Stop"
986+
>
987+
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
988+
<rect x="6" y="6" width="12" height="12" rx="1" />
988989
</svg>
989-
) : (
990+
</button>
991+
) : (
992+
<button
993+
type="button"
994+
onClick={() => handleSendRef.current()}
995+
disabled={disabled}
996+
className="shrink-0 w-7 h-7 flex items-center justify-center rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
997+
title="Send"
998+
>
990999
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
9911000
<path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14M12 5l7 7-7 7" />
9921001
</svg>
993-
)}
994-
</button>
1002+
</button>
1003+
)}
9951004
</div>
9961005
</div>
9971006
</div>

src/core/acp/http-session-store.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ class HttpSessionStore {
3333
private sessions = new Map<string, RoutaSessionRecord>();
3434
private sseControllers = new Map<string, Controller>();
3535
private pendingNotifications = new Map<string, SessionUpdateNotification[]>();
36+
/** Store all notifications per session for history replay */
37+
private messageHistory = new Map<string, SessionUpdateNotification[]>();
3638

3739
upsertSession(record: RoutaSessionRecord) {
3840
this.sessions.set(record.sessionId, record);
@@ -83,6 +85,12 @@ class HttpSessionStore {
8385
*/
8486
pushNotification(notification: SessionUpdateNotification) {
8587
const sessionId = notification.sessionId;
88+
89+
// Always store in history for session switching
90+
const history = this.messageHistory.get(sessionId) ?? [];
91+
history.push(notification);
92+
this.messageHistory.set(sessionId, history);
93+
8694
const controller = this.sseControllers.get(sessionId);
8795

8896
if (controller) {
@@ -99,6 +107,13 @@ class HttpSessionStore {
99107
this.pendingNotifications.set(sessionId, pending);
100108
}
101109

110+
/**
111+
* Get message history for a session (used when switching sessions).
112+
*/
113+
getHistory(sessionId: string): SessionUpdateNotification[] {
114+
return this.messageHistory.get(sessionId) ?? [];
115+
}
116+
102117
/**
103118
* Send a one-off "connected" event (useful for UI).
104119
*/

0 commit comments

Comments
 (0)