chat: unified session history across Claude + OpenCode (Phase 3)

/chat's session list, transcript load, delete, and rename now span both harnesses.

- opencode-sessions.ts — REST-backed reader (OpenCode's SQLite via its HTTP API, never
  the DB): listOpenCodeSessions / loadOpenCodeSession / delete / rename, returning the
  same shapes as the Claude reader, tagged harness:'opencode'. A serve is directory-
  scoped, so listing a cwd = asking the serve rooted there. Transcript rebuild maps
  user/assistant/tool parts and drops reasoning (parity with the delta filter).
- client.ts — adds listSessions/getMessages/deleteSession/renameSession over /session/*.
- chat.ts — /sessions merges both (newest first); /sessions/:id, DELETE, and
  /title route by id shape (ses_ = OpenCode). ClaudeSessionSummary gains an optional
  `harness` tag.
- send-opencode.ts — resuming from history: when the sessionKey is itself a ses_ id,
  reuse that OpenCode session instead of creating a new one.
- SessionList.tsx — shows an "OpenCode" badge for OpenCode sessions.

Verified end-to-end against a live serve: list (5 sessions, tagged), load (transcript
rebuilt, reasoning filtered), and rename all work. Phases 1-3 complete; needs a restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 16:56:20 +00:00
co-authored by Claude Opus 4.8
parent 3a2f3317b2
commit 059539a64a
7 changed files with 210 additions and 15 deletions
+49
View File
@@ -121,8 +121,57 @@ class ServerConnection {
async abort(sessionId: string): Promise<void> {
await this.postJson(`/session/${sessionId}/abort`, {}).catch(() => {});
}
// ── Session history (REST; OpenCode's SQLite store is the source of truth) ──
async listSessions(): Promise<OpenCodeSessionInfo[]> {
const res = await fetch(`${this.baseUrl}/session`);
if (!res.ok) throw new Error(`opencode GET /session → ${res.status}`);
return (await res.json()) as OpenCodeSessionInfo[];
}
async getMessages(sessionId: string): Promise<OpenCodeStoredMessage[]> {
const res = await fetch(`${this.baseUrl}/session/${sessionId}/message`);
if (!res.ok) throw new Error(`opencode GET /session/${sessionId}/message → ${res.status}`);
return (await res.json()) as OpenCodeStoredMessage[];
}
async deleteSession(sessionId: string): Promise<boolean> {
const res = await fetch(`${this.baseUrl}/session/${sessionId}`, { method: 'DELETE' });
return res.ok;
}
async renameSession(sessionId: string, title: string): Promise<boolean> {
const res = await fetch(`${this.baseUrl}/session/${sessionId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title }),
});
return res.ok;
}
}
// Shapes returned by the `/session/*` read endpoints (verified against opencode 1.17.9).
export type OpenCodeSessionInfo = {
id: string;
title?: string;
time?: { created?: number; updated?: number };
location?: { directory?: string | null };
};
export type OpenCodeStoredPart = {
type?: string;
text?: string;
tool?: string;
callID?: string;
state?: { input?: Record<string, unknown>; output?: string; error?: string };
};
export type OpenCodeStoredMessage = {
info?: { role?: string; modelID?: string; model?: { modelID?: string; providerID?: string } };
parts?: OpenCodeStoredPart[];
};
const connections = new Map<string, ServerConnection>();
export function getConnection(baseUrl: string): ServerConnection {