Skip to content
Merged
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
7 changes: 6 additions & 1 deletion crates/giskard-server/static/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
24 changes: 22 additions & 2 deletions crates/giskard-server/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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";
Expand Down
Binary file modified docs/screenshots/ide-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/ide-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 66 additions & 0 deletions tests/e2e/tests/thread-selection.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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/);
});
});
Loading