diff --git a/src/client/src/components/MachineSwitcher.ts b/src/client/src/components/MachineSwitcher.ts index f983409..bc6aef4 100644 --- a/src/client/src/components/MachineSwitcher.ts +++ b/src/client/src/components/MachineSwitcher.ts @@ -282,6 +282,7 @@ export class MachineSwitcher extends LitElement implements KeyboardNavigableSect .activity-indicator { flex: 0 0 auto; display: inline-block; width: 7px; height: 7px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; } .activity-indicator.session { border-radius: 50%; background: var(--pi-success); } .activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); } + .activity-indicator.sending { border-radius: 50%; background: var(--pi-warning); } .machine-switcher-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(280px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); } .machine-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px; align-items: stretch; margin: 2px 0; } .machine-option.no-actions { grid-template-columns: minmax(0, 1fr); } diff --git a/src/client/src/components/SessionList.test.ts b/src/client/src/components/SessionList.test.ts index b301176..b16ac5d 100644 --- a/src/client/src/components/SessionList.test.ts +++ b/src/client/src/components/SessionList.test.ts @@ -1,6 +1,29 @@ import { describe, expect, it } from "vitest"; -import type { SessionInfo } from "../api"; -import { sessionRowsForCurrentTree } from "./SessionList"; +import type { SessionInfo, SessionStatus } from "../api"; +import { markCachedNewSessionInfo } from "../cachedNewSessions"; +import { sessionRowActivityKind, sessionRowsForCurrentTree } from "./SessionList"; + +describe("sessionRowActivityKind", () => { + const idle: SessionStatus = { sessionId: "s", isStreaming: false, isCompacting: false, isBashRunning: false, pendingMessageCount: 0, queuedMessages: [], tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }; + + it("reports 'sending' for an uploading session, taking precedence over server activity", () => { + expect(sessionRowActivityKind(session("s"), idle, undefined, true)).toBe("sending"); + expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, true)).toBe("sending"); + }); + + it("reports 'session' for server activity when not sending", () => { + expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false)).toBe("session"); + }); + + it("reports undefined when idle and not sending", () => { + expect(sessionRowActivityKind(session("s"), idle, undefined, false)).toBeUndefined(); + }); + + it("never shows an indicator for archived or cached-new sessions, even while sending", () => { + expect(sessionRowActivityKind({ ...session("s"), archived: true }, idle, undefined, true)).toBeUndefined(); + expect(sessionRowActivityKind(markCachedNewSessionInfo(session("s")), idle, undefined, true)).toBeUndefined(); + }); +}); describe("sessionRowsForCurrentTree", () => { it("keeps archived ancestors visible while they have unarchived descendants", () => { diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index d7dbc49..17fd55d 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -4,7 +4,7 @@ import type { SessionActivity, SessionInfo, SessionStatus } from "../api"; import { isCachedNewSessionInfo } from "../cachedNewSessions"; import { isSessionActive } from "../../../shared/activity"; import { actionMenuPanelStyle } from "./actionMenu"; -import { renderActionActivityIndicator } from "./activityBadge"; +import { renderActionActivityIndicator, type ActivityIndicatorKind } from "./activityBadge"; import type { KeyboardNavigableSection } from "./navigationFocus"; import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @@ -361,9 +361,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection } private renderActivity(session: SessionInfo) { - if (isCachedNewSessionInfo(session) || session.archived === true) return undefined; - const active = this.sending[session.id] === true || isSessionActive(this.statuses[session.id], this.activities[session.id]); - return renderActionActivityIndicator(active ? "session" : undefined, "Session active"); + const kind = sessionRowActivityKind(session, this.statuses[session.id], this.activities[session.id], this.sending[session.id] === true); + return renderActionActivityIndicator(kind, kind === "sending" ? "Sending message" : "Session active"); } static override styles = [listStyles, css` @@ -417,6 +416,25 @@ function unarchivedDescendantCounts(sessions: SessionInfo[]): Map [session.id, countFor(session, new Set())])); } +/** + * Resolve the activity indicator kind for a session row, or undefined when the + * row should show no indicator. Pure so it can be unit-tested without rendering. + * + * "sending" (client-side upload in flight) is reported with its own kind, and + * takes precedence over server activity, so it can be colored distinctly to + * signal that it is not yet propagated to workspace/machine activity. + */ +export function sessionRowActivityKind( + session: SessionInfo, + status: SessionStatus | undefined, + activity: SessionActivity | undefined, + sending: boolean, +): ActivityIndicatorKind | undefined { + if (isCachedNewSessionInfo(session) || session.archived === true) return undefined; + if (sending) return "sending"; + return isSessionActive(status, activity) ? "session" : undefined; +} + export function sessionRowsForCurrentTree(sessions: SessionInfo[]): SessionRow[] { const byPath = new Map(sessions.map((session) => [session.path, session])); const visible = new Set(); diff --git a/src/client/src/components/activityBadge.ts b/src/client/src/components/activityBadge.ts index a674f49..ea1fa23 100644 --- a/src/client/src/components/activityBadge.ts +++ b/src/client/src/components/activityBadge.ts @@ -1,6 +1,6 @@ import { html, type TemplateResult } from "lit"; -export type ActivityIndicatorKind = "session" | "terminal"; +export type ActivityIndicatorKind = "session" | "terminal" | "sending"; export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined { if (kind === undefined) return undefined; diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index aa7f8ba..e540958 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -249,6 +249,8 @@ export const listStyles = css` .activity-indicator { display: inline-block; width: 7px; height: 7px; margin-right: 6px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; vertical-align: 1px; } .activity-indicator.session { border-radius: 50%; background: var(--pi-success); } .activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); } + /* Client-side sending (upload in flight); distinct from server activity, which propagates to workspace/machine rows. */ + .activity-indicator.sending { border-radius: 50%; background: var(--pi-warning); } .action-menu { position: relative; align-self: stretch; } .action-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; } .action-menu-toggle:hover { color: var(--pi-text); background: var(--pi-surface-hover); }