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:
@@ -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_');
|
||||
Reference in New Issue
Block a user