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
48 changes: 48 additions & 0 deletions site/ui/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -646,3 +646,51 @@
font-size: 12px;
}



.fleet-session-id-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid var(--border-color, rgba(255,255,255,0.12));
background: color-mix(in srgb, var(--surface-elevated, rgba(255,255,255,0.06)) 88%, transparent);
color: var(--text-secondary);
cursor: pointer;
transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease, transform 160ms ease;
}

.fleet-session-id-pill:hover,
.fleet-session-id-pill:focus-visible {
border-color: var(--accent-color, #7c3aed);
color: var(--text-primary);
outline: none;
}

.fleet-session-id-pill:active {
transform: translateY(1px);
}

.fleet-session-id-pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1em;
}

.fleet-session-id-pill[data-copied="true"] {
color: var(--color-done);
border-color: color-mix(in srgb, var(--color-done) 70%, transparent);
background: color-mix(in srgb, var(--color-done) 14%, transparent);
animation: fleet-session-pill-copied 1.5s ease;
}

@keyframes fleet-session-pill-copied {
0%, 100% {
transform: translateY(0);
}
20% {
transform: translateY(-1px);
}
}
42 changes: 42 additions & 0 deletions site/ui/tabs/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,18 @@ export function AgentsTab() {
}, [slots, fleetSearch]);

const allSessions = sessionsData.value || [];

const copySessionId = (sessionId) => {
if (!sessionId) return;
setCopiedSessionId(sessionId);
navigator.clipboard
.writeText(sessionId)
.then(() => showToast("Session ID copied", "success"))
.catch(() => {
setCopiedSessionId("");
showToast("Copy failed", "error");
});
};
Comment on lines +1319 to +1329
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copySessionId references setCopiedSessionId, but AgentsTab does not define a copiedSessionId state (and this helper is not used anywhere in the file). This will throw if called and is likely leftover/unfinished; either add the missing useState + UI usage, or remove this block from AgentsTab.

Suggested change
const copySessionId = (sessionId) => {
if (!sessionId) return;
setCopiedSessionId(sessionId);
navigator.clipboard
.writeText(sessionId)
.then(() => showToast("Session ID copied", "success"))
.catch(() => {
setCopiedSessionId("");
showToast("Copy failed", "error");
});
};

