feat: give the sending indicator a distinct color in session lists

Add a dedicated "sending" activity indicator kind (warning color) so a
session uploading attachments is visually distinct from server activity.
Server activity propagates up to workspace/machine rows; client-side
sending does not, and the different color signals that to users.

Also extract sessionRowActivityKind as a pure, exported helper so the
"sending takes precedence, archived/cached-new never show" logic is unit
tested without rendering.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-13 21:20:46 +02:00
parent 886393a31e
commit a369b1e550
5 changed files with 51 additions and 7 deletions
@@ -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); }
+25 -2
View File
@@ -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", () => {
+22 -4
View File
@@ -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<string, number
return new Map(sessions.map((session) => [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<string>();
+1 -1
View File
@@ -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;
+2
View File
@@ -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); }