From 04bde70ed3c94a7a24190acc610161ea9b9a2cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 24 Jul 2026 12:41:49 +0000 Subject: [PATCH] =?UTF-8?q?chat:=20pwd=20selector=20=E2=80=94=20switch=20t?= =?UTF-8?q?he=20/chat=20working=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/api/chat/chat.ts | 40 ++++++--- src/servers/api/chat/claude-sessions.ts | 62 ++++++++++++- src/servers/api/pi/websocket.ts | 13 ++- .../src/apps/ChatHistory/ChatDetailPanel.tsx | 5 +- .../src/apps/ChatHistory/PwdSelector.tsx | 87 +++++++++++++++++++ .../src/apps/ChatHistory/SessionList.tsx | 17 +++- src/workspaces/state/src/index.ts | 4 +- src/workspaces/state/src/useClaudeSessions.ts | 45 +++++++--- 8 files changed, 237 insertions(+), 36 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index f0843b25..7af40d81 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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 }); }); diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 161672b6..d2ec4776 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -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(); + + 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)); diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index 36eb3ebe..ecb322cf 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -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; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index dac31c72..3c5aa080 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -3,7 +3,6 @@ import { useLocation } from 'react-router'; import { Save, Loader2 } from 'lucide-react'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { useAuth } from 'hooks/useAuth'; -import { getHostHome } from 'state/useModels'; import { useSavedSessions } from 'state/useSavedSessions'; import { useClaudeSessions } from 'state/useClaudeSessions'; import { usePiChat, EmbeddableChat } from '../Chat'; @@ -131,7 +130,9 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages, savedId: ini }; const sandboxed = !isSuperAdmin; - const cwd = isSuperAdmin ? { path: getHostHome() } : locationState?.cwd; + // Run the session in the pwd chosen in the Sessions panel; null → backend default (claude_sessions). + const [activeCwd] = usePanelChannel('chat:active-cwd', null); + const cwd = activeCwd ? { path: activeCwd } : locationState?.cwd; const initialMessage = locationState?.initialMessage ? { diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx new file mode 100644 index 00000000..3a8e29fc --- /dev/null +++ b/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { FolderOpen, ChevronDown, Check, CornerDownLeft } from 'lucide-react'; +import { useChatPwds } from 'state/useClaudeSessions'; + +type PwdSelectorProps = { + value: string | null; // null = the default claude_sessions dir + onChange: (cwd: string | null) => void; +}; + +// A shorter, friendlier label for a working directory. +const shorten = (cwd: string) => cwd.replace(/^\/home\/[^/]+/, '~'); +const basename = (cwd: string) => cwd.split('/').filter(Boolean).pop() ?? cwd; + +export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => { + const { pwds, defaultCwd } = useChatPwds(); + const [open, setOpen] = useState(false); + const [custom, setCustom] = useState(''); + + const isDefaultActive = value === null || value === defaultCwd; + const label = isDefaultActive ? 'claude_sessions' : basename(value!); + + const pick = (cwd: string | null) => { + onChange(cwd); + setOpen(false); + setCustom(''); + }; + const submitCustom = () => { + const p = custom.trim(); + if (p) pick(p); + }; + + return ( +
+ + + {open && ( + <> +
setOpen(false)} /> +
+
+ {pwds.map((p) => { + const selected = p.isDefault ? isDefaultActive : value === p.cwd; + return ( + + ); + })} +
+
+ setCustom(ev.target.value)} + onKeyDown={(ev) => ev.key === 'Enter' && submitCustom()} + placeholder="Enter a path…" + className="min-w-0 flex-1 bg-transparent px-1 text-xs outline-none placeholder:opacity-40" + /> + +
+
+ + )} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index ae65c9a1..2eceb158 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -5,12 +5,15 @@ import { usePanelChannel } from 'hooks/usePanelChannel'; import { useClaudeSessions } from 'state/useClaudeSessions'; import type { SelectedSession } from './ChatDetailPanel'; import type { ChatMessage } from '../Chat/types'; +import { PwdSelector } from './PwdSelector'; // Reads the /chat conversation list from Claude's own transcript store (source of truth). // Clicking a session loads its transcript and continues the real Claude session via --resume. export const SessionList = () => { const navigate = useNavigate(); - const { sessions, isLoading, refetch, loadSession, deleteSession, renameSession } = useClaudeSessions(); + // The working directory the list operates on (null = the default claude_sessions dir). + const [activeCwd, setActiveCwd] = usePanelChannel('chat:active-cwd', null); + const { sessions, isLoading, refetch, loadSession, deleteSession, renameSession } = useClaudeSessions(activeCwd); const [selected, setSelected] = usePanelChannel('chat:selected-session', null); const [openingId, setOpeningId] = useState(null); const [editingId, setEditingId] = useState(null); @@ -58,9 +61,15 @@ export const SessionList = () => { return (
{/* Header */} -
-

Sessions

-
+
+ { + setActiveCwd(cwd); + setSelected(null); // sessions belong to a cwd — clear the open one when switching + }} + /> +