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
26 changes: 26 additions & 0 deletions docs/project-session-activity/change.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 15 additions & 30 deletions src/features/chat/ChatView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -413,13 +399,12 @@ describe("ChatView", () => {
</StrictMode>,
);

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 () => {
Expand Down
15 changes: 4 additions & 11 deletions src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -92,7 +86,6 @@ function ChatViewContent({
});
return () => {
active = false;
detach?.();
};
}, [api, onError, retryToken, runtime, sessionId, worktreeId]);

Expand Down
42 changes: 42 additions & 0 deletions src/features/chat/chatRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
74 changes: 74 additions & 0 deletions src/features/chat/chatRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useSyncExternalStore } from "react";
import type { ChatApi } from "./chatApi";
import { createInitialChatState, sessionReducer } from "./sessionReducer";
import type {
ChatEventEnvelope,
Expand All @@ -18,6 +19,12 @@ export class ChatRuntime {
private readonly states = new Map<string, ChatSessionState>();
private readonly listeners = new Map<string, Set<Listener>>();
private readonly dirtySessions = new Set<string>();
private readonly activeListeners = new Set<Listener>();
private readonly connections = new Map<
string,
{ promise: Promise<void>; detach?: () => void }
>();
private activeWorktreeIds: ReadonlySet<string> = new Set();
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private readonly batchMs: number;
private readonly maxItems: number;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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<void> {
this.ensure(sessionId, worktreeId);
const existing = this.connections.get(sessionId);
if (existing) return existing.promise;
const connection: { promise: Promise<void>; 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<string> => 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;
Expand All @@ -86,20 +124,46 @@ export class ChatRuntime {
? { ...reduced, items: reduced.items.slice(-this.maxItems) }
: reduced;
this.states.set(envelope.sessionId, bounded);
this.updateActiveWorktrees();
this.markDirty(envelope.sessionId);
}

setStatus(sessionId: string, status: ChatRunStatus): void {
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 {
Expand All @@ -126,6 +190,16 @@ export class ChatRuntime {

export const chatRuntime = new ChatRuntime();

export function useActiveChatWorktrees(
runtime: ChatRuntime = chatRuntime,
): ReadonlySet<string> {
return useSyncExternalStore(
runtime.subscribeActiveWorktrees,
runtime.getActiveWorktreeIds,
runtime.getActiveWorktreeIds,
);
}

export function useChatSession(
sessionId: string,
runtime: ChatRuntime = chatRuntime,
Expand Down
34 changes: 34 additions & 0 deletions src/features/projects/ProjectRail.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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: [
{
Expand Down Expand Up @@ -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(<ProjectRail />);

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(<ProjectRail />);
const toggle = screen.getByRole("button", {
Expand Down Expand Up @@ -140,8 +172,10 @@ describe("ProjectRail", () => {
activeWorktreeId: null,
});

chatRuntime.ensure("active-directory", "directory-main", "streaming");
render(<ProjectRail />);

expect(screen.getByLabelText("Pi session active")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Expand notes" }),
).not.toBeInTheDocument();
Expand Down
Loading
Loading