From 2a8df0def18ba0fc6eef04426dab7c8db491fcee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 6 Mar 2026 13:36:25 +0000 Subject: [PATCH] saved sessions: replace transient history with persistent DB storage and auto-save - Add saved_sessions table and CRUD endpoints (save, list, resume, update, delete) - Save is instant (no LLM summarization), stores exact conversation with tool calls - Resume loads full message history into chat UI, sends transcript to agent on first message - Auto-save updates DB after every agent response once a session is saved - Delete old filesystem-based session/group management (sessions router, useChatSessions, useChatGroups) - Clean up ChatHeader, SessionList, ChatDetailPanel for saved sessions flow Co-Authored-By: Claude Opus 4.6 --- .../Screens/Dashboard/ChatHistory/Widget.tsx | 30 +- .../Screens/Dashboard/ChatHistory/index.tsx | 5 +- src/apps/officer-web/state/useInitialData.ts | 4 +- .../0001_freezing_carmella_unuscione.sql | 19 + .../migrations/meta/0001_snapshot.json | 2324 +++++++++++++++++ .../officer_db/migrations/meta/_journal.json | 7 + src/databases/officer_db/src/index.ts | 8 + .../officer_db/src/queries/saved-sessions.ts | 59 + src/databases/officer_db/src/schema/chat.ts | 74 +- src/databases/officer_db/src/types.ts | 15 +- src/servers/api/pi/rest.ts | 507 +--- src/servers/api/pi/types.ts | 41 +- src/servers/api/pi/websocket.ts | 13 +- .../api/saved-sessions/saved-sessions.ts | 160 ++ src/servers/api/scrape/scrape.ts | 7 +- src/servers/api/sessions/sessions.ts | 207 -- src/servers/api/upload/upload.ts | 5 +- src/servers/data-path.ts | 11 +- src/servers/hono.ts | 4 +- .../officerdev/src/apps/Chat/ChatHeader.tsx | 246 +- .../src/apps/Chat/ChatList/index.tsx | 17 +- .../src/apps/Chat/ChatPanelWrapper.tsx | 93 +- .../officerdev/src/apps/Chat/index.ts | 2 - .../officerdev/src/apps/Chat/types.ts | 64 - .../src/apps/Chat/useChatSession.ts | 77 - .../src/apps/Chat/useChatSessions.ts | 41 - .../src/apps/Chat/useSlashCommands.ts | 35 +- .../src/apps/ChatHistory/ChatDetailPanel.tsx | 162 +- .../apps/ChatHistory/CreateGroupDialog.tsx | 143 - .../src/apps/ChatHistory/GroupContextMenu.tsx | 122 - .../apps/ChatHistory/SessionContextMenu.tsx | 128 +- .../src/apps/ChatHistory/SessionList.tsx | 498 +--- .../officerdev/src/apps/ChatHistory/index.ts | 2 +- .../officerdev/src/hooks/usePiChat.ts | 24 +- src/workspaces/officerdev/src/index.ts | 104 +- src/workspaces/state/src/index.ts | 5 +- src/workspaces/state/src/useChatGroups.ts | 53 - src/workspaces/state/src/useChatSessions.ts | 108 - src/workspaces/state/src/useSavedSessions.ts | 105 + 39 files changed, 3272 insertions(+), 2257 deletions(-) create mode 100644 src/databases/officer_db/migrations/0001_freezing_carmella_unuscione.sql create mode 100644 src/databases/officer_db/migrations/meta/0001_snapshot.json create mode 100644 src/databases/officer_db/src/queries/saved-sessions.ts create mode 100644 src/servers/api/saved-sessions/saved-sessions.ts delete mode 100644 src/servers/api/sessions/sessions.ts delete mode 100644 src/workspaces/officerdev/src/apps/Chat/useChatSession.ts delete mode 100644 src/workspaces/officerdev/src/apps/Chat/useChatSessions.ts delete mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/CreateGroupDialog.tsx delete mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/GroupContextMenu.tsx delete mode 100644 src/workspaces/state/src/useChatGroups.ts delete mode 100644 src/workspaces/state/src/useChatSessions.ts create mode 100644 src/workspaces/state/src/useSavedSessions.ts diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/Widget.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/Widget.tsx index ca45633c..a29e7e19 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/Widget.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/Widget.tsx @@ -1,16 +1,15 @@ -import { Link } from 'react-router'; import { MessageSquare, Trash2 } from 'lucide-react'; import { Widget } from 'widgets/Widget'; -import { useChatSessions } from 'state/useChatSessions'; +import { useSavedSessions } from 'state/useSavedSessions'; export const ChatHistory = () => { - const { sessions, deleteSession } = useChatSessions(); + const { sessions, deleteSavedSession } = useSavedSessions(); return ( - +
{sessions.length === 0 ? ( -

No sessions yet

+

No saved sessions yet

) : (
    {sessions.map((session) => ( @@ -18,18 +17,10 @@ export const ChatHistory = () => { key={session.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group" > - +
    - - {session.title} - - ({session.id.slice(0, 8)}) - - + {session.title} {new Date(session.createdAt).toLocaleDateString(undefined, { month: 'short', @@ -38,15 +29,10 @@ export const ChatHistory = () => { minute: '2-digit', })} - {session.model && ( - - {session.model.includes('/') ? session.model.replace('/', ' - ') : session.model} - - )}
    - +
    + ))}
    - Sessions -
    - {sessions.length > 0 && ( - - )} - -
    + Saved Sessions +
    - {/* Show active session at top if not yet in fetched list */} - {activeSessionId && !activeInList && ( - - )} - {sessions.length === 0 && !activeSessionId ? ( -
    No sessions yet
    + {sessions.length === 0 ? ( +
    No saved sessions
    ) : ( - sessions.map((session) => { - const isActive = session.id === activeSessionId; - return ( - - ); - }) + +
    +
    + {new Date(session.createdAt).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} +
    + + )) )}
diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatList/index.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatList/index.tsx index 3a37270f..1d05e5f2 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatList/index.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatList/index.tsx @@ -1,13 +1,13 @@ import { MessageSquare } from 'lucide-react'; -import { useChatSessions } from 'state/useChatSessions'; +import { useSavedSessions } from 'state/useSavedSessions'; export const ChatList = () => { - const { sessions } = useChatSessions(); + const { sessions } = useSavedSessions(); if (sessions.length === 0) { return (
- No sessions yet. Start a new chat! + No saved sessions yet.
); } @@ -15,9 +15,8 @@ export const ChatList = () => { return (
{sessions.map((session) => ( -
@@ -34,16 +33,10 @@ export const ChatList = () => { minute: '2-digit', })}
- {session.model && ( -
- {session.model.includes('/') ? session.model.replace('/', ' - ') : session.model} -
- )}
- + ))} ); }; - diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index 37afd180..05f7bf76 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -1,18 +1,20 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useWorkspace } from '../../components/Workspace'; import { usePanelChannel } from 'hooks/usePanelChannel'; +import { useSavedSessions } from 'state/useSavedSessions'; import { usePiChat } from '../../hooks/usePiChat'; import { EmbeddableChat } from './EmbeddableChat'; -type ChatSessionSelection = { - sessionId: string | null; - model?: string | null; - dashboardId?: string; -}; +import type { ChatSessionSelection } from './ChatHeader'; +import type { AutoSaveMapping } from '../ChatHistory/ChatDetailPanel'; + +import type { ChatMessage } from './types'; type ChatPanelInnerProps = { sessionId?: string; model?: string; + resumeSummary?: string; + initialMessages?: ChatMessage[]; scoped: boolean; sandboxed: boolean; cwdParam?: { root?: string; path: string }; @@ -22,8 +24,43 @@ type ChatPanelInnerProps = { onTurnComplete?: (hadToolCalls: boolean) => void; }; -const ChatPanelInner = ({ sessionId, model, scoped, sandboxed, cwdParam, promptPrefix, chatContext, setActiveSession, onTurnComplete }: ChatPanelInnerProps) => { - const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped, onTurnComplete, ...chatContext }); +const ChatPanelInner = ({ + sessionId, + model, + resumeSummary, + initialMessages, + scoped, + sandboxed, + cwdParam, + promptPrefix, + chatContext, + setActiveSession, + onTurnComplete, +}: ChatPanelInnerProps) => { + const { updateSessionMessages } = useSavedSessions(); + const [autoSave] = usePanelChannel('chat:auto-save', null); + const autoSaveRef = useRef(autoSave); + autoSaveRef.current = autoSave; + + const handleTurnComplete = useCallback( + (hadToolCalls: boolean) => { + onTurnComplete?.(hadToolCalls); + const mapping = autoSaveRef.current; + if (mapping) { + updateSessionMessages(mapping.savedId, mapping.sessionId).catch(() => {}); + } + }, + [onTurnComplete, updateSessionMessages], + ); + + const chat = usePiChat(sessionId, model, { + replaceUrl: false, + projectScoped: scoped, + onTurnComplete: handleTurnComplete, + resumeSummary, + initialMessages, + ...chatContext, + }); useEffect(() => { setActiveSession(chat.sessionId); @@ -50,37 +87,45 @@ export const ChatPanelWrapper = () => { const hostRoot = root === '~' || root === 'officer.dev'; const sandboxed = !hostRoot; - const chatContext = dashboardId === 'email' || dashboardId === 'screens/email' - ? { context: 'email' as const } - : dashboardId?.startsWith('proj-layout-') - ? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') } - : dashboardId && !dashboardId.startsWith('screens/') - ? { context: 'dashboard' as const, contextId: dashboardId } - : {}; + const chatContext = + dashboardId === 'email' || dashboardId === 'screens/email' + ? { context: 'email' as const } + : dashboardId?.startsWith('proj-layout-') + ? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') } + : dashboardId && !dashboardId.startsWith('screens/') + ? { context: 'dashboard' as const, contextId: dashboardId } + : {}; const [selection] = usePanelChannel('chat:panel-session', null); const [, setActiveSession] = usePanelChannel('chat:active-session', null); const [, setPreviewRefresh] = usePanelChannel('preview:refresh', 0); const [, setFilesRefresh] = usePanelChannel('files:refresh-signal', 0); - const onTurnComplete = useCallback((hadToolCalls: boolean) => { - if (hadToolCalls) { - setPreviewRefresh(Date.now()); - setFilesRefresh(Date.now()); - } - }, [setPreviewRefresh, setFilesRefresh]); + const onTurnComplete = useCallback( + (hadToolCalls: boolean) => { + if (hadToolCalls) { + setPreviewRefresh(Date.now()); + setFilesRefresh(Date.now()); + } + }, + [setPreviewRefresh, setFilesRefresh], + ); const cwdParam = scoped ? { root, path: cwd } : undefined; const isCurrentWorkspace = !selection?.dashboardId || selection.dashboardId === dashboardId; - const sessionId = isCurrentWorkspace ? selection?.sessionId ?? undefined : undefined; - const model = isCurrentWorkspace ? selection?.model ?? undefined : undefined; + const sessionId = isCurrentWorkspace ? (selection?.sessionId ?? undefined) : undefined; + const model = isCurrentWorkspace ? (selection?.model ?? undefined) : undefined; + const resumeSummary = isCurrentWorkspace ? selection?.resumeSummary : undefined; + const initialMessages = isCurrentWorkspace ? selection?.initialMessages : undefined; return ( Promise<{ success: boolean; feedback: string }>; }; - -// Legacy type aliases for backward compatibility during migration -// TODO: Remove after Phase 9 cleanup - -/** @deprecated Use SessionEntry instead */ -export type LegacySessionEntry = { - id: string; - title: string; - createdAt: number; - provider: 'claude' | 'pi-mono'; - model?: string | null; -}; - -/** @deprecated Use ChatMessage with toolCallId instead */ -export type LegacyChatMessage = - | { role: 'user'; text: string; images?: { filename: string; dataUrl: string }[] } - | { role: 'assistant'; text: string } - | { role: 'system'; text: string } - | { - role: 'tool'; - toolName: string; - toolInput: Record; - toolUseId: string; - output?: string; - isError?: boolean; - } - | { role: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean } - | { role: 'error'; text: string }; - -/** @deprecated Use ServerMessage instead */ -export type LegacyServerMessage = - | { type: 'session:init'; sessionId: string; model: string | null } - | { type: 'system:prompt'; text: string } - | { type: 'assistant:text'; text: string } - | { type: 'assistant:partial'; text: string } - | { type: 'tool:use'; toolName: string; toolInput: Record; toolUseId: string } - | { type: 'tool:result'; toolUseId: string; output: string; isError: boolean } - | { type: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean } - | { type: 'error'; message: string } - | { type: 'stopped' } - | { type: 'messages:sync'; messages: LegacyChatMessage[]; streamingText: string; isGenerating: boolean }; diff --git a/src/workspaces/officerdev/src/apps/Chat/useChatSession.ts b/src/workspaces/officerdev/src/apps/Chat/useChatSession.ts deleted file mode 100644 index d317ae10..00000000 --- a/src/workspaces/officerdev/src/apps/Chat/useChatSession.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { SessionEntry, ChatMessage, Message } from './types'; -import { useClient } from 'hooks/useClient'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; - -type SessionWithMessages = { - id: string; - title: string; - model: string; - cwd: string; - groupSlug?: string | null; - createdAt: number; - updatedAt: number; - messageCount: number; - cost: { - inputTokens: number; - outputTokens: number; - totalUSD: number; - }; - messages: Message[]; -}; - -type UseChatSessionParams = { - sessionId: string | null | undefined; -}; - -export function useChatSession({ sessionId }: UseChatSessionParams) { - const client = useClient(); - const queryClient = useQueryClient(); - - const { data: session, isLoading } = useQuery({ - queryKey: ['PI_SESSION', sessionId], - enabled: !!sessionId, - queryFn: async () => { - if (!sessionId) return null; - const result = await client.get<{ session: SessionWithMessages }>(`/pi/sessions/${sessionId}`); - return result.session; - }, - }); - - async function saveMessages(messages: ChatMessage[]) { - if (!sessionId) return; - await client.put(`/pi/sessions/${sessionId}/messages`, messages); - } - - async function rename(title: string) { - if (!sessionId) return; - await client.patch(`/pi/sessions/${sessionId}`, { title }); - queryClient.invalidateQueries({ queryKey: ['PI_SESSION', sessionId] }); - queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] }); - } - - async function deleteSession() { - if (!sessionId) return; - await client.delete(`/pi/sessions/${sessionId}`); - queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] }); - queryClient.removeQueries({ queryKey: ['PI_SESSION', sessionId] }); - } - - async function moveToGroup(groupSlug: string | null) { - if (!sessionId) return; - await client.post(`/pi/sessions/${sessionId}/move`, { groupSlug }); - queryClient.invalidateQueries({ queryKey: ['PI_SESSION', sessionId] }); - queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] }); - queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] }); - } - - return { - session, - isLoading, - saveMessages, - rename, - delete: deleteSession, - moveToGroup, - }; -} - -export type UseChatSessionType = ReturnType; diff --git a/src/workspaces/officerdev/src/apps/Chat/useChatSessions.ts b/src/workspaces/officerdev/src/apps/Chat/useChatSessions.ts deleted file mode 100644 index 1eac6bff..00000000 --- a/src/workspaces/officerdev/src/apps/Chat/useChatSessions.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { SessionEntry } from './types'; -import { useAuth } from 'hooks/useAuth'; -import { useClient } from 'hooks/useClient'; -import { useQuery } from '@tanstack/react-query'; - -type UseChatSessionsParams = { - cwd?: string; - cwdRoot?: string; -}; - -export function useChatSessions({ cwd, cwdRoot }: UseChatSessionsParams = {}) { - const client = useClient(); - const { isAuthenticated } = useAuth(); - - const { data: sessions = [], isLoading } = useQuery({ - queryKey: ['PI_SESSIONS', cwd, cwdRoot], - enabled: isAuthenticated, - queryFn: async () => { - 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) { - 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 { - sessions, - isLoading, - searchSessions, - }; -} - -export type UseChatSessionsType = ReturnType; diff --git a/src/workspaces/officerdev/src/apps/Chat/useSlashCommands.ts b/src/workspaces/officerdev/src/apps/Chat/useSlashCommands.ts index dfde1c4b..780beb10 100644 --- a/src/workspaces/officerdev/src/apps/Chat/useSlashCommands.ts +++ b/src/workspaces/officerdev/src/apps/Chat/useSlashCommands.ts @@ -1,5 +1,3 @@ -import { useChatSession } from './useChatSession'; - export type SlashCommandResult = { handled: true; feedback: string } | { handled: false }; type UseSlashCommandsParams = { @@ -7,8 +5,6 @@ type UseSlashCommandsParams = { }; export function useSlashCommands({ sessionId }: UseSlashCommandsParams) { - const { rename } = useChatSession({ sessionId }); - const execute = async (input: string): Promise => { const trimmed = input.trim(); if (!trimmed.startsWith('/')) return { handled: false }; @@ -16,32 +12,15 @@ export function useSlashCommands({ sessionId }: UseSlashCommandsParams) { const spaceIndex = trimmed.indexOf(' '); const commandName = spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex); - const args = spaceIndex === -1 ? '' : trimmed.slice(spaceIndex + 1).trim(); - try { - switch (commandName) { - case 'rename': { - if (!args.trim()) { - return { handled: true, feedback: 'Usage: /rename ' }; - } - await rename(args); - return { handled: true, feedback: `Session renamed to "${args}"` }; - } - - case 'help': { - const helpText = [ - 'Available commands:', - ' /rename - Rename the current session', - ' /help - Show this help message', - ].join('\n'); - return { handled: true, feedback: helpText }; - } - - default: - return { handled: false }; + switch (commandName) { + case 'help': { + const helpText = ['Available commands:', ' /help - Show this help message'].join('\n'); + return { handled: true, feedback: helpText }; } - } catch (error) { - return { handled: true, feedback: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }; + + default: + return { handled: false }; } }; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 107cce6c..ccdbecb2 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -1,18 +1,24 @@ -import { useState } from 'react'; -import { useLocation, useNavigate } from 'react-router'; -import { Trash2, Home, Monitor } from 'lucide-react'; +import { useState, useCallback, useRef } from 'react'; +import { useLocation } from 'react-router'; +import { Save, Loader2, Check } from 'lucide-react'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { useAuth } from 'hooks/useAuth'; -import { useChatSessions } from 'state/useChatSessions'; import { getHostHome } from 'state/useModels'; +import { useSavedSessions } from 'state/useSavedSessions'; import { usePiChat, EmbeddableChat } from '../Chat'; +import type { ChatMessage } from '../Chat/types'; export type SelectedSession = { id: string; model?: string | null; + resumeSummary?: string; + initialMessages?: ChatMessage[]; } | null; +export type AutoSaveMapping = { sessionId: string; savedId: number } | null; + const CHANNEL = 'chat:selected-session'; +const AUTO_SAVE_CHANNEL = 'chat:auto-save'; type ChatLocationState = { initialMessage?: string; @@ -27,21 +33,36 @@ type DetailBarProps = { sessionTitle: string | undefined; isConnected: boolean; isGenerating: boolean; - onDelete: (() => void) | undefined; + sessionId: string | null; + isSaved: boolean; + onSave: () => void; + isSaving: boolean; }; -function DetailBar({ sessionTitle, isConnected, isGenerating, onDelete }: DetailBarProps) { +function DetailBar({ sessionTitle, isConnected, isGenerating, sessionId, isSaved, onSave, isSaving }: DetailBarProps) { return (
- {onDelete && ( - - )} + {sessionId && + (isSaving ? ( + + + Saving… + + ) : isSaved ? ( + + + Saved + + ) : ( + + ))}
{sessionTitle ?? 'New chat'} @@ -60,78 +81,44 @@ function DetailBar({ sessionTitle, isConnected, isGenerating, onDelete }: Detail ); } -type SessionChatProps = { - sessionId: string; - model?: string | null; +type NewChatProps = { + resumeSummary?: string; + initialMessages?: ChatMessage[]; }; -function SessionChat({ sessionId, model }: SessionChatProps) { - const navigate = useNavigate(); - const { sessions, deleteSession } = useChatSessions(); - const [, setSelected] = usePanelChannel(CHANNEL, null); - const sessionTitle = sessions.find((s) => s.id === sessionId)?.title; - - const chat = usePiChat(sessionId, model, { replaceUrl: false }); - - return ( -
- { - await deleteSession(sessionId); - setSelected(null); - navigate('/chat', { replace: true }); - }} - /> - -
- ); -} - -function CwdToggle({ cwdMode, onChange }: { cwdMode: 'user' | 'host'; onChange: (mode: 'user' | 'host') => void }) { - const hostHome = getHostHome(); - if (!hostHome) return null; - - return ( -
- cwd - - -
- ); -} - -function NewChat({ allowHostMode }: { allowHostMode?: boolean }) { +function NewChat({ resumeSummary, initialMessages }: NewChatProps) { const location = useLocation(); const locationState = location.state as ChatLocationState; const { user } = useAuth(); const isSuperAdmin = user?.role === 'Super Admin'; + const { saveSession, updateSessionMessages } = useSavedSessions(); + const [isSaving, setIsSaving] = useState(false); + const [autoSave, setAutoSave] = usePanelChannel(AUTO_SAVE_CHANNEL, null); + const autoSaveRef = useRef(autoSave); + autoSaveRef.current = autoSave; - const chat = usePiChat(undefined, locationState?.model); + const onTurnComplete = useCallback(() => { + const mapping = autoSaveRef.current; + if (mapping) { + updateSessionMessages(mapping.savedId, mapping.sessionId).catch(() => {}); + } + }, [updateSessionMessages]); + + const chat = usePiChat(undefined, locationState?.model, { resumeSummary, initialMessages, onTurnComplete }); + + const isSaved = !!autoSave && autoSave.sessionId === chat.sessionId; + + const handleSave = async () => { + if (!chat.sessionId || isSaving) return; + setIsSaving(true); + try { + const result = await saveSession(chat.sessionId); + setAutoSave({ sessionId: chat.sessionId, savedId: result.id }); + } finally { + setIsSaving(false); + } + }; - // Super Admin always operates as host — no toggle needed const sandboxed = !isSuperAdmin; const cwd = isSuperAdmin ? { path: getHostHome() } : locationState?.cwd; @@ -150,7 +137,10 @@ function NewChat({ allowHostMode }: { allowHostMode?: boolean }) { sessionTitle={undefined} isConnected={chat.isConnected} isGenerating={chat.isGenerating} - onDelete={undefined} + sessionId={chat.sessionId} + isSaved={isSaved} + onSave={handleSave} + isSaving={isSaving} /> { +export const ChatDetailPanel = () => { const [selected] = usePanelChannel(CHANNEL, null); if (!selected) { @@ -181,9 +167,7 @@ export const ChatDetailPanel = ({ allowHostMode }: ChatDetailPanelProps) => { ); } - if (selected.id.startsWith('new')) { - return ; - } - - return ; + return ( + + ); }; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/CreateGroupDialog.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/CreateGroupDialog.tsx deleted file mode 100644 index e61f4d0e..00000000 --- a/src/workspaces/officerdev/src/apps/ChatHistory/CreateGroupDialog.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useState } from 'react'; -import { X } from 'lucide-react'; -import { useChatGroups } from 'state/useChatGroups'; - -type CreateGroupDialogProps = { - onClose: () => void; - onCreateWithSessions?: (name: string, slug: string, description?: string) => Promise; -}; - -function toSlug(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); -} - -export function CreateGroupDialog({ onClose, onCreateWithSessions }: CreateGroupDialogProps) { - const { createGroup } = useChatGroups(); - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - - if (!name.trim()) { - setError('Name is required'); - return; - } - - const slug = toSlug(name); - if (!slug) { - setError('Name must contain at least one alphanumeric character'); - return; - } - - setIsSubmitting(true); - setError(null); - - try { - if (onCreateWithSessions) { - await onCreateWithSessions(name.trim(), slug, description.trim() || undefined); - } else { - await createGroup(name.trim(), slug, description.trim() || undefined); - } - onClose(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create group'); - setIsSubmitting(false); - } - } - - function handleBackdropClick(e: React.MouseEvent) { - if (e.target === e.currentTarget) { - onClose(); - } - } - - return ( -
-
- {/* Header */} -
-

Create Group

- -
- - {/* Form */} -
-
- - setName(e.target.value)} - placeholder="e.g., Work Projects" - className="w-full px-3 py-2 rounded-md border border-duck-dark/20 dark:border-foreground/20 bg-background text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 dark:placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-duck-teal/50" - autoFocus - disabled={isSubmitting} - /> - {name && ( -

- Slug: {toSlug(name) || '(invalid)'} -

- )} -
- -
- -