diff --git a/crates/giskard-server/static/app.css b/crates/giskard-server/static/app.css index 8eaa0b0..dcdac8c 100644 --- a/crates/giskard-server/static/app.css +++ b/crates/giskard-server/static/app.css @@ -99,7 +99,12 @@ .thread.subagent-thread { font-size:12px; } .thread.subagent-thread::before { content:"sub"; flex:none; color:var(--muted); font-size:10px; } .thread:hover { background:var(--panel2); } - .thread.active { background:var(--panel2); color:var(--fg); } + /* Selected thread: a whisper of accent tint over the neutral raised fill plus a thin accent + rail, no bold. The ~9% tint gives selection a hint of color that a plain hover (bare --panel2) + lacks, so the two separate cleanly at a glance while staying restrained and editor-like. */ + .thread.active { background:var(--panel2); background:color-mix(in srgb, var(--accent) 9%, var(--panel2)); color:var(--fg); box-shadow:inset 2px 0 0 var(--accent); } + .thread.active:hover { background:var(--panel2); background:color-mix(in srgb, var(--accent) 13%, var(--panel2)); } + .thread.active .thread-title { color:var(--fg); } .thread-status { flex:none; width:10px; text-align:center; color:var(--muted); font-weight:700; } .thread-title { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .thread.has-activity .thread-title { color:var(--fg); } diff --git a/crates/giskard-server/static/app.js b/crates/giskard-server/static/app.js index 1c04ce3..0d4ed00 100644 --- a/crates/giskard-server/static/app.js +++ b/crates/giskard-server/static/app.js @@ -756,6 +756,7 @@ function threadRow(pid, t) { const title = t.title || t.id.slice(0,8); const el = document.createElement("div"); el.className="thread mono"; applyThreadTitleToElement(el, pid, t.id, title); + markThreadRowActive(el, !!state.threadId && String(t.id) === String(state.threadId)); const menuBtn = document.createElement("button"); menuBtn.type = "button"; menuBtn.className = "thread-menu-btn"; @@ -939,6 +940,25 @@ function renderAllThreadActivityIndicators() { document.querySelectorAll(".thread").forEach(el => renderThreadActivityIndicator(el.dataset.tid)); } +// Mark a single thread row as the selected one (or not). `aria-current` mirrors the visual state for +// assistive tech and gives the CSS a stable hook. +function markThreadRowActive(el, active) { + if (!el) return; + el.classList.toggle("active", active); + if (active) el.setAttribute("aria-current", "true"); + else el.removeAttribute("aria-current"); +} + +// Derive the sidebar selection from `state.threadId` rather than tracking it imperatively. Thread +// rows are rebuilt whenever the list reloads, so any highlight set by hand goes stale on the next +// re-render (leaving a previously selected row looking selected); recomputing from the single source +// of truth keeps exactly one row active. +function syncActiveThreadHighlight() { + const tid = state.threadId ? String(state.threadId) : null; + document.querySelectorAll(".thread").forEach(el => + markThreadRowActive(el, tid !== null && String(el.dataset.tid) === tid)); +} + function setThreadActivity(tid, activity) { if (!tid || !activity) return; const key = String(tid); @@ -1503,7 +1523,7 @@ function openDraftThread(pid, defaultModel) { state.awaitingInitialThreadState = false; state.awaitingThreadResync = false; resetRenderState(); - document.querySelectorAll(".thread").forEach(e => e.classList.remove("active")); + syncActiveThreadHighlight(); // state.threadId is null for a draft, so this clears any selection $("thrHeader").style.display="flex"; $("composer").style.display="flex"; $("pickerBar").style.display="flex"; setThreadTitle("New thread"); @@ -1571,7 +1591,7 @@ async function openThread(pid, tid, title, opts) { state.awaitingThreadResync = false; state.awaitingIncrementalResync = false; state.resyncStickBottom = false; resetRenderState(); - document.querySelectorAll(".thread").forEach(e => e.classList.toggle("active", e.dataset.tid===tid)); + syncActiveThreadHighlight(); renderAllThreadActivityIndicators(); $("thrHeader").style.display="flex"; $("composer").style.display="flex"; $("pickerBar").style.display="flex"; diff --git a/docs/screenshots/ide-desktop.png b/docs/screenshots/ide-desktop.png index 5087e6e..d09d163 100644 Binary files a/docs/screenshots/ide-desktop.png and b/docs/screenshots/ide-desktop.png differ diff --git a/docs/screenshots/ide-mobile.png b/docs/screenshots/ide-mobile.png index 21dd1b2..34c39d3 100644 Binary files a/docs/screenshots/ide-mobile.png and b/docs/screenshots/ide-mobile.png differ diff --git a/tests/e2e/tests/thread-selection.spec.ts b/tests/e2e/tests/thread-selection.spec.ts new file mode 100644 index 0000000..5e9464d --- /dev/null +++ b/tests/e2e/tests/thread-selection.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from "@playwright/test"; +import { SCRIPTED_REPLY, login } from "./helpers"; + +// The selected thread must be unmistakable in the sidebar, and switching threads must move the +// highlight — a previously selected row that stays highlighted (e.g. because the thread list was +// rebuilt and the imperative highlight went stale) is the regression guarded here. +// +// The replay server is shared and stateful across the suite, so earlier specs may have left threads +// in the Demo project. The assertions therefore never assume a clean slate: they rely on the +// invariant that exactly one row is active and identify the threads under test by their own ids. +test.describe("sidebar thread selection", () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + // Create a thread from a fresh draft and return the id of the row that becomes selected. + async function createThread(page, message: string): Promise { + await page.locator(".proj", { hasText: "Demo" }).locator(".project-add").click(); + const input = page.locator("#input"); + await expect(input).toBeVisible(); + await input.fill(message); + await page.locator("#sendBtn").click(); + await expect( + page.locator("#transcript .msg.agent", { hasText: SCRIPTED_REPLY }), + ).toBeVisible(); + // The just-opened thread is the only active one. + await expect(page.locator(".thread.active")).toHaveCount(1); + const tid = await page.locator(".thread.active").getAttribute("data-tid"); + expect(tid).toBeTruthy(); + return tid as string; + } + + test("highlights exactly one thread and moves it when switching", async ({ page }) => { + const tid1 = await createThread(page, "Alpha thread about the login flow"); + const tid2 = await createThread(page, "Bravo thread about the cache layer"); + expect(tid2).not.toBe(tid1); + + // Switch back to the first thread. + await page.locator(`.thread[data-tid="${tid1}"]`).click(); + await expect(page.locator(`.thread[data-tid="${tid1}"]`)).toHaveClass(/\bactive\b/); + // The previously selected thread must lose the highlight — exactly one active row remains. + await expect(page.locator(".thread.active")).toHaveCount(1); + await expect(page.locator(`.thread[data-tid="${tid2}"]`)).not.toHaveClass(/\bactive\b/); + + // The selection is visually reinforced with an accent bar (inset box-shadow), not just a + // background tint, so it reads clearly even in a long list. + const shadow = await page + .locator(`.thread[data-tid="${tid1}"]`) + .evaluate((el) => getComputedStyle(el).boxShadow); + expect(shadow).not.toBe("none"); + // aria-current mirrors the visual selection for assistive tech. + await expect(page.locator(`.thread[data-tid="${tid1}"]`)).toHaveAttribute( + "aria-current", + "true", + ); + + // A reload rebuilds the whole thread list from scratch. The selection is derived from the + // restored thread rather than an imperative highlight, so exactly one row — the restored one — + // must come back active, with no stale highlight left on the other. + await page.reload(); + await expect(page.locator("#app")).toHaveClass(/open/); + await expect(page.locator(`.thread[data-tid="${tid1}"]`)).toHaveClass(/\bactive\b/); + await expect(page.locator(".thread.active")).toHaveCount(1); + await expect(page.locator(`.thread[data-tid="${tid2}"]`)).not.toHaveClass(/\bactive\b/); + }); +});