diff --git a/src/servers/api/pi/rest.ts b/src/servers/api/pi/rest.ts index 36da2a16..26738af6 100644 --- a/src/servers/api/pi/rest.ts +++ b/src/servers/api/pi/rest.ts @@ -4,6 +4,7 @@ import * as storage from './storage'; import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono'; import { listPiModels } from './list-models'; import { getHomeDir } from '../../data-path'; +import { resolveBaseCwd } from './websocket'; import { logger } from './logger'; /** @@ -38,7 +39,8 @@ piRestRouter.get('/pi/models', async (ctx: Context) => { /** * POST /api/pi/sessions - * List all sessions for the current user + * List all sessions for the current user. + * Optionally filter by cwd/cwdRoot to show only project-scoped sessions. */ piRestRouter.post('/pi/sessions', async (ctx: Context) => { const user = ctx.get('user'); @@ -46,11 +48,15 @@ piRestRouter.post('/pi/sessions', async (ctx: Context) => { return ctx.json({ error: 'Unauthorized' }, 401); } - // TODO: Implement proper user home directory resolution + const body = await ctx.req.json().catch(() => ({})); const userHome = getHomeDir(user.email); + const filterCwd = body.cwd ? resolveBaseCwd(user.email, body.cwdRoot, body.cwd) : null; try { - const sessions = await storage.listUserSessions(userHome); + let sessions = await storage.listUserSessions(userHome); + if (filterCwd) { + sessions = sessions.filter((s) => s.cwd === filterCwd); + } return ctx.json({ sessions }); } catch (err) { logger.error('Failed to list sessions', { email: ctx.get('email'), error: String(err) }); @@ -84,7 +90,7 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => { // Not in root, search in groups const groups = await storage.listGroups(userHome); let found = false; - + for (const group of groups) { try { ({ meta, messages } = await storage.loadSession(userHome, sessionId, group.slug)); @@ -94,7 +100,7 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => { continue; } } - + if (!found) { throw new Error('Session not found'); } @@ -137,7 +143,7 @@ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => { try { // Find the session (root or in group) let groupSlug: string | null = null; - + try { const { meta } = await storage.loadSession(userHome, sessionId); groupSlug = meta.groupSlug || null; @@ -158,8 +164,8 @@ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => { const updatedMeta = await storage.updateSessionMeta(userHome, sessionId, { title: body.title, }, groupSlug); - - return ctx.json({ + + return ctx.json({ success: true, session: updatedMeta, }); @@ -189,7 +195,7 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => { try { // Find the session (root or in group) let groupSlug: string | null = null; - + try { const { meta } = await storage.loadSession(userHome, sessionId); groupSlug = meta.groupSlug || null; @@ -208,7 +214,7 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => { } await storage.deleteSession(userHome, sessionId, groupSlug); - + // Update group session count if in a group if (groupSlug) { try { @@ -220,7 +226,7 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => { // Group might not exist anymore } } - + return ctx.json({ success: true }); } catch (err) { logger.error('Failed to delete session', { sessionId, email: ctx.get('email'), error: String(err) }); @@ -265,11 +271,11 @@ piRestRouter.post('/pi/groups', async (ctx: Context) => { } const body = await ctx.req.json(); - + if (!body.name || typeof body.name !== 'string') { return ctx.json({ error: 'Name is required and must be a string' }, 400); } - + if (!body.slug || typeof body.slug !== 'string') { return ctx.json({ error: 'Slug is required and must be a string' }, 400); } @@ -319,7 +325,7 @@ piRestRouter.post('/pi/groups', async (ctx: Context) => { await storage.saveGroup(userHome, groupMeta); } - return ctx.json({ + return ctx.json({ success: true, group: groupMeta, }); @@ -366,8 +372,8 @@ piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => { } const body = await ctx.req.json(); - - const updates: any = {}; + + const updates: Record = {}; if (body.name && typeof body.name === 'string') { updates.name = body.name; } @@ -383,7 +389,7 @@ piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => { try { const updatedGroup = await storage.updateGroupMeta(userHome, groupSlug, updates); - return ctx.json({ + return ctx.json({ success: true, group: updatedGroup, }); @@ -482,7 +488,7 @@ piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => { const updatedMeta = await storage.moveSession(userHome, sessionId, fromGroupSlug, toGroupSlug); - return ctx.json({ + return ctx.json({ success: true, session: updatedMeta, }); diff --git a/src/servers/api/pi/types.ts b/src/servers/api/pi/types.ts index 3e50cea6..e8f78c40 100644 --- a/src/servers/api/pi/types.ts +++ b/src/servers/api/pi/types.ts @@ -46,6 +46,7 @@ export type ClientMessage = sessionId?: string; model?: string; cwd?: string; + cwdRoot?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[]; @@ -53,6 +54,8 @@ export type ClientMessage = | { type: "resume"; sessionId: string; + cwd?: string; + cwdRoot?: string; } | { type: "stop"; diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index a6ef2386..72653a54 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -50,6 +50,11 @@ const resolveCwd = (home: string, cwd?: string) => { return home; }; +export const resolveBaseCwd = (email: string, cwdRoot?: string, cwd?: string) => { + const root = resolveRoot(email, cwdRoot); + return resolveCwd(root, cwd); +}; + const wsToSessionMap = new WeakMap(); function sendToClient(ws: ServerWebSocket | null, msg: ServerMessage): void { @@ -93,7 +98,7 @@ export function close(ws: ServerWebSocket): void { } } -function createEventHandler(sessionId: string, model: string, cwd: string) { +function createEventHandler(sessionId: string, model: string, cwd: string, storageDir: string) { return async (event: PiEvent): Promise => { const session = sessionManager.getSession(sessionId); if (!session) return; @@ -212,7 +217,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string) { // Save session to disk try { - await storage.saveSession(cwd, sessionId, session.meta, session.messages); + await storage.saveSession(storageDir, sessionId, session.meta, session.messages); logger.info('Session saved to disk', { sessionId, messageCount: session.messages.length }); } catch (err) { logger.error('Failed to save session', { sessionId, error: String(err) }); @@ -280,7 +285,7 @@ async function handleChat( sendToClient(ws, { type: 'session:init', sessionId, model, cwd }); if (!session.piProcess) { try { - const onEvent = createEventHandler(sessionId, model, cwd); + const onEvent = createEventHandler(sessionId, model, cwd, homeDir); session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined); logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed }); } catch (err) { @@ -313,17 +318,17 @@ async function handleChat( async function handleResume( ws: ServerWebSocket, - msg: { sessionId: string } + msg: { sessionId: string; cwd?: string; cwdRoot?: string } ): Promise { const { email } = ws.data; const { sessionId } = msg; try { let session = sessionManager.getSession(sessionId); - + if (!session) { const homeDir = getHomeDir(email); - + try { const { meta, messages } = await storage.loadSession(homeDir, sessionId); @@ -349,7 +354,7 @@ async function handleResume( try { const homeDir = getHomeDir(email); const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined; - const onEvent = createEventHandler(sessionId, session.model, session.cwd); + const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir); session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent, sandbox); logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed }); } catch (err) { diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx new file mode 100644 index 00000000..093c4ea3 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from 'react'; +import { MessageSquare, History, Plus } from 'lucide-react'; +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'; +import { useWorkspace } from '../../components/Workspace/WorkspaceContext'; +import { useChatSessions } from './useChatSessions'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { getProviderDisplayName } from 'state/useModels'; + +type ChatSessionSelection = { + sessionId: string | null; + model?: string | null; +}; + +function formatModel(model: string): string { + if (!model.includes('/')) return model; + const [provider, modelId] = model.split('/') as [string, string]; + if (provider.startsWith('officer-local-')) { + return `${getProviderDisplayName(provider)} - ${modelId}`; + } + return model.replace('/', ' - '); +} + +export const ChatHeader = () => { + const { cwd, root } = useWorkspace(); + const scoped = cwd !== '~'; + const { sessions } = useChatSessions(scoped ? { cwd, cwdRoot: root } : {}); + const [selection, setSelection] = usePanelChannel('chat:panel-session', null); + const [activeSessionId] = usePanelChannel('chat:active-session', null); + const [open, setOpen] = useState(false); + const autoResumedRef = useRef(false); + + // Auto-resume the latest session on mount + useEffect(() => { + if (autoResumedRef.current || selection) return; + if (sessions.length > 0) { + const latest = sessions[0]!; + setSelection({ sessionId: latest.id, model: latest.model ?? null }); + autoResumedRef.current = true; + } + }, [sessions, selection]); + + const selectSession = (sessionId: string | null, model?: string | null) => { + setSelection({ sessionId, model }); + setOpen(false); + }; + + const activeInList = activeSessionId ? sessions.some((s) => s.id === activeSessionId) : false; + + return ( + <> + + Chat + + + + + +
+ Sessions + +
+
+ {/* Show active session at top if not yet in fetched list */} + {activeSessionId && !activeInList && ( + + )} + {sessions.length === 0 && !activeSessionId ? ( +
No sessions yet
+ ) : ( + sessions.map((session) => { + const isActive = session.id === activeSessionId; + return ( + + ); + }) + )} +
+
+
+ + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index 4b832aad..9766e08e 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -1,17 +1,44 @@ +import { useEffect } from 'react'; import { useWorkspace } from '../../components/Workspace'; +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { usePiChat } from '../../hooks/usePiChat'; import { EmbeddableChat } from './EmbeddableChat'; +type ChatSessionSelection = { + sessionId: string | null; + model?: string | null; +}; + export const ChatPanelWrapper = () => { const { cwd, root } = useWorkspace(); const scoped = cwd !== '~'; const hostRoot = root === '~' || root === 'officer.dev'; const sandboxed = !hostRoot; + const [selection] = usePanelChannel('chat:panel-session', null); + const [, setActiveSession] = usePanelChannel('chat:active-session', null); + + const cwdParam = scoped ? { root, path: cwd } : undefined; + + const sessionId = selection?.sessionId ?? undefined; + const model = selection?.model ?? undefined; + + const chat = usePiChat(sessionId, model, { replaceUrl: false }); + + useEffect(() => { + setActiveSession(chat.sessionId); + }, [chat.sessionId]); + return ( ); }; diff --git a/src/workspaces/officerdev/src/apps/Chat/index.ts b/src/workspaces/officerdev/src/apps/Chat/index.ts index b0ab89b1..f84200bb 100644 --- a/src/workspaces/officerdev/src/apps/Chat/index.ts +++ b/src/workspaces/officerdev/src/apps/Chat/index.ts @@ -1,6 +1,7 @@ import type { AppRegistryMeta } from '../../AppRegistry'; import { MessageSquare } from 'lucide-react'; import { ChatPanelWrapper } from './ChatPanelWrapper'; +import { ChatHeader } from './ChatHeader'; export { MessageList } from './components/MessageList'; export { MessageBubble, StreamingBubble } from './components/MessageBubble'; @@ -29,5 +30,6 @@ export const appRegistryMetas: AppRegistryMeta[] = [ name: 'Chat', icon: MessageSquare, component: ChatPanelWrapper, + header: ChatHeader, }, ]; diff --git a/src/workspaces/officerdev/src/apps/Chat/useChatSessions.ts b/src/workspaces/officerdev/src/apps/Chat/useChatSessions.ts index 1253fd7a..1eac6bff 100644 --- a/src/workspaces/officerdev/src/apps/Chat/useChatSessions.ts +++ b/src/workspaces/officerdev/src/apps/Chat/useChatSessions.ts @@ -5,23 +5,30 @@ import { useQuery } from '@tanstack/react-query'; type UseChatSessionsParams = { cwd?: string; + cwdRoot?: string; }; -export function useChatSessions({ cwd }: UseChatSessionsParams = {}) { +export function useChatSessions({ cwd, cwdRoot }: UseChatSessionsParams = {}) { const client = useClient(); const { isAuthenticated } = useAuth(); const { data: sessions = [], isLoading } = useQuery({ - queryKey: ['PI_SESSIONS', cwd], + queryKey: ['PI_SESSIONS', cwd, cwdRoot], enabled: isAuthenticated, queryFn: async () => { - const result = await client.post<{ sessions: SessionEntry[] }>('/pi/sessions', cwd ? { cwd } : {}); + const body: Record = {}; + if (cwd) body.cwd = cwd; + if (cwdRoot) body.cwdRoot = cwdRoot; + const result = await client.post<{ sessions: SessionEntry[] }>('/pi/sessions', body); return result.sessions; }, }); function searchSessions(query: string) { - return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`); + const params = new URLSearchParams({ q: query }); + if (cwd) params.set('cwd', cwd); + if (cwdRoot) params.set('cwdRoot', cwdRoot); + return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?${params}`); } return { diff --git a/src/workspaces/officerdev/src/hooks/usePiChat.ts b/src/workspaces/officerdev/src/hooks/usePiChat.ts index 00fcbe28..1b9bc9a5 100644 --- a/src/workspaces/officerdev/src/hooks/usePiChat.ts +++ b/src/workspaces/officerdev/src/hooks/usePiChat.ts @@ -41,6 +41,11 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul } }, [initialSessionId, initialModel, settings]); + // Sync selectedModel when initialModel changes (e.g. resuming a session) + useEffect(() => { + if (initialModel) setSelectedModel(initialModel); + }, [initialModel]); + const streamingRef = useRef(''); const rafRef = useRef(null); const sessionIdRef = useRef(initialSessionId ?? null);