From ba9123fc46dcd8b71f34880669761dbaedd13e8d Mon Sep 17 00:00:00 2001 From: keliangliang Date: Fri, 16 Oct 2026 01:18:22 +0800 Subject: [PATCH] feat: show active Pi sessions in project rail --- docs/project-session-activity/change.md | 26 ++++++++ src/features/chat/ChatView.test.tsx | 45 +++++-------- src/features/chat/ChatView.tsx | 15 ++--- src/features/chat/chatRuntime.test.ts | 42 ++++++++++++ src/features/chat/chatRuntime.ts | 74 ++++++++++++++++++++++ src/features/projects/ProjectRail.test.tsx | 34 ++++++++++ src/features/projects/ProjectRail.tsx | 20 ++++++ src/features/projects/WorktreeDialog.tsx | 2 + src/i18n/en.ts | 1 + src/i18n/zh-CN.ts | 1 + src/styles/index.css | 11 +++- 11 files changed, 228 insertions(+), 43 deletions(-) create mode 100644 docs/project-session-activity/change.md diff --git a/docs/project-session-activity/change.md b/docs/project-session-activity/change.md new file mode 100644 index 0000000..b0cebcf --- /dev/null +++ b/docs/project-session-activity/change.md @@ -0,0 +1,26 @@ +# Intent + +Users need to see when Pi is actively working without keeping a Chat tab selected. The Project rail should show a compact loading indicator on a Worktree with an active Pi Chat session and on its owning Project. Directory Projects, which hide their synthetic workspace row, should show the aggregate indicator on the Project row. + +# Spec + +- A Pi session is active when its Renderer Chat runtime status is `streaming`, from `agent_start` until `agent_settled`, failure, deletion, or removal. +- Chat conversation loading, idle sessions, failed/auth-required sessions, terminals, filesystem loading, and managed Worktree creation do not count as active Pi sessions. +- A Worktree row displays an accessible loading indicator when at least one of its sessions is active. +- A Project row displays the indicator when any of its Worktrees has an active session. +- A directory Project displays the Project indicator for its synthetic primary workspace; it does not expose a Worktree row. +- Multiple active sessions aggregate as a boolean; the UI does not show a count. +- The indicator updates from the existing Chat runtime event stream. No polling, new IPC command, durable state, or arbitrary path access is added. +- Activity remains visible if the user switches away from the running Chat tab because the runtime retains session state. +- Removing a session removes its contribution to activity. + +# Plan + +1. Extend `ChatRuntime` with a global activity subscription and a hook that derives active Worktree IDs from runtime states. +2. Add red-green tests for streaming transitions, multiple sessions, settled/error/removal behavior, and global notification batching. +3. Consume the activity selector in `ProjectRail`, aggregate Worktree activity to Project activity, and render accessible compact spinners on Project and Worktree rows. +4. Add ProjectRail tests for Git Project aggregation, inactive rows, and directory Project aggregation. +5. Add localized accessible labels and minimal CSS that does not displace existing Worktree/menu actions. +6. Run focused tests and `pnpm check`. + +Risk: standard. This changes visible Renderer behavior and shared in-memory Chat runtime APIs, but does not change IPC, persistence, filesystem access, authentication, or Main security boundaries. diff --git a/src/features/chat/ChatView.test.tsx b/src/features/chat/ChatView.test.tsx index f4cd5de..5efaa86 100644 --- a/src/features/chat/ChatView.test.tsx +++ b/src/features/chat/ChatView.test.tsx @@ -371,34 +371,20 @@ describe("ChatView", () => { ); }); - it("cleans up the superseded attachment under StrictMode", async () => { - const firstDetach = vi.fn(); - const secondDetach = vi.fn(); + it("reuses the runtime attachment under StrictMode and keeps it after unmount", async () => { + const detach = vi.fn(); const chatApi = api({ - attach: vi - .fn() - .mockResolvedValueOnce({ - snapshot: { - sessionId: "session-1", - worktreeId: "worktree-1", - status: "idle", - items: [], - queue: [], - sequence: 1, - }, - detach: firstDetach, - }) - .mockResolvedValueOnce({ - snapshot: { - sessionId: "session-1", - worktreeId: "worktree-1", - status: "idle", - items: [], - queue: [], - sequence: 1, - }, - detach: secondDetach, - }), + attach: vi.fn().mockResolvedValue({ + snapshot: { + sessionId: "session-1", + worktreeId: "worktree-1", + status: "idle", + items: [], + queue: [], + sequence: 1, + }, + detach, + }), }); const onError = vi.fn(); const { unmount } = render( @@ -413,13 +399,12 @@ describe("ChatView", () => { , ); - await waitFor(() => expect(chatApi.attach).toHaveBeenCalledTimes(2)); - await waitFor(() => expect(firstDetach).toHaveBeenCalledOnce()); + await waitFor(() => expect(chatApi.attach).toHaveBeenCalledOnce()); await waitFor(() => expect(chatApi.config).toHaveBeenCalledOnce()); expect(onError).not.toHaveBeenCalled(); unmount(); - expect(secondDetach).toHaveBeenCalledOnce(); + expect(detach).not.toHaveBeenCalled(); }); it("loads session config and wires model and thinking selectors", async () => { diff --git a/src/features/chat/ChatView.tsx b/src/features/chat/ChatView.tsx index aca8833..9ae76f9 100644 --- a/src/features/chat/ChatView.tsx +++ b/src/features/chat/ChatView.tsx @@ -55,20 +55,14 @@ function ChatViewContent({ useEffect(() => { let active = true; - let detach: (() => void) | undefined; if (runtime.getSnapshot(sessionId).status === "loading") runtime.setStatus(sessionId, "loading"); setConfig(undefined); setConfigError(undefined); - void api - .attach(worktreeId, sessionId, (event) => runtime.push(event)) - .then(async (attachment) => { - if (!active) { - attachment.detach(); - return; - } - detach = attachment.detach; - runtime.hydrate(attachment.snapshot); + void runtime + .track(api, worktreeId, sessionId) + .then(async () => { + if (!active) return; try { const next = await api.config(worktreeId, sessionId); if (active) setConfig(next); @@ -92,7 +86,6 @@ function ChatViewContent({ }); return () => { active = false; - detach?.(); }; }, [api, onError, retryToken, runtime, sessionId, worktreeId]); diff --git a/src/features/chat/chatRuntime.test.ts b/src/features/chat/chatRuntime.test.ts index 1536e2c..d022310 100644 --- a/src/features/chat/chatRuntime.test.ts +++ b/src/features/chat/chatRuntime.test.ts @@ -31,6 +31,48 @@ describe("ChatRuntime", () => { expect(runtime.getSnapshot("session-1").status).toBe("streaming"); }); + it("aggregates streaming sessions by worktree and notifies only when activity changes", () => { + const runtime = new ChatRuntime({ batchMs: 0 }); + runtime.ensure("session-1", "worktree-1", "idle"); + runtime.ensure("session-2", "worktree-1", "idle"); + runtime.ensure("session-3", "worktree-2", "idle"); + const listener = vi.fn(); + runtime.subscribeActiveWorktrees(listener); + + runtime.setStatus("session-1", "streaming"); + expect(runtime.getActiveWorktreeIds()).toEqual(new Set(["worktree-1"])); + expect(listener).toHaveBeenCalledTimes(1); + + runtime.setStatus("session-2", "streaming"); + runtime.push({ + sessionId: "session-1", + sequence: 1, + event: { + type: "message_update", + message: { + id: "m1", + role: "assistant", + content: "working", + status: "streaming", + }, + }, + }); + expect(listener).toHaveBeenCalledTimes(1); + + runtime.setStatus("session-1", "idle"); + expect(runtime.getActiveWorktreeIds()).toEqual(new Set(["worktree-1"])); + expect(listener).toHaveBeenCalledTimes(1); + + runtime.setStatus("session-2", "idle"); + expect(runtime.getActiveWorktreeIds()).toEqual(new Set()); + expect(listener).toHaveBeenCalledTimes(2); + + runtime.setStatus("session-3", "streaming"); + runtime.remove("session-3"); + expect(runtime.getActiveWorktreeIds()).toEqual(new Set()); + expect(listener).toHaveBeenCalledTimes(4); + }); + it("routes sessions independently and batches notifications", () => { vi.useFakeTimers(); const runtime = new ChatRuntime({ batchMs: 20 }); diff --git a/src/features/chat/chatRuntime.ts b/src/features/chat/chatRuntime.ts index 65bab52..1de03ba 100644 --- a/src/features/chat/chatRuntime.ts +++ b/src/features/chat/chatRuntime.ts @@ -1,4 +1,5 @@ import { useSyncExternalStore } from "react"; +import type { ChatApi } from "./chatApi"; import { createInitialChatState, sessionReducer } from "./sessionReducer"; import type { ChatEventEnvelope, @@ -18,6 +19,12 @@ export class ChatRuntime { private readonly states = new Map(); private readonly listeners = new Map>(); private readonly dirtySessions = new Set(); + private readonly activeListeners = new Set(); + private readonly connections = new Map< + string, + { promise: Promise; detach?: () => void } + >(); + private activeWorktreeIds: ReadonlySet = new Set(); private flushTimer: ReturnType | null = null; private readonly batchMs: number; private readonly maxItems: number; @@ -36,6 +43,7 @@ export class ChatRuntime { if (existing) return existing; const created = createInitialChatState(sessionId, worktreeId, status); this.states.set(sessionId, created); + this.updateActiveWorktrees(); return created; } @@ -70,9 +78,39 @@ export class ChatRuntime { activity: snapshot.activity ?? null, error: snapshot.error ?? null, }); + this.updateActiveWorktrees(); this.markDirty(snapshot.sessionId); } + track(api: ChatApi, worktreeId: string, sessionId: string): Promise { + this.ensure(sessionId, worktreeId); + const existing = this.connections.get(sessionId); + if (existing) return existing.promise; + const connection: { promise: Promise; detach?: () => void } = { + promise: Promise.resolve(), + }; + connection.promise = api + .attach(worktreeId, sessionId, (event) => this.push(event)) + .then((attachment) => { + connection.detach = attachment.detach; + this.hydrate(attachment.snapshot); + }) + .catch((error: unknown) => { + if (this.connections.get(sessionId) === connection) + this.connections.delete(sessionId); + throw error; + }); + this.connections.set(sessionId, connection); + return connection.promise; + } + + getActiveWorktreeIds = (): ReadonlySet => this.activeWorktreeIds; + + subscribeActiveWorktrees = (listener: Listener): (() => void) => { + this.activeListeners.add(listener); + return () => this.activeListeners.delete(listener); + }; + push(envelope: ChatEventEnvelope): void { const current = this.states.get(envelope.sessionId); if (!current) return; @@ -86,6 +124,7 @@ export class ChatRuntime { ? { ...reduced, items: reduced.items.slice(-this.maxItems) } : reduced; this.states.set(envelope.sessionId, bounded); + this.updateActiveWorktrees(); this.markDirty(envelope.sessionId); } @@ -93,13 +132,38 @@ export class ChatRuntime { const current = this.states.get(sessionId); if (!current || current.status === status) return; this.states.set(sessionId, { ...current, status }); + this.updateActiveWorktrees(); this.markDirty(sessionId); } remove(sessionId: string): void { + this.connections.get(sessionId)?.detach?.(); + this.connections.delete(sessionId); this.states.delete(sessionId); this.listeners.delete(sessionId); this.dirtySessions.delete(sessionId); + this.updateActiveWorktrees(); + } + + removeWorktree(worktreeId: string): void { + for (const state of [...this.states.values()]) { + if (state.worktreeId === worktreeId) this.remove(state.sessionId); + } + } + + private updateActiveWorktrees(): void { + const next = new Set( + [...this.states.values()] + .filter(({ status }) => status === "streaming") + .map(({ worktreeId }) => worktreeId), + ); + if ( + next.size === this.activeWorktreeIds.size && + [...next].every((id) => this.activeWorktreeIds.has(id)) + ) + return; + this.activeWorktreeIds = next; + for (const listener of this.activeListeners) listener(); } private markDirty(sessionId: string): void { @@ -126,6 +190,16 @@ export class ChatRuntime { export const chatRuntime = new ChatRuntime(); +export function useActiveChatWorktrees( + runtime: ChatRuntime = chatRuntime, +): ReadonlySet { + return useSyncExternalStore( + runtime.subscribeActiveWorktrees, + runtime.getActiveWorktreeIds, + runtime.getActiveWorktreeIds, + ); +} + export function useChatSession( sessionId: string, runtime: ChatRuntime = chatRuntime, diff --git a/src/features/projects/ProjectRail.test.tsx b/src/features/projects/ProjectRail.test.tsx index 97ad84c..eede9b8 100644 --- a/src/features/projects/ProjectRail.test.tsx +++ b/src/features/projects/ProjectRail.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { chatRuntime } from "../chat/chatRuntime"; import { ProjectRail } from "./ProjectRail"; import { projectsApi } from "./projectsApi"; import { useProjectsStore } from "./projectsStore"; @@ -20,6 +21,9 @@ vi.mock("./projectsApi", () => ({ beforeEach(() => { vi.clearAllMocks(); + chatRuntime.remove("active-main"); + chatRuntime.remove("active-feature"); + chatRuntime.remove("active-directory"); useProjectsStore.setState({ projects: [ { @@ -77,6 +81,34 @@ describe("ProjectRail", () => { expect(screen.getByTestId("spirecode-mark")).toBeInTheDocument(); }); + it("shows active Pi session loading on the owning project and worktree only", async () => { + chatRuntime.ensure("active-feature", "feature", "streaming"); + render(); + + expect( + screen.getByRole("button", { name: "Expand spirecode-client" }), + ).toContainElement(screen.getByLabelText("Pi session active")); + + fireEvent.click( + screen.getByRole("button", { name: "Expand spirecode-client" }), + ); + expect(screen.getByRole("button", { name: "feature" })).toContainElement( + screen.getAllByLabelText("Pi session active")[1], + ); + expect( + screen + .getByRole("button", { name: "main" }) + .querySelector(".project-session-spinner"), + ).toBeNull(); + + chatRuntime.setStatus("active-feature", "idle"); + await vi.waitFor(() => + expect( + screen.queryByLabelText("Pi session active"), + ).not.toBeInTheDocument(), + ); + }); + it("collapses projects by default and toggles their worktrees", () => { render(); const toggle = screen.getByRole("button", { @@ -140,8 +172,10 @@ describe("ProjectRail", () => { activeWorktreeId: null, }); + chatRuntime.ensure("active-directory", "directory-main", "streaming"); render(); + expect(screen.getByLabelText("Pi session active")).toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Expand notes" }), ).not.toBeInTheDocument(); diff --git a/src/features/projects/ProjectRail.tsx b/src/features/projects/ProjectRail.tsx index 25e219e..7d1841a 100644 --- a/src/features/projects/ProjectRail.tsx +++ b/src/features/projects/ProjectRail.tsx @@ -9,6 +9,7 @@ import { RiMore2Fill, } from "@remixicon/react"; import type { WorktreeSummary } from "../../bindings"; +import { chatRuntime, useActiveChatWorktrees } from "../chat/chatRuntime"; import { SpireCodeMark } from "../../components/SpireCodeMark"; import { useTranslation } from "../../i18n"; import { commandError } from "../../lib/errors"; @@ -29,6 +30,7 @@ type DialogState = export function ProjectRail() { const { t } = useTranslation(); const store = useProjectsStore(); + const activeChatWorktrees = useActiveChatWorktrees(); const [dialog, setDialog] = useState(null); const [menuWorktreeId, setMenuWorktreeId] = useState(null); const [menuProjectId, setMenuProjectId] = useState(null); @@ -109,6 +111,9 @@ export function ProjectRail() { setProjectActionPending(projectId); try { await projectsApi.close(projectId); + const project = store.projects.find(({ id }) => id === projectId); + for (const worktree of project?.worktrees ?? []) + chatRuntime.removeWorktree(worktree.id); store.removeProject(projectId); setMenuProjectId(null); } catch (error) { @@ -147,6 +152,9 @@ export function ProjectRail() { const primaryWorkspace = project.worktrees.find(({ kind }) => kind === "main") ?? project.worktrees[0]; + const projectSessionActive = project.worktrees.some(({ id }) => + activeChatWorktrees.has(id), + ); return (
@@ -182,6 +190,12 @@ export function ProjectRail() { )} {project.name} + {projectSessionActive && ( + + )} {!directory && ( {managed && (