Skip to content

Commit c3eedfd

Browse files
committed
feat: add session loading and history thing
1 parent d617057 commit c3eedfd

File tree

5 files changed

+442
-7
lines changed

5 files changed

+442
-7
lines changed

src/lib/acp/client.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type ContentBlock,
77
type Implementation,
88
type InitializeResponse as InitializeResult,
9+
type ListSessionsResponse as ListSessionsResult,
910
type McpServer,
1011
type NewSessionResponse as NewSessionResult,
1112
type RequestPermissionRequest as PermissionRequest,
@@ -236,6 +237,57 @@ export class ACPClient {
236237
return this._session;
237238
}
238239

240+
async loadSession(
241+
sessionId: string,
242+
cwd: string,
243+
mcpServers?: McpServer[],
244+
): Promise<ACPSession> {
245+
this.ensureReady();
246+
247+
if (!this._agentCapabilities?.loadSession) {
248+
throw new Error("This agent does not support session/load");
249+
}
250+
251+
const connection = this.getConnection();
252+
const session = new ACPSession(sessionId, cwd);
253+
254+
this._session?.dispose();
255+
this._session = session;
256+
257+
try {
258+
await this.runWhileConnected(
259+
connection.loadSession({
260+
sessionId,
261+
cwd,
262+
mcpServers: mcpServers ?? [],
263+
}),
264+
);
265+
return session;
266+
} catch (error) {
267+
if (this._session === session) {
268+
session.dispose();
269+
this._session = null;
270+
}
271+
throw error;
272+
}
273+
}
274+
275+
async unstableListSessions(cwd?: string): Promise<ListSessionsResult> {
276+
this.ensureReady();
277+
278+
if (!this._agentCapabilities?.sessionCapabilities?.list) {
279+
throw new Error("This agent does not support session/list");
280+
}
281+
282+
const connection = this.getConnection();
283+
284+
return (await this.runWhileConnected(
285+
connection.unstable_listSessions({
286+
cwd: cwd || undefined,
287+
}),
288+
)) as ListSessionsResult;
289+
}
290+
239291
async startSession({
240292
url,
241293
cwd,

src/lib/acp/history.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
const STORAGE_KEY = "acpSessionHistory";
2+
const MAX_ENTRIES = 30;
3+
4+
export interface ACPHistoryEntry {
5+
sessionId: string;
6+
url: string;
7+
cwd: string;
8+
agentName: string;
9+
title: string;
10+
preview: string;
11+
createdAt: string;
12+
updatedAt: string;
13+
}
14+
15+
type ACPHistoryFilter = {
16+
url?: string;
17+
};
18+
19+
type ACPHistoryRemoveParams = {
20+
sessionId: string;
21+
url: string;
22+
};
23+
24+
type ACPHistorySaveInput = Partial<ACPHistoryEntry> &
25+
Pick<ACPHistoryEntry, "sessionId" | "url">;
26+
27+
function normalizeEntry(entry: Partial<ACPHistoryEntry> = {}): ACPHistoryEntry {
28+
const createdAt = entry.createdAt || new Date().toISOString();
29+
const updatedAt = entry.updatedAt || createdAt;
30+
31+
return {
32+
sessionId:
33+
typeof entry.sessionId === "string" ? entry.sessionId.trim() : "",
34+
url: typeof entry.url === "string" ? entry.url.trim() : "",
35+
cwd: typeof entry.cwd === "string" ? entry.cwd.trim() : "",
36+
agentName:
37+
typeof entry.agentName === "string" ? entry.agentName.trim() : "",
38+
title: typeof entry.title === "string" ? entry.title.trim() : "",
39+
preview: typeof entry.preview === "string" ? entry.preview.trim() : "",
40+
createdAt,
41+
updatedAt,
42+
};
43+
}
44+
45+
function getTimestamp(value: string): number {
46+
const parsed = Date.parse(value);
47+
return Number.isNaN(parsed) ? 0 : parsed;
48+
}
49+
50+
function sortEntries(entries: ACPHistoryEntry[]): ACPHistoryEntry[] {
51+
return [...entries].sort((a, b) => {
52+
return getTimestamp(b.updatedAt) - getTimestamp(a.updatedAt);
53+
});
54+
}
55+
56+
function parseEntries(): ACPHistoryEntry[] {
57+
let entries = null;
58+
try {
59+
entries = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
60+
} catch {
61+
entries = [];
62+
}
63+
if (!Array.isArray(entries)) return [];
64+
65+
return entries
66+
.map((entry) => normalizeEntry(entry))
67+
.filter((entry) => entry.sessionId && entry.url);
68+
}
69+
70+
function persist(entries: ACPHistoryEntry[]): void {
71+
const nextEntries = sortEntries(entries).slice(0, MAX_ENTRIES);
72+
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextEntries));
73+
}
74+
75+
const acpHistory = {
76+
list(filter: ACPHistoryFilter = {}): ACPHistoryEntry[] {
77+
return sortEntries(parseEntries()).filter((entry) => {
78+
if (!filter.url) return true;
79+
return entry.url === filter.url;
80+
});
81+
},
82+
83+
save(entry: ACPHistorySaveInput): ACPHistoryEntry | null {
84+
const normalized = normalizeEntry(entry);
85+
if (!normalized.sessionId || !normalized.url) return null;
86+
87+
const entries = parseEntries();
88+
const index = entries.findIndex((item) => {
89+
return (
90+
item.sessionId === normalized.sessionId && item.url === normalized.url
91+
);
92+
});
93+
94+
if (index >= 0) {
95+
const current = entries[index];
96+
entries[index] = {
97+
...current,
98+
...normalized,
99+
cwd: normalized.cwd || current.cwd,
100+
agentName: normalized.agentName || current.agentName,
101+
title: normalized.title || current.title,
102+
preview: normalized.preview || current.preview,
103+
createdAt: current.createdAt,
104+
updatedAt: normalized.updatedAt || new Date().toISOString(),
105+
};
106+
} else {
107+
entries.unshift({
108+
...normalized,
109+
createdAt: normalized.createdAt || new Date().toISOString(),
110+
updatedAt: normalized.updatedAt || new Date().toISOString(),
111+
});
112+
}
113+
114+
persist(entries);
115+
return normalized;
116+
},
117+
118+
remove({ sessionId, url }: ACPHistoryRemoveParams): void {
119+
if (!sessionId || !url) return;
120+
121+
persist(
122+
parseEntries().filter((entry) => {
123+
return !(entry.sessionId === sessionId && entry.url === url);
124+
}),
125+
);
126+
},
127+
};
128+
129+
export default acpHistory;

