diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/ChatHistory/defaultLayout.ts index 79cf3de4..24ce525c 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/defaultLayout.ts +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/defaultLayout.ts @@ -1,11 +1,25 @@ import type { LayoutNode } from 'officerdev'; +// The left column is itself a split: what is running now on top, the whole history below. They answer +// different questions from different sources — the agent's in-memory map versus transcripts on disk — +// and the live one is small and usually empty, hence the lopsided 25/75. export const defaultLayout: LayoutNode = { type: 'group', id: 'chat-history-root', direction: 'horizontal', children: [ - { node: { type: 'panel', id: 'chat-list', appType: 'chat-session-list' }, size: 35 }, + { + node: { + type: 'group', + id: 'chat-sidebar', + direction: 'vertical', + children: [ + { node: { type: 'panel', id: 'chat-live', appType: 'chat-live' }, size: 25 }, + { node: { type: 'panel', id: 'chat-list', appType: 'chat-session-list' }, size: 75 }, + ], + }, + size: 35, + }, { node: { type: 'panel', id: 'chat-detail', appType: 'chat-detail' }, size: 65 }, ], }; diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index f6776693..5f8fda0a 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -101,7 +101,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { { // Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx new file mode 100644 index 00000000..acb6a349 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx @@ -0,0 +1,86 @@ +import { useParams } from 'react-router'; +import { Activity, Loader2, Radio } from 'lucide-react'; +import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem } from '@/components/Data'; +import { useLiveSessions } from 'state/useLiveSessions'; +import { useClaudeSessions } from 'state/useClaudeSessions'; +import { useSelectedChatSession } from '../../channels'; +import { cwdFromSplat, chatSessionPath } from './chat-routes'; + +/** + * What the agent is actually running, as opposed to what it has ever run. + * + * The list below this one reads transcripts from disk — history, and thousands of it. This reads the + * agent's in-memory map over the sidecar protocol, so it is the only view that can tell you a + * conversation is still working while nothing is on screen. That distinction is the whole point: after + * a `pm2 restart officer`, or from a browser that has never seen the session, officer itself has no + * record of a live turn and only the agent can say. + */ +export const LiveSessions = () => { + const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>(); + const [selected] = useSelectedChatSession(); + const { live, isLoading, error, refetch } = useLiveSessions(); + + // Titles for free, when we happen to have them. This is the same query key the list below already + // holds, so it costs no request — but it only covers the group being browsed, and a live session can + // be in any of them. Hence the fallback to a short key rather than pretending we know. + const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null; + const { sessions } = useClaudeSessions(activeCwd); + const titleFor = (key: string) => sessions.find((session) => session.id === key)?.title; + + if (isLoading && live.length === 0) return ; + if (error) { + return ( + refetch()} className="cursor-pointer text-sm underline"> + Try again + + } + /> + ); + } + + if (live.length === 0) { + return ; + } + + return ( + + {live.map((session) => { + const title = titleFor(session.sessionKey); + // A conversation that has not been saved yet has no transcript to open, so it is shown but not + // linked — a row that navigates nowhere is worse than one that plainly isn't a link. + const isDraft = session.sessionKey.startsWith('new:'); + + return ( + + generating + + ) : ( + + idle + + ), + // The field worth surfacing: it is what keeps a session alive with nothing on screen, and + // what makes it unsafe to restart the agent sidecar. + session.pendingTasks > 0 ? ( + + {session.pendingTasks} background task{session.pendingTasks === 1 ? '' : 's'} + + ) : null, + ]} + /> + ); + })} + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/index.ts b/src/workspaces/officerdev/src/apps/ChatHistory/index.ts index 576f9678..893f8375 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/index.ts +++ b/src/workspaces/officerdev/src/apps/ChatHistory/index.ts @@ -1,9 +1,11 @@ import type { AppRegistryMeta } from '../../AppRegistry'; -import { MessageSquare, List } from 'lucide-react'; +import { MessageSquare, List, Radio } from 'lucide-react'; import { SessionList } from './SessionList'; +import { LiveSessions } from './LiveSessions'; import { ChatDetailPanel } from './ChatDetailPanel'; export { SessionList }; +export { LiveSessions }; export { ChatDetailPanel }; export type { SelectedSession } from './ChatDetailPanel'; export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './chat-routes'; @@ -16,6 +18,13 @@ export const appRegistryMetas: AppRegistryMeta[] = [ component: SessionList, availableOnPanel: false, }, + { + key: 'chat-live', + name: 'Live', + icon: Radio, + component: LiveSessions, + availableOnPanel: false, + }, { key: 'chat-detail', name: 'Chat', diff --git a/src/workspaces/state/src/useLiveSessions.ts b/src/workspaces/state/src/useLiveSessions.ts new file mode 100644 index 00000000..14cb6dc8 --- /dev/null +++ b/src/workspaces/state/src/useLiveSessions.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; +import { useAuth } from 'hooks/useAuth'; + +/** + * A session the agent has a process behind RIGHT NOW. + * + * Not the same question as `useClaudeSessions`, which lists conversations from transcripts on disk. + * Those are history and outnumber these enormously; this is the handful that are actually alive. + */ +export type LiveSession = { + sessionKey: string; + isGenerating: boolean; + /** Background tasks started but not yet notified — `run_in_background`, Monitor, and friends. */ + pendingTasks: number; +}; + +const LIVE_KEY = 'CHAT_LIVE_SESSIONS'; + +/** + * Polled rather than pushed. Liveness is owned by the agent sidecar and changes without officer being + * told — a turn ends, a background task reports — so there is no event to subscribe to that covers + * every transition. Ten seconds is short enough that the list is never meaningfully wrong and long + * enough to be free; the request is one in-memory map read on the other side. + * + * `refetchIntervalInBackground` is deliberately left off: a hidden tab does not need to know. + */ +export function useLiveSessions() { + const client = useClient(); + const { isAuthenticated } = useAuth(); + + const { data, isLoading, error, refetch } = useQuery<{ sessions: LiveSession[] }>({ + queryKey: [LIVE_KEY], + queryFn: () => client.get<{ sessions: LiveSession[] }>('/chat/live'), + enabled: isAuthenticated, + refetchInterval: 10_000, + staleTime: 5_000, + }); + + return { + live: data?.sessions ?? [], + isLoading, + error, + refetch, + }; +}