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
+106
View File
@@ -0,0 +1,106 @@
import type { ClaudeSessionSummary, ClaudeSessionDetail, ClaudeChatMessage } from './claude-sessions';
import { ensureServer } from './opencode/server-manager';
import { getConnection } from './opencode/client';
import { logger } from './logger';
// The OpenCode analog of claude-sessions.ts. OpenCode's own SQLite store is the source of truth, read
// via its HTTP API (never the DB directly). A `serve` is directory-scoped, so listing sessions for a
// cwd just means asking the serve rooted at that cwd. Returns the same shapes as the Claude reader,
// tagged harness:'opencode', so chat.ts can merge both harnesses transparently.
/** List OpenCode sessions for a working directory. Never throws — returns [] if OpenCode is unavailable. */
export async function listOpenCodeSessions(cwd: string, home: string): Promise<ClaudeSessionSummary[]> {
try {
const { baseUrl } = await ensureServer(cwd, home);
const sessions = await getConnection(baseUrl).listSessions();
return sessions.map((s) => {
const created = s.time?.created ?? Date.now();
const updated = s.time?.updated ?? created;
return {
id: s.id,
title: s.title || '(untitled)',
cwd,
createdAt: new Date(created).toISOString(),
updatedAt: new Date(updated).toISOString(),
messageCount: 0, // the session list endpoint doesn't include a turn count
harness: 'opencode',
} satisfies ClaudeSessionSummary;
});
} catch (err) {
logger.warn('Failed to list OpenCode sessions', { cwd, error: String(err) });
return [];
}
}
/** Load one OpenCode session's transcript, rebuilt into the shared display message shape. */
export async function loadOpenCodeSession(cwd: string, home: string, sessionId: string): Promise<ClaudeSessionDetail | null> {
try {
const { baseUrl } = await ensureServer(cwd, home);
const stored = await getConnection(baseUrl).getMessages(sessionId);
const messages: ClaudeChatMessage[] = [];
let modelId = '';
for (const m of stored) {
const role = m.info?.role;
const parts = m.parts ?? [];
if (role === 'user') {
const text = parts
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.join('')
.trim();
if (text) messages.push({ role: 'user', text });
continue;
}
if (role === 'assistant') {
if (!modelId) modelId = m.info?.model?.modelID ?? m.info?.modelID ?? '';
for (const p of parts) {
if (p.type === 'text' && p.text) {
messages.push({ role: 'assistant', id: `${sessionId}-${messages.length}`, text: p.text });
} else if (p.type === 'tool' && p.callID) {
messages.push({
role: 'tool',
toolName: p.tool ?? 'tool',
toolInput: p.state?.input ?? {},
toolCallId: p.callID,
output: p.state?.output,
isError: Boolean(p.state?.error),
});
}
// text-part deltas already excluded reasoning; skip reasoning/step-* parts here too.
}
}
}
return { id: sessionId, model: modelId ? `opencode/${modelId}` : 'opencode', cwd, messages };
} catch (err) {
logger.warn('Failed to load OpenCode session', { sessionId, error: String(err) });
return null;
}
}
export async function deleteOpenCodeSession(cwd: string, home: string, sessionId: string): Promise<boolean> {
try {
const { baseUrl } = await ensureServer(cwd, home);
return await getConnection(baseUrl).deleteSession(sessionId);
} catch (err) {
logger.warn('Failed to delete OpenCode session', { sessionId, error: String(err) });
return false;
}
}
export async function renameOpenCodeSession(cwd: string, home: string, sessionId: string, title: string): Promise<boolean> {
try {
const { baseUrl } = await ensureServer(cwd, home);
return await getConnection(baseUrl).renameSession(sessionId, title);
} catch (err) {
logger.warn('Failed to rename OpenCode session', { sessionId, error: String(err) });
return false;
}
}
/** OpenCode session ids are prefixed `ses_`; Claude ids are bare UUIDs. */
export const isOpenCodeSessionId = (id: string): boolean => id.startsWith('ses_');