src/lib/acp/session.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export class ACPSession {
3030
readonly timeline: TimelineEntry[] = [];
3131
readonly toolCalls: Map<string, ToolCall> = new Map();
3232
plan: Plan | null = null;
33+
title: string | null = null;
34+
updatedAt: string | null = null;
3335

3436
private agentTextBuffer = "";
3537
private currentAgentMessageId: string | null = null;
@@ -102,6 +104,9 @@ export class ACPSession {
102104
case "plan":
103105
this.handlePlan(update.entries);
104106
break;
107+
case "session_info_update":
108+
this.handleSessionInfoUpdate(update);
109+
break;
105110
}
106111
}
107112

@@ -248,6 +253,18 @@ export class ACPSession {
248253
this.emit("plan", this.plan);
249254
}
250255

256+
private handleSessionInfoUpdate(update: {
257+
title?: string | null;
258+
updatedAt?: string | null;
259+
}): void {
260+
if ("title" in update) {
261+
this.title = update.title ?? null;
262+
}
263+
if ("updatedAt" in update) {
264+
this.updatedAt = update.updatedAt ?? null;
265+
}
266+
}
267+
251268
private nextMessageId(): string {
252269
return `msg_${++this.messageIdCounter}_${Date.now()}`;
253270
}

0 commit comments

Comments
 (0)