Skip to content
Open
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
206 changes: 206 additions & 0 deletions packages/web/src/components/session/session-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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<SessionSummary[]>([]);
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 (
<aside className="session-sidebar session-sidebar--collapsed" aria-label="Active sessions">
<button
type="button"
className="session-sidebar-toggle"
onClick={handleToggle}
aria-expanded={false}
aria-label="Expand active sessions sidebar"
title="Expand active sessions sidebar"
>
<ChevronIcon direction="right" />
</button>
{activeCount > 0 && (
<span className="session-sidebar-count-badge" title={`${activeCount} active ${activeCount === 1 ? "session" : "sessions"}`}>
{activeCount}
</span>
)}
</aside>
);
}

return (
<aside className="session-sidebar" aria-label="Active sessions">
<div className="session-sidebar-head">
<span className="session-sidebar-title">Active sessions</span>
<span className="session-sidebar-count">{activeCount}</span>
<button
type="button"
className="session-sidebar-toggle"
onClick={handleToggle}
aria-expanded={true}
aria-label="Collapse active sessions sidebar"
title="Collapse active sessions sidebar"
>
<ChevronIcon direction="left" />
</button>
</div>

<nav className="session-sidebar-nav" aria-label="Other active sessions">
{loaded && activeCount === 0 ? (
<div className="session-sidebar-empty">
No other active sessions.
</div>
) : (
<ul className="session-sidebar-list">
{activeSessions.map((session) => (
<SessionSidebarItem key={session.id} session={session} />
))}
</ul>
)}
</nav>
</aside>
);
}

function SessionSidebarItem({ session }: { session: SessionSummary }) {
const status = deriveStatus(session);
return (
<li>
<Link
to="/session/$id"
params={{ id: session.id }}
className="session-sidebar-item"
>
<span className="session-sidebar-item-head">
<span className="session-sidebar-item-title" title={session.title}>
{session.title}
</span>
<span className={`home-status-pill ${status}`}>
<span className="home-status-dot" aria-hidden="true" />
{STATUS_LABEL[status]}
</span>
</span>
<span className="session-sidebar-item-sub">
<span className="session-sidebar-item-repo">{session.repo}</span>
<span className="session-sidebar-item-time">
{formatRelativeTime(session.last_event_at)}
</span>
</span>
</Link>
</li>
);
}

function ChevronIcon({ direction }: { direction: "left" | "right" }) {
return (
<svg
viewBox="0 0 16 16"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
style={{ transform: direction === "left" ? "rotate(180deg)" : undefined }}
>
<path d="M6 3.5 10.5 8 6 12.5" />
</svg>
);
}

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`;
}
102 changes: 102 additions & 0 deletions packages/web/src/lib/__tests__/session-status.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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([]);
});
});
54 changes: 54 additions & 0 deletions packages/web/src/lib/session-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { SessionSummary } from "@/types";

export type SessionStatus = "running" | "review" | "done" | "failed" | "idle";

export const STATUS_LABEL: Record<SessionStatus, string> = {
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));
}
Loading
Loading