Skip to content

Commit 1c2faaf

Browse files
committed
Implemented session state indicator
1 parent 2339789 commit 1c2faaf

9 files changed

Lines changed: 430 additions & 85 deletions

File tree

packages/ui/src/features/chat/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import type { AskUserQuestion } from "@pi-deck/core/protocol/events.js";
44

55
export type ToolCallStatus = "pending" | "running" | "done" | "error" | "cancelled";
66

7+
/**
8+
* Coarse, per-session lifecycle state surfaced as the colour-coded status dot in the left rail
9+
* (`PidSessionRow`). Derived from the live message/tool state plus the last turn's outcome — see
10+
* `selectSessionRailStatus`. Priority when several could apply: waiting > working > failed > done.
11+
*/
12+
export type RailStatus = "working" | "waiting" | "done" | "failed" | "idle";
13+
714
/**
815
* Inline approval surfaced on a tool-call card when the agent-mode plugin needs the user's
916
* decision before letting the tool run. Set when `session.tool.approval.requested` arrives

packages/ui/src/features/chat/useMessagesStore.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useComposerStore } from "./composer/useComposerStore.js";
66
import type {
77
AssistantMessageEntry,
88
MessageEntry,
9+
RailStatus,
910
ToolCallEntry,
1011
ToolCallStatus,
1112
UserMessageImage,
@@ -15,6 +16,8 @@ interface SessionMessageState {
1516
messages: MessageEntry[];
1617
toolCalls: Record<string, ToolCallEntry>;
1718
isTurnInFlight: boolean;
19+
lastOutcome?: "ok" | "failed";
20+
outcomeViewed?: boolean;
1821
}
1922

2023
interface MessagesStoreState {
@@ -63,6 +66,8 @@ interface MessagesStoreState {
6366
) => void;
6467
endTurn: (sessionId: string, cancelled: boolean | undefined) => void;
6568
markTurnInFlight: (sessionId: string, value: boolean) => void;
69+
markTurnFailed: (sessionId: string) => void;
70+
markViewed: (sessionId: string) => void;
6671
clearSession: (sessionId: string) => void;
6772
/**
6873
* Replace the chat transcript + tool-call cache for a session with the snapshot the
@@ -484,7 +489,14 @@ export const useMessagesStore = create<MessagesStoreState>((set) => ({
484489
return {
485490
bySession: {
486491
...state.bySession,
487-
[sessionId]: { ...session, messages, toolCalls, isTurnInFlight: false },
492+
[sessionId]: {
493+
...session,
494+
messages,
495+
toolCalls,
496+
isTurnInFlight: false,
497+
lastOutcome: cancelled ? "failed" : "ok",
498+
outcomeViewed: false,
499+
},
488500
},
489501
};
490502
}),
@@ -495,7 +507,38 @@ export const useMessagesStore = create<MessagesStoreState>((set) => ({
495507
return {
496508
bySession: {
497509
...state.bySession,
498-
[sessionId]: { ...session, isTurnInFlight: value },
510+
[sessionId]: value
511+
? { ...session, isTurnInFlight: true, lastOutcome: undefined, outcomeViewed: false }
512+
: { ...session, isTurnInFlight: false },
513+
},
514+
};
515+
}),
516+
517+
markTurnFailed: (sessionId) =>
518+
set((state) => {
519+
const session = state.bySession[sessionId];
520+
if (!session?.isTurnInFlight) return state;
521+
return {
522+
bySession: {
523+
...state.bySession,
524+
[sessionId]: {
525+
...session,
526+
isTurnInFlight: false,
527+
lastOutcome: "failed",
528+
outcomeViewed: false,
529+
},
530+
},
531+
};
532+
}),
533+
534+
markViewed: (sessionId) =>
535+
set((state) => {
536+
const session = state.bySession[sessionId];
537+
if (!session || session.outcomeViewed) return state;
538+
return {
539+
bySession: {
540+
...state.bySession,
541+
[sessionId]: { ...session, outcomeViewed: true },
499542
},
500543
};
501544
}),
@@ -576,6 +619,26 @@ export function selectTurnInFlight(sessionId: string | undefined) {
576619
sessionId ? (state.bySession[sessionId]?.isTurnInFlight ?? false) : false;
577620
}
578621

622+
/**
623+
* Coarse rail status for a session's status dot.
624+
*/
625+
export function selectSessionRailStatus(sessionId: string | undefined) {
626+
return (state: MessagesStoreState): RailStatus => {
627+
if (!sessionId) return "idle";
628+
const session = state.bySession[sessionId];
629+
if (!session) return "idle";
630+
for (const call of Object.values(session.toolCalls)) {
631+
if (call.pendingApproval || call.pendingAsk) return "waiting";
632+
}
633+
if (session.isTurnInFlight) return "working";
634+
if (!session.outcomeViewed) {
635+
if (session.lastOutcome === "failed") return "failed";
636+
if (session.lastOutcome === "ok") return "done";
637+
}
638+
return "idle";
639+
};
640+
}
641+
579642
/**
580643
* Identify the most recent assistant message in a session. Used by `PlanCard` to hide its
581644
* Approve/Revise footer on stale plans — once a newer assistant turn lands, the prior plan

packages/ui/src/features/sessions/PidSessionRow.tsx

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import type { SessionSummary } from "@pi-deck/core/domain/session.js";
22
import { useEffect, useRef, useState } from "react";
33
import { ConfirmDialog } from "../../components/dialogs/ConfirmDialog.js";
44
import { InlineRename } from "../../components/InlineRename.js";
5-
import { Package, Pencil, Trash2 } from "../../components/icons/index.js";
5+
import { CheckCheck, Package, Pencil, Trash2 } from "../../components/icons/index.js";
66
import { ContextMenu, type ContextMenuItem } from "../../components/ui/ContextMenu.js";
77
import { relativeTime } from "../../lib/format/relative-time";
88
import { useNavStore } from "../../lib/useNavStore";
9-
import { useMessagesStore } from "../chat/useMessagesStore.js";
9+
import type { RailStatus } from "../chat/types.js";
10+
import { selectSessionRailStatus, useMessagesStore } from "../chat/useMessagesStore.js";
1011
import { warmSession } from "./sessionWarmup.js";
1112
import { useSessionsStore } from "./useSessionsStore";
1213

@@ -16,6 +17,15 @@ const HOVER_PREFETCH_DELAY_MS = 150;
1617
// to sit alongside `var(--t-12)` mono labels.
1718
const MENU_ICON_SIZE = 14;
1819

20+
// Accessible name for the status dot per state. `idle` gets no label (it's the resting default —
21+
// labelling every quiet row just adds screen-reader noise).
22+
const STATUS_LABEL: Record<Exclude<RailStatus, "idle">, string> = {
23+
working: "Running",
24+
waiting: "Waiting for your input",
25+
done: "Finished",
26+
failed: "Failed",
27+
};
28+
1929
export interface PidSessionRowProps {
2030
session: SessionSummary;
2131
active: boolean;
@@ -24,11 +34,20 @@ export interface PidSessionRowProps {
2434
export function PidSessionRow({ session, active }: PidSessionRowProps) {
2535
const [confirmOpen, setConfirmOpen] = useState(false);
2636
const [editing, setEditing] = useState(false);
27-
// The session is "live" while its worker is mid-turn (prompt sent, no turn-end yet).
28-
// Only the active session can ever be in flight — but we still key by session id so the
29-
// selector cost stays per-row and the rendered dot follows the right session if the user
30-
// switches focus mid-turn.
31-
const live = useMessagesStore((s) => s.bySession[session.id]?.isTurnInFlight ?? false);
37+
// Coarse lifecycle for the status dot: working / waiting / done / failed / idle. Keyed by
38+
// session id so the cost stays per-row and the dot follows the right session across focus
39+
// switches. The selector returns a primitive, so a stable status doesn't re-render the row.
40+
const status = useMessagesStore(selectSessionRailStatus(session.id));
41+
42+
// Viewing the session you're focused on clears its done/failed dot to neutral idle. Covers the
43+
// "finished while you're already watching it" case; `activateSession` covers "switched to a
44+
// finished session", and the "Mark as completed" menu item is a manual trigger for the same
45+
// `markViewed` — acknowledging the turn without opening the session.
46+
useEffect(() => {
47+
if (active && (status === "done" || status === "failed")) {
48+
useMessagesStore.getState().markViewed(session.id);
49+
}
50+
}, [active, status, session.id]);
3251

3352
const onClick = () => {
3453
if (editing) return;
@@ -63,7 +82,20 @@ export function PidSessionRow({ session, active }: PidSessionRowProps) {
6382
[],
6483
);
6584

85+
// "Mark as completed" acknowledges a finished/failed turn — it greys the dot to idle without
86+
// opening the session. Only meaningful when there's a terminal outcome to acknowledge.
87+
const canAcknowledge = status === "done" || status === "failed";
6688
const menuItems: ContextMenuItem[] = [
89+
...(canAcknowledge
90+
? ([
91+
{
92+
label: "Mark as completed",
93+
icon: <CheckCheck size={MENU_ICON_SIZE} aria-hidden />,
94+
onSelect: () => useMessagesStore.getState().markViewed(session.id),
95+
},
96+
{ kind: "separator" },
97+
] satisfies ContextMenuItem[])
98+
: []),
6799
{
68100
label: "Rename",
69101
icon: <Pencil size={MENU_ICON_SIZE} aria-hidden />,
@@ -106,9 +138,11 @@ export function PidSessionRow({ session, active }: PidSessionRowProps) {
106138
title={session.title}
107139
>
108140
<span
109-
className="pid-rail-row-marker"
110-
data-state={active ? "active" : "idle"}
111-
aria-hidden
141+
className="pid-rail-row-dot"
142+
data-status={status}
143+
{...(status === "idle"
144+
? { "aria-hidden": true }
145+
: { role: "img", "aria-label": STATUS_LABEL[status], title: STATUS_LABEL[status] })}
112146
/>
113147
<span className="pid-rail-row-main">
114148
{editing ? (
@@ -127,16 +161,7 @@ export function PidSessionRow({ session, active }: PidSessionRowProps) {
127161
)}
128162
{session.branch ? <span className="pid-rail-row-branch">{session.branch}</span> : null}
129163
</span>
130-
{live ? (
131-
<span
132-
className="pid-rail-row-live"
133-
role="status"
134-
aria-label="Session is running"
135-
title="Running"
136-
/>
137-
) : (
138-
<span className="pid-rail-row-meta">{relativeTime(session.lastActivityAt)}</span>
139-
)}
164+
<span className="pid-rail-row-meta">{relativeTime(session.lastActivityAt)}</span>
140165
</button>
141166
</ContextMenu>
142167
<ConfirmDialog

packages/ui/src/features/sessions/useSessionsStore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ export const useSessionsStore = create<SessionsStoreState>((set, get) => ({
404404
const targetProjectId = summary?.projectId ?? previousProjectId;
405405

406406
set({ activeSessionId: id });
407+
// Opening a session counts as viewing it: clear any unviewed done/failed outcome so its rail
408+
// dot settles to neutral idle instead of lingering green/red.
409+
useMessagesStore.getState().markViewed(id);
407410

408411
if (targetProjectId && targetProjectId !== previousProjectId) {
409412
projectsStore.setActive(targetProjectId);

packages/ui/src/lib/transport/event-router.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,8 @@ export function routeEvent(topic: string, rawPayload: unknown): void {
287287
return;
288288
}
289289
case EVENT_SESSION_WORKER_EXIT: {
290-
useMessagesStore.getState().markTurnInFlight(sessionId, false);
290+
// A worker that dies mid-turn is a failed turn (red rail dot); an idle shutdown is a no-op.
291+
useMessagesStore.getState().markTurnFailed(sessionId);
291292
// The worker is gone — drop its warm marker so a later hover/open re-spawns it.
292293
forgetWarmedSession(sessionId);
293294
return;
@@ -314,7 +315,8 @@ export function routeEvent(topic: string, rawPayload: unknown): void {
314315
const event = payload.event as { type?: string; message?: string } | undefined;
315316
if (event?.type === "prompt_error") {
316317
useNotificationStore.getState().error(event.message ?? "pi reported a prompt error");
317-
useMessagesStore.getState().markTurnInFlight(sessionId, false);
318+
// Turn-level error: clear the in-flight flag and record a failed outcome for the rail dot.
319+
useMessagesStore.getState().markTurnFailed(sessionId);
318320
}
319321
return;
320322
}

packages/ui/src/theme/components.css

Lines changed: 58 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1555,55 +1555,80 @@
15551555
color: var(--ink-0);
15561556
border-left-color: var(--accent);
15571557
}
1558-
/* Lifecycle marker — mirrors the ◯ / ◉ pair from the design mockup as CSS shapes so the
1559-
two states are guaranteed to share an outer size (the actual unicode fisheye renders
1560-
smaller than the open circle in most mono fonts). All dimensions are integer pixels and
1561-
centring uses flex, so the inner dot can't drift sub-pixel off-axis. */
1562-
.pid-rail-row-marker {
1563-
width: 12px;
1564-
height: 12px;
1558+
/* Status dot — a single left-aligned indicator encoding the session's live state by hue + motion */
1559+
.pid-rail-row-dot {
1560+
position: relative;
15651561
flex: 0 0 auto;
1566-
display: inline-flex;
1567-
align-items: center;
1568-
justify-content: center;
1569-
border: 1px solid var(--ink-3);
1570-
border-radius: 50%;
1562+
width: 8px;
1563+
height: 8px;
15711564
box-sizing: border-box;
1565+
border-radius: 50%;
1566+
/* idle default — neutral, slightly recessive. */
1567+
color: var(--ink-3);
1568+
background: currentColor;
1569+
}
1570+
/* Idle reads as an empty ring — a hollow neutral circle, visually distinct from the filled
1571+
live states. box-sizing: border-box keeps the 8px outer size so it never reflows the title. */
1572+
.pid-rail-row-dot[data-status="idle"] {
15721573
background: transparent;
1574+
border: 1.5px solid currentColor;
1575+
opacity: 0.8;
15731576
}
1574-
.pid-rail-row-marker[data-state="active"] {
1575-
border-color: var(--accent);
1577+
.pid-rail-row-dot[data-status="working"] {
1578+
color: var(--mod);
15761579
}
1577-
.pid-rail-row-marker[data-state="active"]::after {
1580+
.pid-rail-row-dot[data-status="waiting"] {
1581+
color: var(--info);
1582+
animation: pid-status-pulse 1.4s ease-in-out infinite;
1583+
}
1584+
.pid-rail-row-dot[data-status="done"] {
1585+
color: var(--add);
1586+
}
1587+
.pid-rail-row-dot[data-status="failed"] {
1588+
color: var(--del);
1589+
}
1590+
/* Ring / halo. Working animates an expanding ring; waiting/done/failed hold a steady halo so
1591+
they read as "settled but notable". Idle has none. */
1592+
.pid-rail-row-dot[data-status="working"]::after,
1593+
.pid-rail-row-dot[data-status="waiting"]::after,
1594+
.pid-rail-row-dot[data-status="done"]::after,
1595+
.pid-rail-row-dot[data-status="failed"]::after {
15781596
content: "";
1579-
width: 6px;
1580-
height: 6px;
1597+
position: absolute;
1598+
inset: -3px;
15811599
border-radius: 50%;
1582-
background: var(--accent);
1600+
border: 1.5px solid currentColor;
15831601
}
1584-
.pid-rail-row-live {
1585-
flex: 0 0 auto;
1586-
width: 7px;
1587-
height: 7px;
1588-
/* Pulls the dot inboard so it sits roughly where the centre of a 2-char timestamp
1589-
("5h") would land, instead of flush against the row's right padding. */
1590-
margin-right: 6px;
1591-
border-radius: 50%;
1592-
background: var(--add);
1593-
box-shadow: 0 0 0 0 var(--add);
1594-
animation: pid-rail-row-pulse 1.6s ease-out infinite;
1602+
.pid-rail-row-dot[data-status="working"]::after {
1603+
animation: pid-rail-row-ring 1.5s ease-out infinite;
1604+
}
1605+
.pid-rail-row-dot[data-status="waiting"]::after {
1606+
opacity: 0.4;
1607+
}
1608+
.pid-rail-row-dot[data-status="done"]::after {
1609+
opacity: 0.3;
1610+
}
1611+
.pid-rail-row-dot[data-status="failed"]::after {
1612+
opacity: 0.35;
15951613
}
1596-
@keyframes pid-rail-row-pulse {
1614+
@keyframes pid-rail-row-ring {
15971615
0% {
1598-
box-shadow: 0 0 0 0 oklch(0.78 0.16 145 / 0.55);
1616+
transform: scale(0.55);
1617+
opacity: 0.7;
15991618
}
16001619
100% {
1601-
box-shadow: 0 0 0 6px oklch(0.78 0.16 145 / 0);
1620+
transform: scale(1.9);
1621+
opacity: 0;
16021622
}
16031623
}
16041624
@media (prefers-reduced-motion: reduce) {
1605-
.pid-rail-row-live {
1625+
.pid-rail-row-dot[data-status="waiting"] {
1626+
animation: none;
1627+
}
1628+
/* Drop the expanding ring but keep a static one so working stays distinguishable. */
1629+
.pid-rail-row-dot[data-status="working"]::after {
16061630
animation: none;
1631+
opacity: 0.4;
16071632
}
16081633
}
16091634
.pid-rail-row-main {

0 commit comments

Comments
 (0)