show what the agent is actually running, above the history
The /chat sidebar is now a vertical split: live sessions on top, the transcript list below. They look similar and answer completely different questions — the list reads conversations from disk, thousands of them, while this reads the agent's in-memory map over `claude:list`. Only the second can tell you a conversation is still working while nothing is on screen, which is exactly the state that has been invisible: after a `pm2 restart officer`, or from a browser that has never seen the session, officer has no record of a live turn and only the agent can say. `pendingTasks` is surfaced per row because it is the load-bearing number. It is what keeps a session alive with nothing on screen, and what makes restarting the agent sidecar unsafe at that moment. Polled at 10s rather than pushed: liveness changes without officer being told — a turn ends, a background task reports — so there is no single event to subscribe to. The request is one map read. Titles come from the sessions query already in cache, so they cost nothing, but that query only covers the group being browsed and a live session can be in any of them. Unmatched rows show a short key rather than inventing a name, and an unsaved chat renders unlinked rather than pointing at a transcript that does not exist yet. Closes the UI half of step 2 in docs/chat-session-lifetime.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,25 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
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 = {
|
export const defaultLayout: LayoutNode = {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'chat-history-root',
|
id: 'chat-history-root',
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
children: [
|
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 },
|
{ node: { type: 'panel', id: 'chat-detail', appType: 'chat-detail' }, size: 65 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
locked
|
locked
|
||||||
appTypes={{ allowed: ['chat-session-list', 'chat-detail'], fallback: 'chat-detail' }}
|
appTypes={{ allowed: ['chat-session-list', 'chat-live', 'chat-detail'], fallback: 'chat-detail' }}
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
onMobilePanelChange={(id) => {
|
onMobilePanelChange={(id) => {
|
||||||
// Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL;
|
// Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL;
|
||||||
|
|||||||
@@ -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 <LoadingBlock label="Asking the agent…" />;
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ErrorBlock
|
||||||
|
title="Could not reach the agent"
|
||||||
|
message="The sidecar did not answer. Nothing is necessarily wrong with a running session — this view just cannot see it."
|
||||||
|
action={
|
||||||
|
<button onClick={() => refetch()} className="cursor-pointer text-sm underline">
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (live.length === 0) {
|
||||||
|
return <EmptyBlock icon={Radio} title="Nothing running" hint="Sessions with a live agent appear here." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataList>
|
||||||
|
{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 (
|
||||||
|
<DataRow
|
||||||
|
key={session.sessionKey}
|
||||||
|
to={isDraft ? undefined : chatSessionPath(session.sessionKey)}
|
||||||
|
selected={session.sessionKey === sessionId}
|
||||||
|
title={title ?? (isDraft ? 'Unsaved chat' : `${session.sessionKey.slice(0, 8)}…`)}
|
||||||
|
meta={[
|
||||||
|
session.isGenerating ? (
|
||||||
|
<MetaItem key="g" icon={Loader2}>
|
||||||
|
<span className="text-duck-teal">generating</span>
|
||||||
|
</MetaItem>
|
||||||
|
) : (
|
||||||
|
<span key="g" className="text-muted-foreground">
|
||||||
|
idle
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
// 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 ? (
|
||||||
|
<MetaItem key="t" icon={Activity}>
|
||||||
|
{session.pendingTasks} background task{session.pendingTasks === 1 ? '' : 's'}
|
||||||
|
</MetaItem>
|
||||||
|
) : null,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DataList>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||||
import { MessageSquare, List } from 'lucide-react';
|
import { MessageSquare, List, Radio } from 'lucide-react';
|
||||||
import { SessionList } from './SessionList';
|
import { SessionList } from './SessionList';
|
||||||
|
import { LiveSessions } from './LiveSessions';
|
||||||
import { ChatDetailPanel } from './ChatDetailPanel';
|
import { ChatDetailPanel } from './ChatDetailPanel';
|
||||||
|
|
||||||
export { SessionList };
|
export { SessionList };
|
||||||
|
export { LiveSessions };
|
||||||
export { ChatDetailPanel };
|
export { ChatDetailPanel };
|
||||||
export type { SelectedSession } from './ChatDetailPanel';
|
export type { SelectedSession } from './ChatDetailPanel';
|
||||||
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './chat-routes';
|
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './chat-routes';
|
||||||
@@ -16,6 +18,13 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
|||||||
component: SessionList,
|
component: SessionList,
|
||||||
availableOnPanel: false,
|
availableOnPanel: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'chat-live',
|
||||||
|
name: 'Live',
|
||||||
|
icon: Radio,
|
||||||
|
component: LiveSessions,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'chat-detail',
|
key: 'chat-detail',
|
||||||
name: 'Chat',
|
name: 'Chat',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user