diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index 29ac6c2..d1c4167 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -54,6 +54,11 @@ async function emitTurnFixture(request: APIRequestContext, status: "completed" | expect(response.ok()).toBe(true); } +async function emitSessionFixture(request: APIRequestContext, status: "in_progress" | "idle") { + const response = await request.post(`${fixtureBaseUrl}/__fixture/emit-session`, { data: { status } }); + expect(response.ok()).toBe(true); +} + async function fixtureRequests(request: APIRequestContext): Promise { const response = await request.get(`${fixtureBaseUrl}/__fixture/requests`); expect(response.ok()).toBe(true); @@ -295,6 +300,7 @@ test("continues from a default Agent definition into an admitted idle Session", await expect(page.getByRole("button", { name: "Start Session" })).toBeEnabled(); await page.getByRole("button", { name: "Start Session" }).click(); await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); + await expect(page.getByRole("region", { name: "Environment and Workspace status" })).toHaveCount(0); const sessionCreates = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" @@ -336,8 +342,10 @@ test("rechecks the saved response before offering the setup-page Session continu test("starts only Agents that pass known Session admission", async ({ page, request }) => { await openAgents(page, request); + await expect(page.getByRole("columnheader", { name: "Session" })).toBeVisible(); const blockedStart = page.getByRole("button", { name: /Start a Session with Lifecycle Agent/ }); + await expect(blockedStart).toContainText("Unavailable"); await expect(blockedStart).toHaveAttribute("aria-disabled", "true"); await blockedStart.focus(); await expect(blockedStart.locator("xpath=..").getByRole("tooltip")).toBeVisible(); @@ -358,6 +366,7 @@ test("starts only Agents that pass known Session admission", async ({ page, requ entry.method === "POST" && entry.path === "/v1/agents/sessions" ))).toHaveLength(blockedSessionCount); await expect(page.getByRole("button", { name: "Start a Session with Second Agent" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Start a Session with Second Agent" })).toContainText("Start Session"); const before = (await fixtureRequests(request)).filter((entry) => ( entry.method === "POST" && entry.path === "/v1/agents/sessions" @@ -484,6 +493,7 @@ test("keeps the Agent ledger and dialogs usable at 390 px in light and dark mode const main = document.querySelector(".app-main")?.getBoundingClientRect(); const trigger = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("New Agent"))?.getBoundingClientRect(); const ledger = document.querySelector(".agent-ledger")?.getBoundingClientRect(); + const sessionTrigger = document.querySelector('button[aria-label="Start a Session with Second Agent"]')?.getBoundingClientRect(); return { innerWidth, documentScrollWidth: document.documentElement.scrollWidth, @@ -491,6 +501,7 @@ test("keeps the Agent ledger and dialogs usable at 390 px in light and dark mode main: main && { left: main.left, right: main.right, width: main.width }, trigger: trigger && { left: trigger.left, right: trigger.right, width: trigger.width }, ledger: ledger && { left: ledger.left, right: ledger.right, width: ledger.width }, + sessionTrigger: sessionTrigger && { left: sessionTrigger.left, right: sessionTrigger.right, width: sessionTrigger.width }, }; }); expect(metrics.documentScrollWidth).toBeLessThanOrEqual(metrics.innerWidth); @@ -501,6 +512,9 @@ test("keeps the Agent ledger and dialogs usable at 390 px in light and dark mode expect(metrics.trigger?.right).toBeLessThanOrEqual(390); expect(metrics.ledger?.left).toBeGreaterThanOrEqual(0); expect(metrics.ledger?.right).toBeLessThanOrEqual(390); + expect(metrics.sessionTrigger?.left).toBeGreaterThanOrEqual(metrics.ledger?.left ?? 0); + expect(metrics.sessionTrigger?.right).toBeLessThanOrEqual(metrics.ledger?.right ?? 390); + await expect(page.getByRole("button", { name: "Start a Session with Second Agent" })).toContainText("Start Session"); await attachScreenshot(page, testInfo, "narrow-light-agent-ledger"); await expect(page.getByRole("button", { name: "Environments", exact: true })).toHaveCount(0); @@ -585,6 +599,27 @@ test("starts one Session with an idempotency key and without browser authorizati } }); +test("shows composer activity only for a Core-reported in-progress Session", async ({ page, request }, testInfo) => { + await resetFixture(request); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + + const activity = page.locator(".conversation-activity"); + await expect(activity).toHaveCount(0); + await emitSessionFixture(request, "in_progress"); + await expect(activity).toBeVisible(); + await expect(activity).toContainText("Lifecycle Agent is working…"); + await expect(activity).toHaveAttribute("aria-live", "polite"); + await expect(activity).toHaveAttribute("aria-atomic", "true"); + await expect(activity.locator(".status-running")).toBeVisible(); + await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeVisible(); + await attachElementScreenshot(activity, testInfo, "conversation-activity"); + + await emitSessionFixture(request, "idle"); + await expect(activity).toHaveCount(0); + await expect(page.getByRole("button", { name: "Send message" })).toBeVisible(); +}); + test("updates Session title and metadata after a latest read while preserving failed and unknown drafts", async ({ page, request }) => { await resetFixture(request); await page.goto("/"); @@ -1218,7 +1253,7 @@ test("applies a buffered live Environment event after an earlier durable snapsho await expect(panel).not.toContainText("Pending"); }); -test("loads every Turn page, reconciles terminal events, and keeps failures beside conversation Items", async ({ page, request }, testInfo) => { +test("loads every Turn page, reconciles terminal events, and keeps diagnostics out of Conversation", async ({ page, request }, testInfo) => { await resetFixture(request); await controlFixture(request, { turnsScenario: 1, @@ -1227,7 +1262,18 @@ test("loads every Turn page, reconciles terminal events, and keeps failures besi await page.goto("/"); await expect(page.getByText("listening", { exact: true })).toBeVisible(); - const timeline = page.getByRole("region", { name: "Turn timeline" }); + const conversationTab = page.getByRole("tab", { name: "Conversation" }); + const conversation = page.getByRole("tabpanel", { name: "Conversation" }); + await expect(conversation.getByRole("region", { name: "Turn timeline" })).toHaveCount(0); + + await page.getByRole("tab", { name: "Trace" }).click(); + const trace = page.getByRole("tabpanel", { name: "Trace" }); + const diagnostics = trace.locator("details.trace-turn-diagnostics"); + await expect(diagnostics).not.toHaveAttribute("open", ""); + await diagnostics.locator("summary").click(); + await expect(diagnostics).toHaveAttribute("open", ""); + + const timeline = diagnostics.getByRole("region", { name: "Turn timeline" }); await expect(timeline).toContainText("7 observed Turns"); for (const status of ["Queued", "In progress", "Waiting", "Completed", "Failed", "Cancelled"]) { await expect(timeline.getByRole("img", { name: `Turn status: ${status}` }).first()).toBeVisible(); @@ -1239,7 +1285,7 @@ test("loads every Turn page, reconciles terminal events, and keeps failures besi const failed = timeline.locator('[data-turn-id="turn_failed"]'); await expect(failed).toContainText("The execution could not complete."); await expect(failed).toContainText("1 linked Item"); - await expect(page.getByText("Persisted input before the Turn failed.")).toBeVisible(); + await expect(trace.getByText("Persisted input before the Turn failed.")).toBeVisible(); await expect(timeline).toContainText("1 Item is not associated with an observed Turn yet."); const readsBeforeTerminal = (await fixtureRequests(request)).filter((entry) => ( @@ -1261,14 +1307,140 @@ test("loads every Turn page, reconciles terminal events, and keeps failures besi await controlFixture(request, { turnsRetrieveStatus: 503 }); await page.getByRole("button", { name: "Recover durable state" }).click(); + await expect(trace.locator(".trace-load-error").filter({ hasText: "Turn history is incomplete" })).toBeVisible(); await expect(timeline.locator(".turn-timeline-failure")).toContainText("Couldn’t load Turn history"); await expect(timeline).toContainText("last observed Turn timeline remains visible"); - await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); + await expect(trace.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + await diagnostics.evaluate((element) => element.scrollIntoView({ block: "start" })); + const diagnosticsLayout = await diagnostics.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewport: innerWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + left: box.left, + right: box.right, + overflowY: getComputedStyle(element).overflowY, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + }; + }); + expect(diagnosticsLayout.document).toBeLessThanOrEqual(diagnosticsLayout.viewport); + expect(diagnosticsLayout.body).toBeLessThanOrEqual(diagnosticsLayout.viewport); + expect(diagnosticsLayout.left).toBeGreaterThanOrEqual(0); + expect(diagnosticsLayout.right).toBeLessThanOrEqual(diagnosticsLayout.viewport); + expect(diagnosticsLayout.overflowY).toBe("auto"); + expect(diagnosticsLayout.scrollHeight).toBeGreaterThan(diagnosticsLayout.clientHeight); + await diagnostics.evaluate((element) => { element.scrollTop = element.scrollHeight; }); + await expect(timeline.locator(".turn-card").last()).toBeVisible(); + await attachElementScreenshot(diagnostics, testInfo, "narrow-turn-diagnostics"); + + await conversationTab.click(); + await expect(conversation.getByRole("region", { name: "Turn timeline" })).toHaveCount(0); + await expect(conversation.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); await expect(page.getByLabel("Message the Agent")).toBeVisible(); +}); + +test("presents an honest searchable Trace workbench without changing the conversation draft", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { + turnsScenario: 1, + turnsPageSize: 2, + itemsScenario: 2, + }); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + + const viewTabs = page.getByRole("tablist", { name: "Session view" }); + const conversationTab = viewTabs.getByRole("tab", { name: "Conversation" }); + const traceTab = viewTabs.getByRole("tab", { name: "Trace" }); + const composer = page.getByLabel("Message the Agent"); + await expect(page.locator("#session-trace-panel")).toBeHidden(); + await composer.fill("Draft survives Trace inspection\nwith a second line"); + const sendsBefore = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length; + + await conversationTab.focus(); + await page.keyboard.press("ArrowRight"); + await expect(traceTab).toHaveAttribute("aria-selected", "true"); + const trace = page.getByRole("tabpanel", { name: "Trace" }); + await expect(trace).toBeVisible(); + await expect(trace).toContainText("Known Turn time"); + const diagnostics = trace.locator("details.trace-turn-diagnostics"); + await expect(diagnostics).not.toHaveAttribute("open", ""); + await expect(diagnostics.locator("summary")).toContainText("Turn diagnostics"); + await expect(trace).toContainText("Turns"); + await expect(trace).toContainText("Tool calls"); + await expect(trace).toContainText("Equal-width sequence · not time-scaled"); + await expect(trace.locator(".trace-order-scroll")).toHaveCount(1); + await expect(trace).toContainText("Core reports Turn wall-clock time, but not per-item timing"); + await expect(trace).toContainText("Configured instructions"); + await expect(trace).toContainText("Completed Turn output remains in the conversation."); + await expect(trace).not.toContainText("TTFT"); + await expect(trace).not.toContainText("Throughput"); + + const search = trace.getByRole("searchbox", { name: "Search trace" }); + await search.fill("Persisted input failed"); + await expect(trace.locator(".trace-ledger-row")).toHaveCount(1); + await expect(trace).toContainText("Persisted input before the Turn failed."); + const messageDuration = trace.locator(".trace-ledger-row").filter({ hasText: "Persisted input before the Turn failed." }).locator(".trace-row-duration"); + await expect(messageDuration.locator('[aria-hidden="true"]')).toHaveText("—"); + await expect(messageDuration).toHaveAttribute("title", "Core does not provide per-item timing."); + await expect(messageDuration.locator(".trace-visually-hidden")).toHaveText("Per-item timing not provided by Core."); + await search.fill(""); + + const patchRow = trace.locator(".trace-ledger-row-tools").filter({ hasText: "apply_patch" }).first(); + await expect(patchRow.locator('.trace-row-duration [aria-hidden="true"]')).toHaveText("41 ms"); + await patchRow.click(); + const detail = page.getByRole("complementary", { name: "Trace item details" }); + await expect(detail).toBeVisible(); + await expect(detail.getByRole("button", { name: "Close trace details" })).toBeFocused(); + const previewTab = detail.getByRole("tab", { name: "Preview" }); + await expect(previewTab).toHaveAttribute("aria-controls", "session-trace-panel-detail-content"); + await previewTab.click(); + await expect(detail.getByRole("tabpanel", { name: "Preview" })).toHaveAttribute("tabindex", "0"); + const viewer = detail.getByRole("region", { name: "Parsar apply patch diff" }); + await expect(viewer).toContainText("3 files"); + await expect(viewer).toContainText("Completed"); + const desktopSplit = await Promise.all([ + trace.locator(".trace-ledger").boundingBox(), + detail.boundingBox(), + ]); + expect(desktopSplit[0]).not.toBeNull(); + expect(desktopSplit[1]).not.toBeNull(); + expect(desktopSplit[1]!.x).toBeGreaterThanOrEqual(desktopSplit[0]!.x + desktopSplit[0]!.width - 1); + await attachScreenshot(page, testInfo, "desktop-trace-detail"); + await page.keyboard.press("Escape"); + await expect(detail).toHaveCount(0); + await expect(patchRow).toBeFocused(); + + await patchRow.click(); + await search.fill("filter selected row out"); + await expect(patchRow).toBeHidden(); + await page.getByRole("button", { name: "Close trace details" }).click(); + await expect(search).toBeFocused(); + await search.fill(""); + + await conversationTab.click(); + await expect(composer).toBeVisible(); + await expect(composer).toHaveValue("Draft survives Trace inspection\nwith a second line"); + const sendsAfter = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length; + expect(sendsAfter).toBe(sendsBefore); await page.setViewportSize({ width: 390, height: 844 }); - await timeline.evaluate((element) => element.scrollIntoView({ block: "start" })); - const widths = await timeline.evaluate((element) => { + await traceTab.click(); + const narrowTrace = page.getByRole("tabpanel", { name: "Trace" }); + const narrowPatchRow = narrowTrace.locator(".trace-ledger-row-tools").filter({ hasText: "apply_patch" }).first(); + await narrowPatchRow.click(); + const narrowDetail = page.getByRole("complementary", { name: "Trace item details" }); + await expect(narrowDetail).toBeVisible(); + await expect(narrowTrace.locator(".trace-ledger")).toBeHidden(); + const widths = await narrowDetail.evaluate((element) => { const box = element.getBoundingClientRect(); return { viewport: innerWidth, @@ -1282,7 +1454,7 @@ test("loads every Turn page, reconciles terminal events, and keeps failures besi expect(widths.body).toBeLessThanOrEqual(widths.viewport); expect(widths.left).toBeGreaterThanOrEqual(0); expect(widths.right).toBeLessThanOrEqual(widths.viewport); - await attachElementScreenshot(timeline, testInfo, "narrow-turn-timeline"); + await attachScreenshot(page, testInfo, "narrow-trace-detail"); }); test("drops a delayed Turn page after switching Sessions", async ({ page, request }) => { @@ -1293,16 +1465,23 @@ test("drops a delayed Turn page after switching Sessions", async ({ page, reques turnsPageSize: 2, }); await page.goto("/"); - const timeline = page.getByRole("region", { name: "Turn timeline" }); await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible({ timeout: 1_500 }); + await page.getByRole("tab", { name: "Trace" }).click(); + const diagnostics = page.locator("details.trace-turn-diagnostics"); + await diagnostics.locator("summary").click(); + const timeline = diagnostics.getByRole("region", { name: "Turn timeline" }); await expect(timeline).toContainText("Loading every Turn page"); await page.getByRole("button", { name: "Agents" }).click(); await expect(page.getByRole("table", { name: "Agents" })).toBeVisible(); await page.getByRole("button", { name: /Start a Session with Second Agent/ }).click(); - await expect(timeline).toContainText("No Turns reported yet."); + await page.getByRole("tab", { name: "Trace" }).click(); + const nextDiagnostics = page.locator("details.trace-turn-diagnostics"); + await nextDiagnostics.locator("summary").click(); + const nextTimeline = nextDiagnostics.getByRole("region", { name: "Turn timeline" }); + await expect(nextTimeline).toContainText("No Turns reported yet."); await page.waitForTimeout(3_000); - await expect(timeline).not.toContainText("turn_queued"); + await expect(nextTimeline).not.toContainText("turn_queued"); await expect(page.getByText("Completed Turn output remains in the conversation.")).toHaveCount(0); const turnReads = (await fixtureRequests(request)).filter((entry) => ( diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 577736b..65008fc 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -312,6 +312,25 @@ function emitTurnLifecycle(status) { return true; } +function emitSessionLifecycle(status) { + if (!["in_progress", "idle"].includes(status)) return false; + const session = state.sessions.find((candidate) => candidate.id === "session_snapshot"); + if (!session) return false; + session.status = status; + session.required_actions = []; + state.sequence += 1; + const event = `id: session_${state.sequence}\ndata: ${JSON.stringify({ + type: `agent.session.${status}`, + event_id: `session_${state.sequence}`, + session_id: session.id, + session, + })}\n\n`; + for (const [stream, sessionId] of streamResponses) { + if (sessionId === session.id) stream.write(event); + } + return true; +} + function sendJson(response, value, status = 200) { const body = JSON.stringify(value); response.writeHead(status, { @@ -409,6 +428,12 @@ const server = http.createServer(async (request, response) => { ? sendJson(response, { emitted: true }) : sendError(response, 400, "Fixture terminal Turn is unavailable."); } + if (request.method === "POST" && url.pathname === "/__fixture/emit-session") { + const input = await readJson(request); + return emitSessionLifecycle(input.status) + ? sendJson(response, { emitted: true }) + : sendError(response, 400, "Fixture Session lifecycle status is unavailable."); + } if (request.method === "GET" && url.pathname === "/__fixture/requests") { return sendJson(response, state.requests); } @@ -659,11 +684,13 @@ const server = http.createServer(async (request, response) => { } const items = sessionId !== "session_snapshot" ? [] - : state.controls.itemsScenario - ? patchItems() - : state.controls.turnsScenario - ? observableTurnItems() - : []; + : state.controls.itemsScenario === 2 + ? [...observableTurnItems(), ...patchItems()] + : state.controls.itemsScenario + ? patchItems() + : state.controls.turnsScenario + ? observableTurnItems() + : []; return sendJson(response, page(items)); } diff --git a/apps/web/src/features/CoreCollectionStates.test.tsx b/apps/web/src/features/CoreCollectionStates.test.tsx index 37b98ed..ebaad7a 100644 --- a/apps/web/src/features/CoreCollectionStates.test.tsx +++ b/apps/web/src/features/CoreCollectionStates.test.tsx @@ -117,6 +117,33 @@ describe("Agent Core collection states", () => { expect(failed).toContain("Researcher"); }); + it("shows composer activity only for a Core-reported in-progress Session", () => { + const renderStatus = (status: AgentSession["status"], busy = false) => renderToStaticMarkup( + , + ); + + const active = renderStatus("in_progress"); + expect(active).toContain('class="conversation-activity"'); + expect(active).toContain('role="status" aria-live="polite" aria-atomic="true"'); + expect(active).toContain("Researcher is working…"); + expect(renderStatus("idle", true)).not.toContain("conversation-activity"); + expect(renderStatus("requires_action")).not.toContain("conversation-activity"); + expect(renderStatus("failed")).not.toContain("conversation-activity"); + }); + it("keeps Sessions loading and failure distinct from ready empty copy", () => { const common = { agents: [], diff --git a/apps/web/src/features/agents/AgentsView.test.tsx b/apps/web/src/features/agents/AgentsView.test.tsx index 3c2724f..0a1917b 100644 --- a/apps/web/src/features/agents/AgentsView.test.tsx +++ b/apps/web/src/features/agents/AgentsView.test.tsx @@ -66,6 +66,8 @@ describe("Agents view", () => { expect(html).toContain('type="button" aria-label="Open details for this Agent"'); expect(html).toContain('aria-disabled="true" aria-label="Start a Session with this Agent"'); + expect(html).toContain('Session'); + expect(html).toContain('Unavailable'); expect(html).toContain("Session unavailable: Current Core Session admission requires"); }); @@ -91,6 +93,7 @@ describe("Agents view", () => { ); expect(html).toContain('type="button" aria-label="Start a Session with this Agent"'); + expect(html).toContain('Start Session'); expect(html).not.toContain("Session unavailable:"); }); diff --git a/apps/web/src/features/agents/AgentsView.tsx b/apps/web/src/features/agents/AgentsView.tsx index 60f5fce..9150246 100644 --- a/apps/web/src/features/agents/AgentsView.tsx +++ b/apps/web/src/features/agents/AgentsView.tsx @@ -82,7 +82,7 @@ function AgentSessionStartAction({ return ( - - {blocker ? `Session unavailable: ${blocker}` : "Start Session"} - + {blocker ? ( + + Session unavailable: {blocker} + + ) : null} ); } @@ -372,7 +375,7 @@ export function AgentsView({ Model Tools Updated - + Session
{filteredAgents.map((agent) => ( diff --git a/apps/web/src/features/sessions/SessionsView.tsx b/apps/web/src/features/sessions/SessionsView.tsx index 4d7e91f..3adb482 100644 --- a/apps/web/src/features/sessions/SessionsView.tsx +++ b/apps/web/src/features/sessions/SessionsView.tsx @@ -38,11 +38,13 @@ import { } from "./environment/EnvironmentPanel"; import type { EnvironmentObservation } from "./environment/environment-state"; import { ThreadItems } from "./items/ItemRenderers"; -import { TurnTimeline, type TurnTimelineLoadState } from "./turns/TurnTimeline"; +import { TraceView } from "./trace/TraceView"; +import type { TurnTimelineLoadState } from "./turns/TurnTimeline"; import { SessionActionsDialog } from "./actions/SessionActionsDialog"; export type StreamState = "idle" | "connecting" | "listening" | "recovering" | "failed"; export type SessionDetailState = "idle" | "loading" | "ready" | "failed"; +type SessionView = "conversation" | "trace"; interface SessionsViewProps { agents: SavedAgent[]; @@ -216,6 +218,15 @@ function CancelOnlyBar({ busy, onCancel }: { busy: boolean; onCancel: () => void ); } +function ConversationActivity({ agentName }: { agentName: string }) { + return ( +
+ + {agentName} is working… +
+ ); +} + function FunctionActionBar({ actions, agentName, @@ -329,6 +340,7 @@ export function SessionsView({ : "Create or load a saved Agent before starting a Session." : null; const [message, setMessage] = useState(""); + const [sessionView, setSessionView] = useState("conversation"); const [newSessionOpen, setNewSessionOpen] = useState(false); const [agentId, setAgentId] = useState(firstStartableAgent?.id ?? ""); const [actionSession, setActionSession] = useState(null); @@ -461,6 +473,25 @@ export function SessionsView({ (unsupportedActionCount > 0 || environmentConnections.length > 0 && functionActions.length === 0), ); + const onViewTabKeyDown = (event: KeyboardEvent) => { + const tabs = Array.from(event.currentTarget.parentElement?.querySelectorAll("[role=tab]") ?? []); + const index = tabs.indexOf(event.currentTarget); + if (index < 0) return; + const target = event.key === "ArrowRight" + ? tabs[(index + 1) % tabs.length] + : event.key === "ArrowLeft" + ? tabs[(index - 1 + tabs.length) % tabs.length] + : event.key === "Home" + ? tabs[0] + : event.key === "End" + ? tabs[tabs.length - 1] + : null; + if (!target) return; + event.preventDefault(); + target.click(); + target.focus(); + }; + return (