From fe6d8e4a357cffea0473568c0123c1455008e962 Mon Sep 17 00:00:00 2001 From: Codevil Date: Mon, 14 Sep 2026 08:21:05 +0000 Subject: [PATCH] Add collapsible active-sessions sidebar to the session page Add a left sidebar on /session/:id that lists all other active sessions (running or awaiting review), excluding the current one. The sidebar fetches the session directory on mount, refreshes every 30s and on tab focus/visibility, and persists its collapsed state in localStorage. Factor the home page's session-status derivation into a shared module so the sidebar and home page use one status vocabulary, with unit tests. --- .../components/session/session-sidebar.tsx | 206 ++++++++++++++++++ .../src/lib/__tests__/session-status.test.ts | 102 +++++++++ packages/web/src/lib/session-status.ts | 54 +++++ packages/web/src/routes/index.tsx | 33 +-- packages/web/src/routes/session.$id.tsx | 70 +++--- packages/web/src/session-components.css | 206 ++++++++++++++++++ 6 files changed, 606 insertions(+), 65 deletions(-) create mode 100644 packages/web/src/components/session/session-sidebar.tsx create mode 100644 packages/web/src/lib/__tests__/session-status.test.ts create mode 100644 packages/web/src/lib/session-status.ts diff --git a/packages/web/src/components/session/session-sidebar.tsx b/packages/web/src/components/session/session-sidebar.tsx new file mode 100644 index 0000000..e22b669 --- /dev/null +++ b/packages/web/src/components/session/session-sidebar.tsx @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { loadConfig } from "@/lib/config"; +import { listSessions } from "@/lib/api-client"; +import { + deriveStatus, + filterOtherActiveSessions, + STATUS_LABEL, +} from "@/lib/session-status"; +import type { SessionSummary } from "@/types"; + +const SIDEBAR_COLLAPSED_KEY = "codevil_session_sidebar_collapsed"; +const REFRESH_INTERVAL_MS = 30_000; + +function loadCollapsed(): boolean { + try { + return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1"; + } catch { + return false; + } +} + +function persistCollapsed(collapsed: boolean): void { + try { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0"); + } catch { + /* The sidebar still works; only persistence is lost. */ + } +} + +interface SessionSidebarProps { + sessionId: string; +} + +export function SessionSidebar({ sessionId }: SessionSidebarProps) { + const [collapsed, setCollapsed] = useState(loadCollapsed); + const [activeSessions, setActiveSessions] = useState([]); + const [loaded, setLoaded] = useState(false); + const fetchingRef = useRef(false); + + const refresh = useCallback(async () => { + if (fetchingRef.current) return; + const config = loadConfig(); + if (!config) return; + fetchingRef.current = true; + try { + const result = await listSessions(config); + setActiveSessions(filterOtherActiveSessions(result.sessions, sessionId)); + setLoaded(true); + } catch { + // Keep the last-known list; a transient failure should not clear the sidebar. + } finally { + fetchingRef.current = false; + } + }, [sessionId]); + + useEffect(() => { + // Refetch when switching sessions so "current" is excluded correctly. + setActiveSessions([]); + setLoaded(false); + void refresh(); + }, [refresh]); + + useEffect(() => { + const interval = window.setInterval(() => { + if (document.visibilityState === "visible") void refresh(); + }, REFRESH_INTERVAL_MS); + + function refreshOnVisible() { + if (document.visibilityState === "visible") void refresh(); + } + document.addEventListener("visibilitychange", refreshOnVisible); + window.addEventListener("focus", refreshOnVisible); + + return () => { + window.clearInterval(interval); + document.removeEventListener("visibilitychange", refreshOnVisible); + window.removeEventListener("focus", refreshOnVisible); + }; + }, [refresh]); + + function handleToggle() { + setCollapsed((current) => { + persistCollapsed(!current); + return !current; + }); + } + + const activeCount = activeSessions.length; + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +} + +function SessionSidebarItem({ session }: { session: SessionSummary }) { + const status = deriveStatus(session); + return ( +
  • + + + + {session.title} + + + + + + {session.repo} + + {formatRelativeTime(session.last_event_at)} + + + +
  • + ); +} + +function ChevronIcon({ direction }: { direction: "left" | "right" }) { + return ( + + ); +} + +function formatRelativeTime(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ""; + const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000)); + if (seconds < 45) return "now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} \ No newline at end of file diff --git a/packages/web/src/lib/__tests__/session-status.test.ts b/packages/web/src/lib/__tests__/session-status.test.ts new file mode 100644 index 0000000..5de13cb --- /dev/null +++ b/packages/web/src/lib/__tests__/session-status.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import type { SessionSummary } from "@/types"; +import { + deriveStatus, + filterOtherActiveSessions, + isActiveSession, +} from "@/lib/session-status"; + +function makeSession(overrides: Partial = {}): SessionSummary { + return { + id: "s1", + title: "Fix the badge", + repo: "acme/app", + room_state: "ready", + sandbox_state: "ready", + created_at: "2026-09-14T00:00:00.000Z", + updated_at: "2026-09-14T00:00:00.000Z", + last_event_at: "2026-09-14T00:00:00.000Z", + ...overrides, + }; +} + +describe("deriveStatus", () => { + it("reports running for queued/thinking/executing runs", () => { + for (const state of ["queued", "thinking", "executing"] as const) { + expect(deriveStatus(makeSession({ active_run_state: state }))).toBe("running"); + } + }); + + it("reports review for awaiting_approval/verifying/publishing runs", () => { + for (const state of ["awaiting_approval", "verifying", "publishing"] as const) { + expect(deriveStatus(makeSession({ active_run_state: state }))).toBe("review"); + } + }); + + it("reports done for completed runs", () => { + expect(deriveStatus(makeSession({ active_run_state: "completed" }))).toBe("done"); + }); + + it("reports failed for failed room, sandbox, or run state", () => { + expect(deriveStatus(makeSession({ room_state: "failed" }))).toBe("failed"); + expect(deriveStatus(makeSession({ sandbox_state: "failed" }))).toBe("failed"); + expect(deriveStatus(makeSession({ active_run_state: "failed" }))).toBe("failed"); + }); + + it("reports running while the sandbox is provisioning", () => { + expect(deriveStatus(makeSession({ sandbox_state: "provisioning" }))).toBe("running"); + }); + + it("reports idle once nothing is in flight", () => { + expect(deriveStatus(makeSession())).toBe("idle"); + }); +}); + +describe("isActiveSession", () => { + it("treats running and review sessions as active", () => { + expect(isActiveSession(makeSession({ active_run_state: "executing" }))).toBe(true); + expect(isActiveSession(makeSession({ active_run_state: "awaiting_approval" }))).toBe(true); + expect(isActiveSession(makeSession({ active_run_state: "verifying" }))).toBe(true); + }); + + it("excludes done, failed, archived, and idle sessions", () => { + expect(isActiveSession(makeSession({ active_run_state: "completed" }))).toBe(false); + expect(isActiveSession(makeSession({ active_run_state: "failed" }))).toBe(false); + expect(isActiveSession(makeSession({ room_state: "failed" }))).toBe(false); + expect(isActiveSession(makeSession({ room_state: "archived" }))).toBe(false); + expect(isActiveSession(makeSession())).toBe(false); + }); +}); + +describe("filterOtherActiveSessions", () => { + it("returns only active sessions besides the current one, newest first", () => { + const running = makeSession({ + id: "s-running", + active_run_state: "executing", + last_event_at: "2026-09-14T10:00:00.000Z", + }); + const review = makeSession({ + id: "s-review", + active_run_state: "awaiting_approval", + last_event_at: "2026-09-14T12:00:00.000Z", + }); + const current = makeSession({ + id: "s-current", + active_run_state: "executing", + last_event_at: "2026-09-14T11:00:00.000Z", + }); + const done = makeSession({ + id: "s-done", + active_run_state: "completed", + last_event_at: "2026-09-14T13:00:00.000Z", + }); + + const result = filterOtherActiveSessions([done, review, current, running], "s-current"); + expect(result.map((session) => session.id)).toEqual(["s-review", "s-running"]); + }); + + it("returns an empty list when there are no other active sessions", () => { + const done = makeSession({ id: "s-done", active_run_state: "completed" }); + expect(filterOtherActiveSessions([done], "s-done")).toEqual([]); + }); +}); \ No newline at end of file diff --git a/packages/web/src/lib/session-status.ts b/packages/web/src/lib/session-status.ts new file mode 100644 index 0000000..51b6052 --- /dev/null +++ b/packages/web/src/lib/session-status.ts @@ -0,0 +1,54 @@ +import type { SessionSummary } from "@/types"; + +export type SessionStatus = "running" | "review" | "done" | "failed" | "idle"; + +export const STATUS_LABEL: Record = { + running: "Running", + review: "Review", + done: "Done", + failed: "Failed", + idle: "Idle", +}; + +export function deriveStatus(session: SessionSummary): SessionStatus { + if (session.room_state === "failed" || session.sandbox_state === "failed") return "failed"; + switch (session.active_run_state) { + case "completed": + return "done"; + case "awaiting_approval": + case "verifying": + case "publishing": + return "review"; + case "queued": + case "thinking": + case "executing": + return "running"; + case "failed": + return "failed"; + default: + break; + } + if (["provisioning", "cloning", "not_started"].includes(session.sandbox_state)) return "running"; + return "idle"; +} + +/** + * A session is "active" while it still has work in flight or is waiting on a + * decision: the agent is running, or the run is awaiting review. Terminal + * sessions (done/failed/archived) are not active. + */ +export function isActiveSession(session: SessionSummary): boolean { + if (session.room_state === "archived" || session.room_state === "failed") return false; + const status = deriveStatus(session); + return status === "running" || status === "review"; +} + +/** Active sessions other than the one currently open, most recent first. */ +export function filterOtherActiveSessions( + sessions: SessionSummary[], + currentSessionId: string | null, +): SessionSummary[] { + return sessions + .filter((session) => session.id !== currentSessionId && isActiveSession(session)) + .sort((a, b) => b.last_event_at.localeCompare(a.last_event_at)); +} \ No newline at end of file diff --git a/packages/web/src/routes/index.tsx b/packages/web/src/routes/index.tsx index 46abd5c..f7451f7 100644 --- a/packages/web/src/routes/index.tsx +++ b/packages/web/src/routes/index.tsx @@ -18,6 +18,7 @@ import { assignParticipantAvatarColors, getParticipantColorKey, } from "@/lib/avatar-colors"; +import { deriveStatus, STATUS_LABEL } from "@/lib/session-status"; import type { CSSProperties } from "react"; export const Route = createFileRoute("/")({ @@ -60,38 +61,6 @@ function saveModelPrefs(prefs: ModelPrefs): void { localStorage.setItem(MODEL_PREFS_KEY, JSON.stringify(prefs)); } -type SessionStatus = "running" | "review" | "done" | "failed" | "idle"; - -const STATUS_LABEL: Record = { - running: "Running", - review: "Review", - done: "Done", - failed: "Failed", - idle: "Idle", -}; - -function deriveStatus(session: SessionSummary): SessionStatus { - if (session.room_state === "failed" || session.sandbox_state === "failed") return "failed"; - switch (session.active_run_state) { - case "completed": - return "done"; - case "awaiting_approval": - case "verifying": - case "publishing": - return "review"; - case "queued": - case "thinking": - case "executing": - return "running"; - case "failed": - return "failed"; - default: - break; - } - if (["provisioning", "cloning", "not_started"].includes(session.sandbox_state)) return "running"; - return "idle"; -} - const FILTERS = ["all", "running", "review", "done"] as const; type Filter = (typeof FILTERS)[number]; diff --git a/packages/web/src/routes/session.$id.tsx b/packages/web/src/routes/session.$id.tsx index 9349d5d..80370a0 100644 --- a/packages/web/src/routes/session.$id.tsx +++ b/packages/web/src/routes/session.$id.tsx @@ -13,6 +13,7 @@ import { SessionRail } from "@/components/session/session-rail"; import { Timeline } from "@/components/session/Timeline"; import { ChatInput } from "@/components/session/ChatInput"; import { WorkspacePane } from "@/components/session/workspace-pane"; +import { SessionSidebar } from "@/components/session/session-sidebar"; import { RoomHeader } from "@/components/session/room-header"; import { PlanReviewPanel } from "@/components/session/plan-review-panel"; import { openThreadsSorted } from "@/lib/annotation-predicates"; @@ -102,42 +103,45 @@ function SessionPage() { return (
    -
    -
    - - {planRevision && ( -
    -
    - - Plan ready · Round {planRevision.round + 1} - {openCount > 0 && ( - <> · {openCount} {openCount === 1 ? "comment" : "comments"} +
    + +
    +
    + + {planRevision && ( +
    +
    + + Plan ready · Round {planRevision.round + 1} + {openCount > 0 && ( + <> · {openCount} {openCount === 1 ? "comment" : "comments"} + )} + + {planRevision.locked && ( + Locked )} - - {planRevision.locked && ( - Locked - )} +
    +
    - -
    - )} - + +
    + - - - +
    {/* Full-screen slide-out panel — PlanRevisionView lives here ONLY */} diff --git a/packages/web/src/session-components.css b/packages/web/src/session-components.css index 3ec0b1d..37269ae 100644 --- a/packages/web/src/session-components.css +++ b/packages/web/src/session-components.css @@ -4622,3 +4622,209 @@ mark.annotation-highlight { color: var(--fg-3); font-style: italic; } + +/* ─── Active sessions sidebar ─────────────────────────────────────────────── */ +.session-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + overflow: hidden; +} + +.session-body .session-workbench { + min-width: 0; +} + +.session-sidebar { + width: 248px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--surface-2); + border-right: 1px solid var(--line); + overflow: hidden; + transition: width var(--t-base) var(--ease-out); +} + +.session-sidebar--collapsed { + width: 40px; +} + +.session-sidebar-head { + height: 44px; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 0 8px 0 16px; + border-bottom: 1px solid var(--line-2); +} + +.session-sidebar-title { + font-family: var(--sans); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--fg-2); + white-space: nowrap; +} + +.session-sidebar-count { + min-width: 18px; + height: 18px; + padding: 0 5px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-family: var(--mono); + font-size: 10.5px; + font-weight: 600; +} + +.session-sidebar-toggle { + margin-left: auto; + width: 26px; + height: 26px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--r-md); + background: transparent; + border: 0; + color: var(--fg-4); + cursor: pointer; + transition: background var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out); +} + +.session-sidebar-toggle:hover { + background: var(--surface-3); + color: var(--fg-2); +} + +.session-sidebar--collapsed .session-sidebar-toggle { + margin: 10px auto 0; + color: var(--fg-3); +} + +.session-sidebar-nav { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 8px; +} + +.session-sidebar-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.session-sidebar-item { + display: flex; + flex-direction: column; + gap: 5px; + padding: 9px 10px; + border: 1px solid transparent; + border-radius: var(--r-md); + background: transparent; + color: var(--fg); + text-decoration: none; + transition: background var(--t-fast) var(--ease-out), border-color var(--t-fast) var(--ease-out); +} + +.session-sidebar-item:hover { + background: var(--surface); + border-color: var(--line); +} + +.session-sidebar-item-head { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.session-sidebar-item-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12.5px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--fg); +} + +.session-sidebar-item-sub { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.session-sidebar-item-repo { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--mono); + font-size: 10.5px; + color: var(--fg-3); +} + +.session-sidebar-item-time { + flex-shrink: 0; + font-size: 10.5px; + color: var(--fg-4); + white-space: nowrap; +} + +.session-sidebar-item .home-status-pill { + flex-shrink: 0; + height: 20px; + padding: 0 8px; + gap: 5px; + font-size: 10.5px; +} + +.session-sidebar-empty { + padding: 4px 6px; + font-size: 12px; + line-height: 1.45; + color: var(--fg-4); +} + +.session-sidebar-count-badge { + margin: 12px auto 0; + min-width: 20px; + height: 20px; + padding: 0 6px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-family: var(--mono); + font-size: 11px; + font-weight: 600; +} + +@media (max-width: 900px) { + .session-sidebar { + width: 200px; + } + .session-sidebar--collapsed { + width: 40px; + } +}