diff --git a/src/servers/api/pi/list-models.ts b/src/servers/api/pi/list-models.ts index f2e000ac..27255e4a 100644 --- a/src/servers/api/pi/list-models.ts +++ b/src/servers/api/pi/list-models.ts @@ -66,6 +66,7 @@ export async function listPiModels(): Promise { }; const models: ModelInfo[] = []; + const seen = new Set(); for (let i = 1; i < lines.length; i++) { const line = lines[i]!; @@ -78,11 +79,16 @@ export async function listPiModels(): Promise { const thinking = extractCol(line, 4); const images = extractCol(line, 5); - const normalizedProvider = provider === 'zai' ? 'opencode' : provider; + // zai and opencode are the same service — prefer opencode, skip zai duplicates + const displayProvider = provider === 'zai' ? 'opencode' : provider; + const dedupeKey = `${displayProvider}/${model}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + models.push({ id: `${provider}/${model}`, name: model, - provider: normalizedProvider, + provider: displayProvider, contextWindow: parseSize(context), maxTokens: parseSize(maxOut), reasoning: thinking === 'yes', diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index 8767231c..6cd1023f 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -241,7 +241,6 @@ export async function spawnPi( await ensureGoogleTokenFile(sandbox.userId, sandbox.email); const envFlags = [ - '-e', `PI_CODING_AGENT_DIR=/officer/pi-config`, '-e', `HOME=${containerHome}`, '-e', `OFFICER_USER_HOME=${containerHome}`, '-e', `OFFICER_USER_ROOT=/officer/user`, diff --git a/src/servers/api/pi/rest.ts b/src/servers/api/pi/rest.ts index f82408f8..ab0eee4e 100644 --- a/src/servers/api/pi/rest.ts +++ b/src/servers/api/pi/rest.ts @@ -51,9 +51,10 @@ piRestRouter.post('/pi/sessions', async (ctx: Context) => { const body = await ctx.req.json().catch(() => ({})); const userHome = getHomeDir(user.email); const filterCwd = body.cwd ? resolveBaseCwd(user.email, body.cwdRoot, body.cwd) : null; + const contextFilter = body.context ? { context: body.context as string, contextId: body.contextId as string | undefined } : undefined; try { - let sessions = await storage.listUserSessions(userHome); + let sessions = await storage.listUserSessions(userHome, contextFilter); if (filterCwd) { sessions = sessions.filter((s) => s.cwd === filterCwd); } diff --git a/src/servers/api/pi/session-manager.ts b/src/servers/api/pi/session-manager.ts index 9b755e03..c46fcae9 100644 --- a/src/servers/api/pi/session-manager.ts +++ b/src/servers/api/pi/session-manager.ts @@ -11,7 +11,9 @@ class SessionManager { email: string, cwd: string, model: string, - groupSlug?: string | null + groupSlug?: string | null, + context?: string, + contextId?: string, ): UserSession { let session = this.sessions.get(sessionId); @@ -35,6 +37,8 @@ class SessionManager { model, cwd, groupSlug: groupSlug || null, + context, + contextId, createdAt: Date.now(), updatedAt: Date.now(), messageCount: 0, diff --git a/src/servers/api/pi/storage.ts b/src/servers/api/pi/storage.ts index d2cc3329..2db735d5 100644 --- a/src/servers/api/pi/storage.ts +++ b/src/servers/api/pi/storage.ts @@ -251,6 +251,8 @@ export function messagesToJnlEntries(messages: Message[], meta: SessionMeta): Jn messageCount: meta.messageCount, createdAt: meta.createdAt, updatedAt: meta.updatedAt, + context: meta.context, + contextId: meta.contextId, }, }; entries.push(infoEntry); @@ -357,6 +359,8 @@ function indexEntryToMeta(sessionId: string, entry: SessionIndexEntry): SessionM messageCount: entry.messageCount, cost: entry.cost, groupSlug: entry.groupSlug ?? null, + context: entry.context, + contextId: entry.contextId, }; } @@ -371,6 +375,8 @@ function metaToIndexEntry(meta: SessionMeta, relFile: string): SessionIndexEntry messageCount: meta.messageCount, cost: meta.cost, groupSlug: meta.groupSlug ?? null, + context: meta.context, + contextId: meta.contextId, }; } @@ -495,14 +501,28 @@ export async function deleteSession( await saveIndex(baseCwd, index); } -export async function listUserSessions(baseCwd: string): Promise { +type SessionFilter = { + context?: string; + contextId?: string; +}; + +export async function listUserSessions(baseCwd: string, filter?: SessionFilter): Promise { let index = await loadIndex(baseCwd); if (Object.keys(index).length === 0) { index = await rebuildIndex(baseCwd); } - const sessions: SessionMeta[] = Object.entries(index).map(([id, entry]) => indexEntryToMeta(id, entry)); + let sessions: SessionMeta[] = Object.entries(index).map(([id, entry]) => indexEntryToMeta(id, entry)); + + if (filter?.context) { + if (filter.context === 'chat') { + // 'chat' matches sessions with no context or context='chat' + sessions = sessions.filter((s) => !s.context || s.context === 'chat'); + } else { + sessions = sessions.filter((s) => s.context === filter.context && s.contextId === filter.contextId); + } + } sessions.sort((a, b) => b.updatedAt - a.updatedAt); return sessions; @@ -748,6 +768,8 @@ export async function rebuildIndex(baseCwd: string): Promise { let createdAt = new Date(header.timestamp).getTime(); let updatedAt = createdAt; let groupSlug: string | null = null; + let context: string | undefined; + let contextId: string | undefined; // Count message entries and find session_info for (const entry of entries) { @@ -763,6 +785,8 @@ export async function rebuildIndex(baseCwd: string): Promise { createdAt = info.officer.createdAt; updatedAt = info.officer.updatedAt; groupSlug = info.officer.groupSlug ?? null; + context = info.officer.context; + contextId = info.officer.contextId; } } } @@ -777,6 +801,8 @@ export async function rebuildIndex(baseCwd: string): Promise { messageCount, cost, groupSlug, + context, + contextId, }; } catch { continue; diff --git a/src/servers/api/pi/types.ts b/src/servers/api/pi/types.ts index 97ab6ad0..a21d12dd 100644 --- a/src/servers/api/pi/types.ts +++ b/src/servers/api/pi/types.ts @@ -28,6 +28,8 @@ export type SessionMeta = { messageCount: number; cost: MessageCost; groupSlug?: string | null; + context?: string; + contextId?: string; }; export type GroupMeta = { @@ -53,6 +55,8 @@ export type ClientMessage = groupSlug?: string; attachmentIds?: string[]; thinking?: ThinkingLevel; + context?: string; + contextId?: string; } | { type: "resume"; @@ -70,6 +74,8 @@ export type ServerMessage = sessionId: string; model: string; cwd: string; + context?: string; + contextId?: string; } | { type: "assistant:text"; @@ -230,6 +236,8 @@ export type JnlSessionInfoEntry = JnlEntryBase & { messageCount: number; createdAt: number; updatedAt: number; + context?: string; + contextId?: string; }; }; @@ -247,6 +255,8 @@ export type SessionIndexEntry = { messageCount: number; cost: MessageCost; groupSlug?: string | null; + context?: string; + contextId?: string; }; export type SessionIndex = Record; diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index 98149754..ede3ee31 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -242,7 +242,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora async function handleChat( ws: ServerWebSocket, - msg: { prompt: string; sessionId?: string; model?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[]; thinking?: string } + msg: { prompt: string; sessionId?: string; model?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[]; thinking?: string; context?: string; contextId?: string } ): Promise { const { email, username, userId } = ws.data; const sessionId = msg.sessionId || randomUUID(); @@ -276,13 +276,13 @@ async function handleChat( ? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd) : resolveHostCwd(msg.cwdRoot, msg.cwd); const groupSlug = msg.groupSlug || null; - const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug); + const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId); session.sandboxed = sandboxed; session.userId = userId; sessionManager.attachWs(sessionId, ws); wsToSessionMap.set(ws as any, sessionId); - sendToClient(ws, { type: 'session:init', sessionId, model, cwd }); + sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId }); if (!session.piProcess) { try { const onEvent = createEventHandler(sessionId, model, cwd, homeDir); @@ -353,7 +353,7 @@ async function handleResume( sessionManager.attachWs(sessionId, ws); wsToSessionMap.set(ws as any, sessionId); - sendToClient(ws, { type: 'session:init', sessionId, model: session.model, cwd: session.cwd }); + sendToClient(ws, { type: 'session:init', sessionId, model: session.model, cwd: session.cwd, context: session.meta.context, contextId: session.meta.contextId }); // Spawn fresh Pi process if needed if (!session.piProcess) { diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index e9fd08e2..60426800 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url'; import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, SERVER_CONFIG_DIR, PI_CONFIG_DIR } from '@@/data-path'; import { getUsers, getServerIntegration, getUserIntegration } from 'officerdb'; +const ensureDir = (dir: string) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); return dir; }; + type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number }; type ShellInfo = { command: string; args: string[]; name: string }; type BridgeSession = { @@ -191,6 +193,8 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number, '-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`, '-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`, '-v', `${PI_CONFIG_DIR}:/officer/pi-config:ro`, + '-v', `${PI_CONFIG_DIR}:${containerHome}/.pi/agent`, + '-v', `${ensureDir(join(DATA_PATH, email, 'pi-sessions'))}:${containerHome}/.pi/agent/sessions`, '-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`, ...googleMounts, '-v', `${join(DATA_PATH, email, 'integrations')}:/officer/user/integrations:ro`, diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index 011f884f..af5bbf89 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -10,11 +10,17 @@ type ChatSessionSelection = { }; export const ChatPanelWrapper = () => { - const { cwd, root } = useWorkspace(); + const { workspaceId, cwd, root } = useWorkspace(); const scoped = cwd !== '~'; const hostRoot = root === '~' || root === 'officer.dev'; const sandboxed = !hostRoot; + const chatContext = workspaceId?.startsWith('proj-layout-') + ? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') } + : workspaceId && !workspaceId.startsWith('screens/') + ? { context: 'workspace' as const, contextId: workspaceId } + : {}; + const [selection] = usePanelChannel('chat:panel-session', null); const [, setActiveSession] = usePanelChannel('chat:active-session', null); @@ -23,7 +29,7 @@ export const ChatPanelWrapper = () => { const sessionId = selection?.sessionId ?? undefined; const model = selection?.model ?? undefined; - const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped }); + const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped, ...chatContext }); useEffect(() => { setActiveSession(chat.sessionId); @@ -39,6 +45,7 @@ export const ChatPanelWrapper = () => { cwd={cwdParam} sandboxed={sandboxed} replaceUrl={false} + {...chatContext} /> ); }; diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/EmbeddableChat.tsx b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/EmbeddableChat.tsx index d4d0d993..84dbfb0b 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/EmbeddableChat.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/EmbeddableChat.tsx @@ -20,6 +20,8 @@ type EmbeddableChatProps = { replaceUrl?: boolean; autoSend?: boolean; chat?: UsePiChatType; + context?: string; + contextId?: string; }; export const EmbeddableChat = ({ className, ...params }: EmbeddableChatProps) => { diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index 5027c1c3..f21cd8e6 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -21,12 +21,14 @@ type UseEmbeddableChatParams = { replaceUrl?: boolean; autoSend?: boolean; chat?: UsePiChatType; + context?: string; + contextId?: string; }; export function useEmbeddableChat(params: UseEmbeddableChatParams) { const { initialMessage, defaultInput = '', promptPrefix, cwd, sandboxed, autoSend = false, chat: externalChat } = params; - const internalChat = usePiChat(params.sessionId, params.initialModel, { replaceUrl: params.replaceUrl ?? false }); + const internalChat = usePiChat(params.sessionId, params.initialModel, { replaceUrl: params.replaceUrl ?? false, context: params.context, contextId: params.contextId }); const chat = externalChat ?? internalChat; const { diff --git a/src/workspaces/officerdev/src/apps/Chat/types.ts b/src/workspaces/officerdev/src/apps/Chat/types.ts index 65eaa43c..c8708656 100644 --- a/src/workspaces/officerdev/src/apps/Chat/types.ts +++ b/src/workspaces/officerdev/src/apps/Chat/types.ts @@ -28,6 +28,8 @@ export type SessionEntry = { messageCount: number; cost: MessageCost; groupSlug?: string | null; + context?: string; + contextId?: string; }; export type GroupEntry = { @@ -62,7 +64,7 @@ export type TaskInfo = { }; export type ServerMessage = - | { type: 'session:init'; sessionId: string; model: string; cwd: string } + | { type: 'session:init'; sessionId: string; model: string; cwd: string; context?: string; contextId?: string } | { type: 'assistant:text'; text: string } | { type: 'assistant:delta'; text: string } | { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record } diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 8c035d23..c06d3749 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -5,6 +5,7 @@ import { usePanelChannel } from 'hooks/usePanelChannel'; import { useChatSessions } from 'state/useChatSessions'; import { useChatGroups } from 'state/useChatGroups'; import { getProviderDisplayName } from 'state/useModels'; +import { useWorkspace } from '../../components/Workspace'; import type { SelectedSession } from './ChatDetailPanel'; import { CreateGroupDialog } from './CreateGroupDialog'; import { GroupContextMenu } from './GroupContextMenu'; @@ -12,7 +13,14 @@ import { SessionContextMenu } from './SessionContextMenu'; export const SessionList = () => { const navigate = useNavigate(); - const { sessions, deleteSession } = useChatSessions(); + const { workspaceId } = useWorkspace(); + // Only scope sessions for actual project/workspace contexts, not screen layout IDs like 'screens/chat' + const contextFilter = workspaceId?.startsWith('proj-layout-') + ? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') } + : workspaceId && !workspaceId.startsWith('screens/') + ? { context: 'workspace' as const, contextId: workspaceId } + : undefined; + const { sessions, deleteSession } = useChatSessions(contextFilter); const { groups } = useChatGroups(); const [selected, setSelected] = usePanelChannel('chat:selected-session', null); const [collapsed, setCollapsed] = useState>(new Set()); diff --git a/src/workspaces/officerdev/src/hooks/usePiChat.ts b/src/workspaces/officerdev/src/hooks/usePiChat.ts index 2a1170d9..18a8630a 100644 --- a/src/workspaces/officerdev/src/hooks/usePiChat.ts +++ b/src/workspaces/officerdev/src/hooks/usePiChat.ts @@ -17,10 +17,12 @@ type UsePiChatOptions = { resourceChatDir?: string; taskInfo?: TaskInfo; projectScoped?: boolean; + context?: string; + contextId?: string; }; export function usePiChat(initialSessionId?: string, initialModel?: string | null, options?: UsePiChatOptions) { - const { replaceUrl = true, storage, resourceChatDir, taskInfo, projectScoped } = options ?? {}; + const { replaceUrl = true, storage, resourceChatDir, taskInfo, projectScoped, context, contextId } = options ?? {}; const [messages, setMessages] = useState([]); const [streamingText, setStreamingText] = useState(''); const [isGenerating, setIsGenerating] = useState(false); @@ -54,7 +56,8 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul const sessionIdRef = useRef(initialSessionId ?? null); const saveTimerRef = useRef(null); - const { getSession, saveMessages, invalidate: invalidateSessions } = useChatSessions(); + const sessionFilter = context ? { context, contextId } : undefined; + const { getSession, saveMessages, invalidate: invalidateSessions } = useChatSessions(sessionFilter); const token = localStorage.getItem('BEARER_TOKEN'); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -301,6 +304,8 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul ...(resourceChatDir ? { resourceChatDir } : {}), ...(taskInfo ? { taskInfo } : {}), ...(thinking ? { thinking } : {}), + ...(context ? { context } : {}), + ...(contextId ? { contextId } : {}), }); } diff --git a/src/workspaces/state/src/useChatSessions.ts b/src/workspaces/state/src/useChatSessions.ts index 29489a17..e7eaec2b 100644 --- a/src/workspaces/state/src/useChatSessions.ts +++ b/src/workspaces/state/src/useChatSessions.ts @@ -3,15 +3,26 @@ import { useAuth } from 'hooks/useAuth'; import { useClient } from 'hooks/useClient'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -export function useChatSessions() { +type ChatSessionsFilter = { + context?: string; + contextId?: string; +}; + +export function useChatSessions(filter?: ChatSessionsFilter) { const client = useClient(); const queryClient = useQueryClient(); const { isAuthenticated } = useAuth(); const { data: sessions = [] } = useQuery({ - queryKey: ['PI_SESSIONS'], + queryKey: ['PI_SESSIONS', filter?.context, filter?.contextId], enabled: isAuthenticated, - queryFn: () => client.post<{ sessions: SessionEntry[] }>('/pi/sessions').then((r) => r.sessions), + queryFn: () => + client + .post<{ sessions: SessionEntry[] }>('/pi/sessions', { + ...(filter?.context ? { context: filter.context } : {}), + ...(filter?.contextId ? { contextId: filter.contextId } : {}), + }) + .then((r) => r.sessions), }); function getSession(sessionId: string) { @@ -24,13 +35,13 @@ export function useChatSessions() { async function renameSession(sessionId: string, title: string) { await client.patch(`/pi/sessions/${sessionId}`, { title }); - queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] }); + queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS', filter?.context, filter?.contextId] }); } async function deleteSession(sessionId: string) { await client.delete(`/pi/sessions/${sessionId}`); queryClient.setQueryData( - ['PI_SESSIONS'], + ['PI_SESSIONS', filter?.context, filter?.contextId], (prev) => prev?.filter((s) => s.id !== sessionId) ?? [], ); } @@ -40,7 +51,7 @@ export function useChatSessions() { } function invalidate() { - queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] }); + queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS', filter?.context, filter?.contextId] }); } return {