Copilot uses AI. Check for mistakes.
const activeSessionCount = allSessions.filter((session) => {
if (!session || typeof session !== "object") return false;
if (session.active === true) return true;
Expand Down Expand Up @@ -2087,10 +2099,23 @@ function FleetSessionsPanel({ slots, taskFallbackEntries = [], onOpenWorkspace,
const [sessionScope, setSessionScope] = useState(FLEET_SESSION_SCOPE.all);
const [sessionSearch, setSessionSearch] = useState("");
const [selectedEntryKey, setSelectedEntryKey] = useState(null);
const [copiedSessionId, setCopiedSessionId] = useState("");
const [logText, setLogText] = useState("(no logs yet)");
const logRef = useRef(null);
const allSessions = sessionsData.value || [];

const copySessionId = (sessionId) => {
if (!sessionId) return;
setCopiedSessionId(sessionId);
navigator.clipboard
.writeText(sessionId)
.then(() => showToast("Session ID copied", "success"))
.catch(() => {
setCopiedSessionId("");
showToast("Copy failed", "error");
});
Comment on lines +2109 to +2116
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copySessionId calls navigator.clipboard.writeText without checking that the Clipboard API is available. In unsupported/insecure contexts this can throw synchronously (bypassing the Promise .catch) and will also leave data-copied stuck. Add a navigator?.clipboard?.writeText guard (consistent with other clipboard code in this file) and reset copiedSessionId / toast appropriately when unavailable.

Suggested change
setCopiedSessionId(sessionId);
navigator.clipboard
.writeText(sessionId)
.then(() => showToast("Session ID copied", "success"))
.catch(() => {
setCopiedSessionId("");
showToast("Copy failed", "error");
});
// Guard against unsupported / insecure contexts where the Clipboard API
// is unavailable or cannot be used safely.
if (!navigator?.clipboard?.writeText) {
setCopiedSessionId("");
showToast("Clipboard not available", "error");
return;
}
setCopiedSessionId(sessionId);
try {
navigator.clipboard
.writeText(sessionId)
.then(() => showToast("Session ID copied", "success"))
.catch(() => {
setCopiedSessionId("");
showToast("Copy failed", "error");
});
} catch (err) {
setCopiedSessionId("");
showToast("Copy failed", "error");
}

Copilot uses AI. Check for mistakes.
};

/* Stabilise entries with useMemo so the reference only changes when the
underlying data actually changes – prevents infinite render loops that
previously caused "insertBefore" DOM errors. */
Expand Down Expand Up @@ -2385,6 +2410,23 @@ function FleetSessionsPanel({ slots, taskFallbackEntries = [], onOpenWorkspace,
<span class=${`fleet-slot-state-badge ${isFleetEntryActive(entry) ? "active" : "historic"}`}>
${entryStatus || "unknown"}
</span>
${sessionId
? html`<button
type="button"
class="fleet-session-id-pill"
data-session-id=${sessionId}
data-copied=${copiedSessionId === sessionId ? "true" : "false"}
aria-label=${`Copy session ID ${sessionId}`}
onClick=${() => copySessionId(sessionId)}
onAnimationEnd=${(event) => {
if (event?.target !== event?.currentTarget) return;
if (copiedSessionId === sessionId) setCopiedSessionId("");
}}
>
<span class="fleet-session-id-pill-text mono">${sessionId.slice(0, 8)}</span>
<span class="fleet-session-id-pill-icon" aria-hidden="true">${copiedSessionId === sessionId ? "✓" : ICONS.copy}</span>
Comment on lines +2414 to +2427
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The session-id pill is rendered as a native <button> inside an MUI <Button> (which renders as a <button> by default). Nested buttons are invalid HTML and browsers will implicitly close the outer button, breaking click/keyboard behavior and accessibility. Refactor so the row container is not a <button> when it contains this pill (e.g., make the row a <div>/ListItemButton with appropriate keyboard handling, or move copy handling onto a non-nested element).

Copilot uses AI. Check for mistakes.
</button>`
: null}
${entry.slot?.branch
? html`<span class="fleet-slot-meta-branch">${entry.slot.branch}</span>`
: entry.session?.branch
Expand Down
10 changes: 10 additions & 0 deletions tests/fleet-tab-render.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ const sessionListSourceFiles = [

for (const { relPath, source } of sourceFiles) {
describe(`FleetSessionsPanel render stability (${relPath})`, () => {
it("renders a keyboard-accessible session id pill with copy feedback state", () => {
expect(source).toContain("fleet-session-id-pill");
expect(source).toContain("type=\"button\"");
expect(source).toContain("aria-label=${`Copy session ID ${sessionId}`}\");
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion line has a syntax error: the string literal is not closed due to an extra backslash/quote (...${sessionId}}"). This will prevent the test file from parsing/running. Fix the string so it’s a valid JS literal and matches the intended source substring.

Suggested change
expect(source).toContain("aria-label=${`Copy session ID ${sessionId}`}\");
expect(source).toContain("aria-label={`Copy session ID ${sessionId}`}");

Copilot uses AI. Check for mistakes.
expect(source).toContain("data-copied=${copiedSessionId === sessionId ? \"true\" : \"false\"}");
expect(source).toContain("sessionId.slice(0, 8)");
expect(source).toContain("copySessionId(sessionId)");
expect(source).toContain("fleet-session-id-pill-icon");
expect(source).toContain('copiedSessionId === sessionId ? "✓" : ICONS.copy');
});
it("never fabricates session ids for task-only fallback entries", () => {
expect(source).toContain("function resolveFleetEntrySessionId(entry)");
expect(source).toContain("if (entry?.isTaskFallback || entry?.slot?.synthetic) return \"\";");
Expand Down
93 changes: 93 additions & 0 deletions tests/ui-agents-session-pill.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { h } from "preact";
import { cleanup, fireEvent, render } from "@testing-library/preact";

Check failure on line 3 in tests/ui-agents-session-pill.test.mjs

View workflow job for this annotation

GitHub Actions / Build + Tests

tests/ui-agents-session-pill.test.mjs

Error: Cannot find package '@testing-library/preact' imported from '/home/runner/work/bosun/bosun/tests/ui-agents-session-pill.test.mjs' ❯ tests/ui-agents-session-pill.test.mjs:3:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' }

Check failure on line 3 in tests/ui-agents-session-pill.test.mjs

View workflow job for this annotation

GitHub Actions / 🔁 Existing E2E Suite

tests/ui-agents-session-pill.test.mjs

Error: Cannot find package '@testing-library/preact' imported from '/home/runner/work/bosun/bosun/tests/ui-agents-session-pill.test.mjs' ❯ tests/ui-agents-session-pill.test.mjs:3:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' }
import htm from "htm";

const html = htm.bind(h);

vi.mock("../ui/modules/telegram.js", () => ({
haptic: vi.fn(),
showConfirm: vi.fn(),
}));

vi.mock("../ui/modules/api.js", () => ({
apiFetch: vi.fn(() => Promise.resolve({ data: [] })),
sendCommandToChat: vi.fn(),
}));

vi.mock("../ui/modules/state.js", async () => {
const actual = await vi.importActual("../ui/modules/state.js");
return {
...actual,
executorData: {
value: {
data: {
slots: [
{
taskId: "task-1",
taskTitle: "Agent task",
branch: "feature/test",
status: "busy",
sessionId: "12345678-1234-1234-1234-1234567890ab",
startedAt: "2026-03-21T00:00:00.000Z",
},
],
},
},
},
showToast: vi.fn(),
scheduleRefresh: vi.fn(),
refreshTab: vi.fn(),
};
});

vi.mock("../ui/components/session-list.js", () => ({
loadSessions: vi.fn(),
loadSessionMessages: vi.fn(),
selectedSessionId: { value: null },
sessionsData: {
value: [
{
id: "12345678-1234-1234-1234-1234567890ab",
taskId: "task-1",
title: "Agent task",
branch: "feature/test",
status: "active",
createdAt: "2026-03-21T00:00:00.000Z",
lastActiveAt: "2026-03-21T00:00:10.000Z",
},
],
},
sessionMessages: { value: [] },
sessionMessagesSessionId: { value: null },
}));

vi.mock("../ui/components/chat-view.js", () => ({ ChatView: () => html`<div />` }));
vi.mock("../ui/components/diff-viewer.js", () => ({ DiffViewer: () => html`<div />` }));

describe("agents session ID pill", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

it("copies the full session id and clears copied state after animation end", async () => {
const clipboardWrite = vi.fn().mockResolvedValue();
Object.defineProperty(globalThis, "navigator", {
value: { clipboard: { writeText: clipboardWrite } },
configurable: true,
});
Comment on lines +75 to +79
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test overwrites globalThis.navigator but never restores the original value, which can leak into other tests. Capture the prior navigator and restore it in afterEach, or use vi.stubGlobal('navigator', ...) / vi.unstubAllGlobals() to keep test isolation.

Copilot uses AI. Check for mistakes.

const mod = await import("../ui/tabs/agents.js");
const FleetSessionsTab = mod.FleetSessionsTab;
const { findByRole } = render(html`<${FleetSessionsTab} />`);
const pill = await findByRole("button", { name: /copy session id 12345678-1234-1234-1234-1234567890ab/i });

await fireEvent.click(pill);
expect(clipboardWrite).toHaveBeenCalledWith("12345678-1234-1234-1234-1234567890ab");
expect(pill.getAttribute("data-copied")).toBe("true");

fireEvent.animationEnd(pill);
expect(pill.getAttribute("data-copied")).toBe("false");
});
});
48 changes: 48 additions & 0 deletions ui/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -890,3 +890,51 @@
button.task-related-link-chip {
appearance: none;
}


.fleet-session-id-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid var(--border-color, rgba(255,255,255,0.12));
background: color-mix(in srgb, var(--surface-elevated, rgba(255,255,255,0.06)) 88%, transparent);
color: var(--text-secondary);
cursor: pointer;
transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease, transform 160ms ease;
}

.fleet-session-id-pill:hover,
.fleet-session-id-pill:focus-visible {
border-color: var(--accent-color, #7c3aed);
color: var(--text-primary);
outline: none;
}

.fleet-session-id-pill:active {
transform: translateY(1px);
}

.fleet-session-id-pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1em;
}

.fleet-session-id-pill[data-copied="true"] {
color: var(--color-done);
border-color: color-mix(in srgb, var(--color-done) 70%, transparent);
background: color-mix(in srgb, var(--color-done) 14%, transparent);
animation: fleet-session-pill-copied 1.5s ease;
}

@keyframes fleet-session-pill-copied {
0%, 100% {
transform: translateY(0);
}
20% {
transform: translateY(-1px);
}
}
42 changes: 42 additions & 0 deletions ui/tabs/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,18 @@ export function AgentsTab() {
}, [slots, fleetSearch]);

const allSessions = sessionsData.value || [];

const copySessionId = (sessionId) => {
if (!sessionId) return;
setCopiedSessionId(sessionId);
navigator.clipboard
.writeText(sessionId)
.then(() => showToast("Session ID copied", "success"))
.catch(() => {
setCopiedSessionId("");
showToast("Copy failed", "error");
});
};
Comment on lines +1319 to +1329
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copySessionId references setCopiedSessionId, but AgentsTab does not define a copiedSessionId state (and this helper is not used anywhere in the file). This will throw if called and is likely leftover/unfinished; either add the missing useState + UI usage, or remove this block from AgentsTab.

Suggested change
const copySessionId = (sessionId) => {
if (!sessionId) return;
setCopiedSessionId(sessionId);
navigator.clipboard
.writeText(sessionId)
.then(() => showToast("Session ID copied", "success"))
.catch(() => {
setCopiedSessionId("");
showToast("Copy failed", "error");
});
};

Copilot uses AI. Check for mistakes.
const activeSessionCount = allSessions.filter((session) => {
if (!session || typeof session !== "object") return false;
if (session.active === true) return true;
Expand Down Expand Up @@ -2087,10 +2099,23 @@ function FleetSessionsPanel({ slots, taskFallbackEntries = [], onOpenWorkspace,
const [sessionScope, setSessionScope] = useState(FLEET_SESSION_SCOPE.all);
const [sessionSearch, setSessionSearch] = useState("");
const [selectedEntryKey, setSelectedEntryKey] = useState(null);
const [copiedSessionId, setCopiedSessionId] = useState("");
const [logText, setLogText] = useState("(no logs yet)");
const logRef = useRef(null);
const allSessions = sessionsData.value || [];

const copySessionId = (sessionId) => {
if (!sessionId) return;
setCopiedSessionId(sessionId);
navigator.clipboard
.writeText(sessionId)
.then(() => showToast("Session ID copied", "success"))
.catch(() => {
setCopiedSessionId("");
showToast("Copy failed", "error");
});
Comment on lines +2110 to +2116
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copySessionId calls navigator.clipboard.writeText without checking that the Clipboard API is available. In unsupported/insecure contexts this can throw synchronously (bypassing the Promise .catch) and will also leave data-copied stuck. Add a navigator?.clipboard?.writeText guard (consistent with other clipboard code in this file) and reset copiedSessionId / toast appropriately when unavailable.

Suggested change
navigator.clipboard
.writeText(sessionId)
.then(() => showToast("Session ID copied", "success"))
.catch(() => {
setCopiedSessionId("");
showToast("Copy failed", "error");
});
const writeText = navigator?.clipboard?.writeText;
if (!writeText) {
setCopiedSessionId("");
showToast("Copy failed", "error");
return;
}
try {
writeText.call(navigator.clipboard, sessionId)
.then(() => showToast("Session ID copied", "success"))
.catch(() => {
setCopiedSessionId("");
showToast("Copy failed", "error");
});
} catch (_err) {
setCopiedSessionId("");
showToast("Copy failed", "error");
}

Copilot uses AI. Check for mistakes.
};

/* Stabilise entries with useMemo so the reference only changes when the
underlying data actually changes – prevents infinite render loops that
previously caused "insertBefore" DOM errors. */
Expand Down Expand Up @@ -2385,6 +2410,23 @@ function FleetSessionsPanel({ slots, taskFallbackEntries = [], onOpenWorkspace,
<span class=${`fleet-slot-state-badge ${isFleetEntryActive(entry) ? "active" : "historic"}`}>
${entryStatus || "unknown"}
</span>
${sessionId
? html`<button
type="button"
class="fleet-session-id-pill"
data-session-id=${sessionId}
data-copied=${copiedSessionId === sessionId ? "true" : "false"}
aria-label=${`Copy session ID ${sessionId}`}
onClick=${() => copySessionId(sessionId)}
onAnimationEnd=${(event) => {
if (event?.target !== event?.currentTarget) return;
if (copiedSessionId === sessionId) setCopiedSessionId("");
}}
>
<span class="fleet-session-id-pill-text mono">${sessionId.slice(0, 8)}</span>
<span class="fleet-session-id-pill-icon" aria-hidden="true">${copiedSessionId === sessionId ? "✓" : ICONS.copy}</span>
Comment on lines +2414 to +2427
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The session-id pill is rendered as a native <button> inside an MUI <Button> (which renders as a <button> by default). Nested buttons are invalid HTML and browsers will implicitly close the outer button, breaking click/keyboard behavior and accessibility. Refactor so the row container is not a <button> when it contains this pill (e.g., make the row a <div>/ListItemButton with appropriate keyboard handling, or move copy handling onto a non-nested element).

Copilot uses AI. Check for mistakes.
</button>`
: null}
${entry.slot?.branch
? html`<span class="fleet-slot-meta-branch">${entry.slot.branch}</span>`
: entry.session?.branch
Expand Down
Loading