chat: pwd selector — switch the /chat working directory
The Sessions panel header gets a pwd selector: the default claude_sessions dir, plus every directory that already has Claude sessions (auto-discovered by reading the real cwd back from ~/.claude/projects), plus free-text entry. Switching pwd refetches the list for that cwd's Claude project group and runs New Chat / resume in it. Backend: GET /chat/pwds + a ?cwd= param on the session ops; the WS handler honors a chosen cwd for /chat (default claude_sessions). Browse-modal picker next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,38 +1,54 @@
|
||||
import type { Context } from 'hono';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getClaudeSessionsCwd, listClaudeSessions, loadClaudeSession, deleteClaudeSession, renameClaudeSession } from './claude-sessions';
|
||||
import {
|
||||
getClaudeSessionsCwd,
|
||||
listClaudePwds,
|
||||
listClaudeSessions,
|
||||
loadClaudeSession,
|
||||
deleteClaudeSession,
|
||||
renameClaudeSession,
|
||||
} from './claude-sessions';
|
||||
|
||||
export const chatRouter = createRouter();
|
||||
|
||||
// GET /chat/sessions — the /chat route's conversations, read straight from Claude's own transcript
|
||||
// store for the dedicated claude_sessions working directory (Claude is the source of truth).
|
||||
chatRouter.get('/sessions', (ctx) => {
|
||||
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
const sessions = listClaudeSessions(email, getClaudeSessionsCwd(email));
|
||||
return ctx.json({ sessions });
|
||||
return ctx.json({ pwds: listClaudePwds(email), default: getClaudeSessionsCwd(email) });
|
||||
});
|
||||
|
||||
// GET /chat/sessions/:id — one conversation's full transcript, parsed into display-ready messages.
|
||||
// GET /chat/sessions[?cwd=] — the conversations for a working directory, from Claude's transcripts.
|
||||
chatRouter.get('/sessions', (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
return ctx.json({ sessions: listClaudeSessions(email, cwdOf(ctx, email)) });
|
||||
});
|
||||
|
||||
// GET /chat/sessions/:id[?cwd=] — one conversation's full transcript, parsed into display messages.
|
||||
chatRouter.get('/sessions/:id', (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const detail = loadClaudeSession(email, getClaudeSessionsCwd(email), ctx.req.param('id'));
|
||||
const detail = loadClaudeSession(email, cwdOf(ctx, email), ctx.req.param('id'));
|
||||
if (!detail) return ctx.text('Not found', 404);
|
||||
return ctx.json(detail);
|
||||
});
|
||||
|
||||
// DELETE /chat/sessions/:id — remove a conversation (deletes Claude's transcript file).
|
||||
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation (deletes Claude's transcript file).
|
||||
chatRouter.delete('/sessions/:id', (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const ok = deleteClaudeSession(email, getClaudeSessionsCwd(email), ctx.req.param('id'));
|
||||
const ok = deleteClaudeSession(email, cwdOf(ctx, email), ctx.req.param('id'));
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// PATCH /chat/sessions/:id/title — rename by writing a summary entry into Claude's transcript.
|
||||
// PATCH /chat/sessions/:id/title[?cwd=] — rename by writing a summary entry into Claude's transcript.
|
||||
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const { title } = await ctx.req.json<{ title?: string }>();
|
||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||
const ok = renameClaudeSession(email, getClaudeSessionsCwd(email), ctx.req.param('id'), title.trim());
|
||||
const ok = renameClaudeSession(email, cwdOf(ctx, email), ctx.req.param('id'), title.trim());
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, rmSync, appendFileSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, rmSync, appendFileSync, openSync, readSync, closeSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
|
||||
@@ -232,6 +232,66 @@ export function renameClaudeSession(email: string, cwd: string, sessionId: strin
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Working directories (pwds) ──
|
||||
// Every dir you've run Claude in shows up as a project group under ~/.claude/projects. We read the
|
||||
// real `cwd` back from each group's transcripts so the UI can offer "jump to any project's sessions".
|
||||
|
||||
/** Read the `cwd` recorded in a transcript, from a bounded head read (cwd appears in early entries). */
|
||||
function firstCwd(filePath: string): string {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(filePath, 'r');
|
||||
const buf = Buffer.alloc(32768);
|
||||
const n = readSync(fd, buf, 0, buf.length, 0);
|
||||
return buf.toString('utf-8', 0, n).match(/"cwd":"([^"]*)"/)?.[1] ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
||||
|
||||
/** All working directories that have Claude sessions, plus the default /chat dir. Newest first. */
|
||||
export function listClaudePwds(email: string): ClaudePwd[] {
|
||||
const projectsDir = claudeProjectsDir(email);
|
||||
const defaultCwd = getClaudeSessionsCwd(email);
|
||||
const byCwd = new Map<string, { count: number; updatedAt: string }>();
|
||||
|
||||
if (existsSync(projectsDir)) {
|
||||
for (const group of readdirSync(projectsDir)) {
|
||||
const groupDir = join(projectsDir, group);
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(groupDir).filter((f) => f.endsWith('.jsonl'));
|
||||
} catch {
|
||||
continue; // not a directory
|
||||
}
|
||||
if (files.length === 0) continue;
|
||||
|
||||
const cwd = firstCwd(join(groupDir, files[0]!));
|
||||
if (!cwd) continue;
|
||||
let updatedAt = '';
|
||||
for (const f of files) {
|
||||
const m = statSync(join(groupDir, f)).mtime.toISOString();
|
||||
if (m > updatedAt) updatedAt = m;
|
||||
}
|
||||
const prev = byCwd.get(cwd);
|
||||
byCwd.set(cwd, {
|
||||
count: (prev?.count ?? 0) + files.length,
|
||||
updatedAt: prev && prev.updatedAt > updatedAt ? prev.updatedAt : updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!byCwd.has(defaultCwd)) byCwd.set(defaultCwd, { count: 0, updatedAt: '' });
|
||||
|
||||
return [...byCwd.entries()]
|
||||
.map(([cwd, v]) => ({ cwd, sessionCount: v.count, updatedAt: v.updatedAt, isDefault: cwd === defaultCwd }))
|
||||
.sort((a, b) => (a.isDefault ? -1 : b.isDefault ? 1 : b.updatedAt.localeCompare(a.updatedAt)));
|
||||
}
|
||||
|
||||
/** List sessions Claude has stored for a given working directory, newest first. */
|
||||
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
|
||||
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
|
||||
|
||||
@@ -299,10 +299,15 @@ async function handleClaudeCodeChat(
|
||||
const { email, username, userId } = ws.data;
|
||||
const homeDir = getHomeDirForRole(email, ws.data.role);
|
||||
|
||||
// The standalone /chat route runs every session from a dedicated `claude_sessions` working
|
||||
// directory, so its transcripts form their own Claude "project" group (source of truth) instead of
|
||||
// polluting the home-dir history. Other contexts (email/project panels) keep their own cwd.
|
||||
const cwd = msg.context === 'chat' ? ensureClaudeSessionsCwd(email) : resolveCwd(email, ws.data.role, msg.cwd);
|
||||
// The standalone /chat route runs from a chosen working directory (the pwd selector) or, by default,
|
||||
// a dedicated `claude_sessions` dir — so transcripts form their own Claude "project" group per cwd.
|
||||
// Other contexts (email/project panels) keep their own cwd.
|
||||
const cwd =
|
||||
msg.context === 'chat'
|
||||
? msg.cwd?.trim()
|
||||
? resolveCwd(email, ws.data.role, msg.cwd)
|
||||
: ensureClaudeSessionsCwd(email)
|
||||
: resolveCwd(email, ws.data.role, msg.cwd);
|
||||
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user