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:
@@ -9,8 +9,16 @@ import {
|
||||
deleteClaudeSession,
|
||||
renameClaudeSession,
|
||||
} from './claude-sessions';
|
||||
import {
|
||||
listOpenCodeSessions,
|
||||
loadOpenCodeSession,
|
||||
deleteOpenCodeSession,
|
||||
renameOpenCodeSession,
|
||||
isOpenCodeSessionId,
|
||||
} from './opencode-sessions';
|
||||
import { listChatModels } from './list-models';
|
||||
import { logger } from './logger';
|
||||
import { getHomeDirForRole } from '../../data-path';
|
||||
import { readSttConfig } from '../server-settings/stt';
|
||||
import { transcribeAudio } from '../stt/transcribe';
|
||||
|
||||
@@ -20,40 +28,60 @@ export const chatRouter = createRouter();
|
||||
// claude_sessions dir. Claude groups sessions by cwd, so this selects which project group we read.
|
||||
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getClaudeSessionsCwd(email);
|
||||
|
||||
// The home dir an OpenCode `serve` runs under (so it reads the user's ~/.local/share/opencode auth).
|
||||
const homeOf = (ctx: Context): string => getHomeDirForRole(ctx.get('user').email, ctx.get('user').role);
|
||||
|
||||
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||
chatRouter.get('/pwds', (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
return ctx.json({ pwds: listClaudePwds(email), default: getClaudeSessionsCwd(email) });
|
||||
});
|
||||
|
||||
// GET /chat/sessions[?cwd=] — the conversations for a working directory, from Claude's transcripts.
|
||||
chatRouter.get('/sessions', (ctx) => {
|
||||
// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
|
||||
// (Claude transcripts + OpenCode's session store), newest first.
|
||||
chatRouter.get('/sessions', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
return ctx.json({ sessions: listClaudeSessions(email, cwdOf(ctx, email)) });
|
||||
const cwd = cwdOf(ctx, email);
|
||||
const claude = listClaudeSessions(email, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
||||
const opencode = await listOpenCodeSessions(cwd, homeOf(ctx));
|
||||
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return ctx.json({ sessions });
|
||||
});
|
||||
|
||||
// GET /chat/sessions/:id[?cwd=] — one conversation's full transcript, parsed into display messages.
|
||||
chatRouter.get('/sessions/:id', (ctx) => {
|
||||
// GET /chat/sessions/:id[?cwd=] — one conversation's transcript. Routes by harness (ses_ = OpenCode).
|
||||
chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const detail = loadClaudeSession(email, cwdOf(ctx, email), ctx.req.param('id'));
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, email);
|
||||
const detail = isOpenCodeSessionId(id)
|
||||
? await loadOpenCodeSession(cwd, homeOf(ctx), id)
|
||||
: loadClaudeSession(email, cwd, id);
|
||||
if (!detail) return ctx.text('Not found', 404);
|
||||
return ctx.json(detail);
|
||||
});
|
||||
|
||||
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation (deletes Claude's transcript file).
|
||||
chatRouter.delete('/sessions/:id', (ctx) => {
|
||||
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store.
|
||||
chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const ok = deleteClaudeSession(email, cwdOf(ctx, email), ctx.req.param('id'));
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, email);
|
||||
const ok = isOpenCodeSessionId(id)
|
||||
? await deleteOpenCodeSession(cwd, homeOf(ctx), id)
|
||||
: deleteClaudeSession(email, cwd, id);
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// PATCH /chat/sessions/:id/title[?cwd=] — rename by writing a summary entry into Claude's transcript.
|
||||
// PATCH /chat/sessions/:id/title[?cwd=] — rename in the owning harness's store.
|
||||
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, email);
|
||||
const { title } = await ctx.req.json<{ title?: string }>();
|
||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||
const ok = renameClaudeSession(email, cwdOf(ctx, email), ctx.req.param('id'), title.trim());
|
||||
const ok = isOpenCodeSessionId(id)
|
||||
? await renameOpenCodeSession(cwd, homeOf(ctx), id, title.trim())
|
||||
: renameClaudeSession(email, cwd, id, title.trim());
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user