Archived
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:
@@ -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 { 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.session { border-radius: 50%; background: var(--pi-success); }
|
||||||
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
|
.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-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 { 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); }
|
.machine-option.no-actions { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { SessionInfo } from "../api";
|
import type { SessionInfo, SessionStatus } from "../api";
|
||||||
import { sessionRowsForCurrentTree } from "./SessionList";
|
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", () => {
|
describe("sessionRowsForCurrentTree", () => {
|
||||||
it("keeps archived ancestors visible while they have unarchived descendants", () => {
|
it("keeps archived ancestors visible while they have unarchived descendants", () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
|||||||
import { isCachedNewSessionInfo } from "../cachedNewSessions";
|
import { isCachedNewSessionInfo } from "../cachedNewSessions";
|
||||||
import { isSessionActive } from "../../../shared/activity";
|
import { isSessionActive } from "../../../shared/activity";
|
||||||
import { actionMenuPanelStyle } from "./actionMenu";
|
import { actionMenuPanelStyle } from "./actionMenu";
|
||||||
import { renderActionActivityIndicator } from "./activityBadge";
|
import { renderActionActivityIndicator, type ActivityIndicatorKind } from "./activityBadge";
|
||||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||||
import { listStyles } from "./shared";
|
import { listStyles } from "./shared";
|
||||||
@@ -361,9 +361,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderActivity(session: SessionInfo) {
|
private renderActivity(session: SessionInfo) {
|
||||||
if (isCachedNewSessionInfo(session) || session.archived === true) return undefined;
|
const kind = sessionRowActivityKind(session, this.statuses[session.id], this.activities[session.id], this.sending[session.id] === true);
|
||||||
const active = this.sending[session.id] === true || isSessionActive(this.statuses[session.id], this.activities[session.id]);
|
return renderActionActivityIndicator(kind, kind === "sending" ? "Sending message" : "Session active");
|
||||||
return renderActionActivityIndicator(active ? "session" : undefined, "Session active");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static override styles = [listStyles, css`
|
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())]));
|
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[] {
|
export function sessionRowsForCurrentTree(sessions: SessionInfo[]): SessionRow[] {
|
||||||
const byPath = new Map(sessions.map((session) => [session.path, session]));
|
const byPath = new Map(sessions.map((session) => [session.path, session]));
|
||||||
const visible = new Set<string>();
|
const visible = new Set<string>();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { html, type TemplateResult } from "lit";
|
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 {
|
export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined {
|
||||||
if (kind === undefined) return undefined;
|
if (kind === undefined) return undefined;
|
||||||
|
|||||||
@@ -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 { 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.session { border-radius: 50%; background: var(--pi-success); }
|
||||||
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
|
.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 { 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 { 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); }
|
.action-menu-toggle:hover { color: var(--pi-text); background: var(--pi-surface-hover); }
|
||||||
|
|||||||
Reference in New Issue
Block a user