Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/design/web-shell/session-active-work-live-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ renders `activeWorkState: active` separately when no foreground prompt is
running; this state can represent queued prompt work as well as background
work.

The floating Todo panel animates an `in_progress` item only while the local
stream, daemon foreground state, or per-session active-work state confirms
that execution is live. A persisted `in_progress` value without live activity
keeps its static status glyph instead of implying that work is still running.

The field is optional for compatibility with older daemons. It uses the
bridge's existing hold cache, capability negotiation, and freshness window, so
the live-state request remains an in-memory read with no ACP round trip.
Expand Down
17 changes: 9 additions & 8 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ const {
mockReleaseDetachedWebTerminal,
mockReleaseWebTerminal,
mockUseWorkspaceSessionLiveState,
mockUseDaemonActivePromptBridge,
mockUseDaemonSessionActivityBridge,
} = vi.hoisted(() => {
const connection: MockConnection = {
status: 'connected',
Expand Down Expand Up @@ -679,7 +679,7 @@ const {
mockReleaseWebTerminal: vi.fn(),
mockReleaseDetachedWebTerminal: vi.fn(),
mockUseWorkspaceSessionLiveState: vi.fn(() => new Map()),
mockUseDaemonActivePromptBridge: vi.fn(),
mockUseDaemonSessionActivityBridge: vi.fn(),
};
});

Expand Down Expand Up @@ -1570,7 +1570,7 @@ vi.mock('./session-catalog/session-catalog-hooks', () => ({
hasActivePrompt: testState.sessionHasActivePrompt,
authoritative: true,
}),
useDaemonActivePromptBridge: mockUseDaemonActivePromptBridge,
useDaemonSessionActivityBridge: mockUseDaemonSessionActivityBridge,
// The Workspaces overview panel's per-row session counts; inert here.
useSessionCatalogQuery: () => ({
page: undefined,
Expand Down Expand Up @@ -8473,10 +8473,11 @@ beforeEach(() => {
workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }],
};
mockUseWorkspaceSessionLiveState.mockClear();
mockUseDaemonActivePromptBridge.mockReset();
mockUseDaemonActivePromptBridge.mockImplementation(
() => testState.sessionHasActivePrompt,
);
mockUseDaemonSessionActivityBridge.mockReset();
mockUseDaemonSessionActivityBridge.mockImplementation(() => ({
hasActivePrompt: testState.sessionHasActivePrompt,
activeWorkState: undefined,
}));
mockWorkspace.status = 'connected';
mockWorkspace.refreshCapabilities.mockReset();
mockWorkspace.refreshCapabilities.mockResolvedValue(
Expand Down Expand Up @@ -10108,7 +10109,7 @@ describe('App conversation indicator keep-alive (#9487)', () => {
renderApp({ sidebar: false });
await flush();

expect(mockUseDaemonActivePromptBridge).toHaveBeenCalledWith(
expect(mockUseDaemonSessionActivityBridge).toHaveBeenCalledWith(
mockWorkspace.client,
'/tmp/live',
'session-1',
Expand Down
12 changes: 10 additions & 2 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ import {
import { useVoiceWorkspaceSettings } from './voice/use-voice-workspace-settings';
import {
useSessionCatalogController,
useDaemonActivePromptBridge,
useDaemonSessionActivityBridge,
} from './session-catalog/session-catalog-hooks';
import {
loadSessionCatalogOnce,
Expand Down Expand Up @@ -3270,7 +3270,10 @@ export function App({
? trustedLiveWorkspaces[0]?.cwd
: undefined
: connection.workspaceCwd;
const sessionHasActivePrompt = useDaemonActivePromptBridge(
const {
hasActivePrompt: sessionHasActivePrompt,
activeWorkState: sessionActiveWorkState,
} = useDaemonSessionActivityBridge(
workspace.client,
activePromptWorkspaceCwd,
connection.sessionId,
Expand Down Expand Up @@ -17728,6 +17731,11 @@ export function App({
<TodoPanel
todos={showFloatingTodos ? floatingTodos : []}
statusItems={floatingBottomStatusItems}
hasLiveActivity={
streamingState !== 'idle' ||
sessionHasActivePrompt ||
sessionActiveWorkState === 'active'
}
onOpen={
showFloatingTodos
? floatingTodosUseSessionWorkflow
Expand Down
4 changes: 3 additions & 1 deletion packages/web-shell/client/components/panels/TodoPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface TodoPanelProps {
todos: TodoItem[];
title?: string;
statusItems?: readonly WebShellBottomStatusItem[];
hasLiveActivity?: boolean;
onOpen?: () => void;
}

Expand All @@ -28,6 +29,7 @@ export const TodoPanel = memo(function TodoPanel({
todos,
title,
statusItems = [],
hasLiveActivity = true,
onOpen,
}: TodoPanelProps) {
const { t } = useI18n();
Expand Down Expand Up @@ -138,7 +140,7 @@ export const TodoPanel = memo(function TodoPanel({
className={`${styles.item} ${getStatusClass(todo.status)}`}
>
<span className={styles.icon} aria-hidden="true">
{todo.status === 'in_progress' ? (
{todo.status === 'in_progress' && hasLiveActivity ? (
<span className={styles.loadingIcon} />
) : (
getTodoStatusIcon(todo.status)
Expand Down
42 changes: 33 additions & 9 deletions packages/web-shell/client/session-catalog/session-catalog-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export function useSessionActivePromptState(
sessionId: string | undefined,
): {
hasActivePrompt: boolean;
activeWorkState: DaemonSessionSummary['activeWorkState'];
authoritative: boolean;
observationRevision: number | undefined;
} {
Expand Down Expand Up @@ -215,12 +216,15 @@ export function useSessionActivePromptState(
getLiveSessionRevision,
() => undefined,
);
const liveSession =
liveSessionRevision !== undefined && sessionId !== undefined
? store.getLiveSession(workspaceCwd!, sessionId)
: undefined;
const liveActivePrompt =
liveSessionRevision === undefined
? undefined
: sessionId !== undefined &&
store.getLiveSession(workspaceCwd!, sessionId)?.hasActivePrompt ===
true;
: liveSession?.hasActivePrompt === true;
const liveActiveWorkState = liveSession?.activeWorkState;
const hasLiveSessions = liveActivePrompt !== undefined;
const authorityBaselineRef = useRef<
| {
Expand Down Expand Up @@ -275,13 +279,15 @@ export function useSessionActivePromptState(
if (!workspaceCwd || !sessionId) {
return {
hasActivePrompt: false,
activeWorkState: undefined,
authoritative: false,
observationRevision: undefined,
};
}
if (liveActivePrompt !== undefined) {
return {
hasActivePrompt: liveActivePrompt,
activeWorkState: liveActiveWorkState,
authoritative: liveAnswerIsFreshForTarget,
observationRevision: liveSessionRevision,
};
Expand All @@ -291,6 +297,7 @@ export function useSessionActivePromptState(
: undefined;
return {
hasActivePrompt: row?.hasActivePrompt === true,
activeWorkState: row?.activeWorkState,
// Never settle-grade, whether or not the row is on the page. A row that
// drops off a bounded page between refetches is indistinguishable from one
// whose turn ended, and treating that as "the turn ended" is exactly the
Expand All @@ -310,15 +317,23 @@ export function useSessionActivePromptState(
* silent tool call from a finished turn (#9487). Publishing `undefined` while
* the answer is unknown leaves that provider's pre-existing heuristics alone.
*
* Returns the plain boolean for rendering, so a caller needs only this hook.
* Returns both live facts for callers that need them; the boolean wrapper
* below preserves the existing prompt-only API.
*/
export function useDaemonActivePromptBridge(
export function useDaemonSessionActivityBridge(
client: DaemonClient,
workspaceCwd: string | undefined,
sessionId: string | undefined,
): boolean {
const { hasActivePrompt, authoritative, observationRevision } =
useSessionActivePromptState(client, workspaceCwd, sessionId);
): {
hasActivePrompt: boolean;
activeWorkState: DaemonSessionSummary['activeWorkState'];
} {
const {
hasActivePrompt,
activeWorkState,
authoritative,
observationRevision,
} = useSessionActivePromptState(client, workspaceCwd, sessionId);
// Idempotent, so the main view and its ChatPane sharing one provider both
// publishing the same value is harmless; a split pane, which renders a
// ChatPane without an App around it, needs its own.
Expand All @@ -344,7 +359,16 @@ export function useDaemonActivePromptBridge(
setDaemonActivePrompt,
workspaceCwd,
]);
return hasActivePrompt;
return { hasActivePrompt, activeWorkState };
}

export function useDaemonActivePromptBridge(
client: DaemonClient,
workspaceCwd: string | undefined,
sessionId: string | undefined,
): boolean {
return useDaemonSessionActivityBridge(client, workspaceCwd, sessionId)
.hasActivePrompt;
}

export function useSessionCatalogPolling(
Expand Down
Loading