import { expect, type Locator, type Page } from "@playwright/test"; import { test } from "../../fixtures/test-base"; import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { KanbanPage } from "../../pages/kanban-page"; import { seedSecondaryClarificationTask } from "../../helpers/clarification"; import { createStandardProfile, openTaskSession } from "../../helpers/git-helper"; import { waitForLatestSessionDone } from "../../helpers/session"; import { attachGatewayTrafficCapture, type GatewayTrafficFrame } from "../../helpers/ws-traffic"; const AGENT_TITLE = "Threads live agent work"; const SECOND_TITLE = "Threads second live agent work"; const IDLE_TITLE = "Threads never started"; function sentSessionIds(frames: readonly GatewayTrafficFrame[], action: string): string[] { return frames .filter((frame) => frame.direction === "sent" && frame.action === action && frame.sessionId) .map((frame) => frame.sessionId as string); } function activeSessionIds(frames: readonly GatewayTrafficFrame[]): string[] { const active = new Set(); for (const frame of frames) { if (frame.direction !== "sent" || !frame.sessionId) continue; if (frame.action === "session.subscribe") active.add(frame.sessionId); if (frame.action === "session.unsubscribe") active.delete(frame.sessionId); } return [...active]; } function columnOrder(board: Locator): Promise { return board .locator("[data-thread-column-id]") .evaluateAll((columns) => columns.map((column) => column.getAttribute("data-thread-column-id") ?? ""), ); } async function visibleColumnTaskIds(board: Locator): Promise { return board.evaluate((node) => { const boardRect = node.getBoundingClientRect(); return [...node.querySelectorAll("[data-thread-column-id]")] .filter((column) => { const rect = column.getBoundingClientRect(); return rect.right > boardRect.left && rect.left < boardRect.right; }) .map((column) => column.dataset.threadColumnId ?? ""); }); } /** * Runs a real agent turn so the task ends with a settled primary session. * Seeded sessions are never marked primary, and the deck follows production's * primary-session rule, so they would not appear. * * The task page is what launches the session (`useEnsureTaskSession`), so * opening it is part of arranging the fixture, not an assertion. */ async function startAgentTask( page: Page, apiClient: ApiClient, seedData: SeedData, profileName: string, options: { title?: string } = {}, ) { const title = options.title ?? AGENT_TITLE; const profile = await createStandardProfile(apiClient, profileName); const task = await apiClient.createTaskWithAgent(seedData.workspaceId, title, profile.id, { description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], }); await openTaskSession(page, title); // `POST /tasks` answers with the Task itself, so the id lives on `id` — the // `task_id` shape belongs to the seed harness. await waitForLatestSessionDone(apiClient, task.id, 1, `agent turn for ${title}`); return task; } test.describe("Threads view", () => { test("decks live agent threads and leaves work with no agent out", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(180_000); const live = await startAgentTask(testPage, apiClient, seedData, "threads-live"); const idle = await apiClient.seedTask(seedData.workspaceId, IDLE_TITLE, { workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, }); await testPage.goto("/"); await expect(new KanbanPage(testPage).board).toBeVisible(); await testPage.getByTestId("view-toggle-threads").click(); await expect(testPage).toHaveURL(/\/threads/); await expect(testPage.getByTestId("threads-board")).toBeVisible(); const column = testPage.getByTestId(`thread-column-${live.id}`); await expect(column).toBeVisible(); await expect(column).toContainText(AGENT_TITLE); // The simple workflow moves the completed task into its review step. await expect(column.getByTestId("thread-status-review-ready")).toBeVisible(); await expect(testPage.getByTestId(`thread-column-${idle.task_id}`)).toHaveCount(0); }); test("remembers Threads as the device listing view and reopens Home there", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(180_000); const live = await startAgentTask(testPage, apiClient, seedData, "threads-remembered"); await testPage.goto("/"); await expect(new KanbanPage(testPage).board).toBeVisible(); await testPage.getByTestId("view-toggle-threads").click(); await expect(testPage).toHaveURL(/\/threads/); await expect(testPage.getByTestId("view-toggle-threads")).toHaveAttribute("data-state", "on"); await testPage.goto("/"); await expect(testPage).toHaveURL(/\/threads/); await expect(testPage.getByTestId(`thread-column-${live.id}`)).toBeVisible(); }); test("opens the full task page from a column", async ({ testPage, apiClient, seedData }) => { test.setTimeout(180_000); const live = await startAgentTask(testPage, apiClient, seedData, "threads-open-task"); await testPage.goto("/threads"); const column = testPage.getByTestId(`thread-column-${live.id}`); await expect(column).toBeVisible(); await column.getByRole("button", { name: "Open task" }).click(); await expect(testPage).toHaveURL(new RegExp(`/t/${live.id}`)); }); test("hands a discussion back to the deck, scrolled to its column", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(180_000); const live = await startAgentTask(testPage, apiClient, seedData, "threads-round-trip"); // startAgentTask leaves the browser on the task page, which is the surface // the button lives on. await testPage.getByTestId("open-in-threads-button").click(); await expect(testPage).toHaveURL(new RegExp(`/threads\\?taskId=${live.id}`)); const column = testPage.getByTestId(`thread-column-${live.id}`); await expect(column).toBeVisible(); await expect(column).toHaveAttribute("data-focused", "true"); // The deck is the round trip's destination, so it must not re-offer the jump. await expect(testPage.getByTestId("open-in-threads-button")).toHaveCount(0); // The mark answers "where is it"; once the reader starts working it has // nothing left to say and must not sit on the column indefinitely. await column.locator(".tiptap.ProseMirror").click(); await expect(column).not.toHaveAttribute("data-focused", "true"); }); test("shares the board width between columns instead of leaving it empty", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(240_000); const first = await startAgentTask(testPage, apiClient, seedData, "threads-width-a"); const second = await startAgentTask(testPage, apiClient, seedData, "threads-width-b", { title: SECOND_TITLE, }); await testPage.goto("/threads"); const board = testPage.getByTestId("threads-board"); await expect(board).toBeVisible(); const columns = [ testPage.getByTestId(`thread-column-${first.id}`), testPage.getByTestId(`thread-column-${second.id}`), ]; for (const column of columns) await expect(column).toBeVisible(); const boardWidth = (await board.boundingBox())?.width ?? 0; const widths = await Promise.all( columns.map(async (column) => (await column.boundingBox())?.width ?? 0), ); expect(boardWidth).toBeGreaterThan(0); // Two threads on a desktop board fill it rather than sitting at a fixed // 380px with the rest of the deck blank. expect(Math.min(...widths)).toBeGreaterThan(400); expect(widths[0] + widths[1]).toBeGreaterThan(boardWidth * 0.8); }); test("saves a capped view, switches views, and restores it after reload", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(360_000); await startAgentTask(testPage, apiClient, seedData, "threads-saved-view-a"); await startAgentTask(testPage, apiClient, seedData, "threads-saved-view-b", { title: "Threads saved view second work", }); await startAgentTask(testPage, apiClient, seedData, "threads-saved-view-c", { title: "Threads saved view third work", }); await testPage.goto("/threads"); const board = testPage.getByTestId("threads-board"); await expect(board.locator("[data-thread-column-id]")).toHaveCount(3); await testPage.getByTestId("threads-view-picker").click(); await testPage.getByTestId("threads-new-view").click(); const editor = testPage.getByTestId("threads-view-settings-popover"); await expect(editor).toBeVisible(); await editor.getByTestId("threads-max-columns").fill("1"); const savedViewResponse = testPage.waitForResponse((response) => { const request = response.request(); if ( !response.ok() || request.method() !== "PATCH" || !request.url().includes("/api/v1/user/settings") ) { return false; } const payload = request.postDataJSON() as { thread_view_draft?: unknown; thread_views?: Array<{ max_columns?: number | null }>; } | null; return ( payload?.thread_view_draft === null && payload.thread_views?.some((view) => view.max_columns === 1) === true ); }); await editor.getByTestId("threads-view-save").click(); await savedViewResponse; await expect(board.locator("[data-thread-column-id]")).toHaveCount(1); await expect(testPage.getByTestId("threads-view-count")).toContainText("1 of 3 columns"); await expect(testPage.getByTestId("threads-view-count")).toContainText("2 hidden"); // The saved view remains active after a full page bootstrap. await testPage.reload(); await expect(board.locator("[data-thread-column-id]")).toHaveCount(1); await expect(testPage.getByTestId("threads-view-picker")).toContainText("New view"); // Switching back to the built-in view restores all eligible columns. The // sidebar's saved-view state is not involved in this interaction. await testPage.getByTestId("threads-view-picker").click(); await testPage.getByTestId("threads-view-option-view-all-threads").click(); await expect(board.locator("[data-thread-column-id]")).toHaveCount(3); }); test("confirms deletion of a saved Threads view before closing settings", async ({ testPage, apiClient, }) => { const baseView = { task_scope: { mode: "all", task_ids: [] }, filters: [], sort: { key: "attention", direction: "asc" }, max_columns: null, }; const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { thread_views: [ { ...baseView, id: "view-all-threads", name: "All threads" }, { ...baseView, id: "view-release", name: "Release threads" }, ], thread_active_view_id: "view-release", thread_view_draft: null, }); expect(seedResponse.ok).toBe(true); await testPage.goto("/threads"); await expect(testPage.getByTestId("threads-view-picker")).toContainText("Release threads"); await testPage.getByTestId("threads-view-settings").click(); const settings = testPage.getByTestId("threads-view-settings-popover"); await expect(settings).toBeVisible(); await settings.getByTestId("threads-view-delete").click(); const confirmation = testPage.getByTestId("saved-task-view-delete-confirmation"); await expect(confirmation).toHaveAccessibleName("Delete Release threads?"); await expect(settings).toBeVisible(); await confirmation.getByRole("button", { name: "Cancel" }).click(); await expect(settings).toBeVisible(); await expect(testPage.getByTestId("threads-view-picker")).toContainText("Release threads"); await settings.getByTestId("threads-view-delete").click(); const deletedViewResponse = testPage.waitForResponse( (response) => response.ok() && response.request().method() === "PATCH" && response.url().includes("/api/v1/user/settings"), ); await confirmation.getByRole("button", { name: "Delete Release threads" }).click(); await deletedViewResponse; await expect(settings).toBeHidden(); await expect(testPage.getByTestId("threads-view-picker")).toContainText("All threads"); }); test("explains sorts and shows live task details in the task picker", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(240_000); const task = await startAgentTask(testPage, apiClient, seedData, "threads-view-task-details", { title: "Threads view task details", }); await apiClient.mockGitHubAssociateTaskPR({ workspace_id: seedData.workspaceId, repository_id: seedData.repositoryId, task_id: task.id, owner: "kandev-e2e", repo: "threads-view-details", pr_number: 42, pr_url: "https://github.test/kandev-e2e/threads-view-details/pull/42", pr_title: "Show Threads view task details", head_branch: "feature/threads-view-details", base_branch: "main", author_login: "threads-author", state: "open", review_state: "approved", checks_state: "failure", mergeable_state: "blocked", }); await testPage.goto("/threads"); await testPage.getByTestId("threads-view-picker").click(); await testPage.getByTestId("threads-new-view").click(); const editor = testPage.getByTestId("threads-view-settings-popover"); await expect(editor).toBeVisible(); await expect(editor).toHaveCSS("border-top-width", "1px"); await expect(editor.getByTestId("threads-max-columns")).toHaveValue("5"); await editor.getByTestId("threads-sort-select").click(); const attention = testPage.getByRole("option", { name: "Attention" }); await expect(attention).toContainText("need you"); await attention.click(); await editor.getByTestId("threads-scope-select").click(); await testPage.getByRole("option", { name: "Selected tasks", exact: true }).click(); await editor.getByTestId("threads-open-task-picker").click(); const row = editor.getByTestId("threads-task-picker-row").filter({ hasText: "Threads view task details", }); await expect(row.getByRole("img", { name: "Review" })).toBeVisible(); await expect(row.getByTestId("threads-task-picker-step")).toHaveText("Review"); await apiClient.updateTaskState(task.id, "WAITING_FOR_INPUT"); await expect(row.getByTestId("task-state-waiting-for-input")).toBeVisible(); const prIcon = row.getByTestId(`pr-task-icon-${task.id}`); await expect(prIcon).toBeVisible(); await expect(prIcon).toHaveClass(/text-/); await prIcon.hover(); await expect( testPage .locator('[data-slot="tooltip-content"]:visible') .filter({ has: testPage.getByTestId("pr-task-status-summary") }) .last(), ).toBeVisible(); }); test("shows which column the cursor is in, and moves that mark on click", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(240_000); const baseView = { id: "view-all-threads", name: "All threads", task_scope: { mode: "all", task_ids: [] }, filters: [], sort: { key: "attention", direction: "asc" }, max_columns: null, }; const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { thread_views: [baseView], thread_active_view_id: baseView.id, thread_view_draft: null, }); expect(seedResponse.ok).toBe(true); const first = await startAgentTask(testPage, apiClient, seedData, "threads-focus-a"); const second = await startAgentTask(testPage, apiClient, seedData, "threads-focus-b", { title: SECOND_TITLE, }); await testPage.goto("/threads"); const columns = { first: testPage.getByTestId(`thread-column-${first.id}`), second: testPage.getByTestId(`thread-column-${second.id}`), }; // The board shell can render before the workflow snapshot carrying the // second completed session reaches the client after navigation. for (const column of Object.values(columns)) { await expect(column).toBeVisible({ timeout: 30_000 }); } // The composer's own border tracks agent state, not the caret, so the // column has to carry the focus mark or a deck of composers gives the // reader no way to tell where typing will land. const ringed = (column: Locator) => column.evaluate((node) => getComputedStyle(node).boxShadow !== "none"); await columns.second.locator(".tiptap.ProseMirror").click(); await expect.poll(() => ringed(columns.second)).toBe(true); expect(await ringed(columns.first)).toBe(false); await columns.first.locator(".tiptap.ProseMirror").click(); await expect.poll(() => ringed(columns.first)).toBe(true); expect(await ringed(columns.second)).toBe(false); }); test("holds a column's slot and the deck's scroll while you reply to it", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(240_000); await startAgentTask(testPage, apiClient, seedData, "threads-order-a"); await startAgentTask(testPage, apiClient, seedData, "threads-order-b", { title: SECOND_TITLE, }); await testPage.goto("/threads"); const board = testPage.getByTestId("threads-board"); await expect(board).toBeVisible(); const columnIds = () => board.evaluate((node) => [...node.querySelectorAll('[data-testid^="thread-column-"]')].map( (column) => column.getAttribute("data-testid") ?? "", ), ); const orderBefore = await columnIds(); expect(orderBefore).toHaveLength(2); const scrollBefore = await board.evaluate((node) => node.scrollLeft); // Reply to the column ranked LAST. Ranking puts the most recent activity // first, so this is the thread whose slot a live re-rank would visibly // move, right while the reader is typing into it. const targetTaskId = orderBefore[orderBefore.length - 1].replace("thread-column-", ""); const target = testPage.getByTestId(`thread-column-${targetTaskId}`); const editor = target.locator(".tiptap.ProseMirror"); await editor.click(); await editor.fill("/e2e:simple-message"); await editor.press("Control+Enter"); // A whole turn's worth of task updates reaches the deck before this // resolves, which is more than enough re-ranking pressure to move a column. await waitForLatestSessionDone(apiClient, targetTaskId, 1, "reply turn never settled"); await expect(target.getByTestId("thread-status-review-ready")).toBeVisible({ timeout: 30_000, }); expect(await columnIds()).toEqual(orderBefore); expect(await board.evaluate((node) => node.scrollLeft)).toBe(scrollBefore); }); test("explains an empty deck when nothing is running", async ({ testPage, apiClient, seedData, }) => { await apiClient.seedTask(seedData.workspaceId, IDLE_TITLE, { workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, }); await testPage.goto("/threads"); await expect(testPage.getByTestId("threads-empty-state")).toBeVisible(); await expect(testPage.getByTestId("threads-board")).toHaveCount(0); }); test("switches sessions and bounds desktop detail streams to the viewport", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(300_000); const target = await seedSecondaryClarificationTask( apiClient, seedData, "Threads multi-session target", ); const first = await startAgentTask(testPage, apiClient, seedData, "threads-window-first", { title: "Threads window first", }); const second = await startAgentTask(testPage, apiClient, seedData, "threads-window-second", { title: "Threads window second", }); const third = await startAgentTask(testPage, apiClient, seedData, "threads-window-third", { title: "Threads window third", }); const sessionByTask = new Map([ [target.id, target.primarySessionId], [first.id, first.session_id], [second.id, second.session_id], [third.id, third.session_id], ]); const capture = attachGatewayTrafficCapture(testPage); await testPage.goto(`/threads?taskId=${target.id}&sessionId=${target.clarificationSessionId}`); const board = testPage.getByTestId("threads-board"); await expect(board).toBeVisible(); await expect(board.locator("[data-thread-column-id]")).toHaveCount(4); const targetColumn = testPage.getByTestId(`thread-column-${target.id}`); const primaryTab = targetColumn.getByTestId(`thread-session-tab-${target.primarySessionId}`); const siblingTab = targetColumn.getByTestId( `thread-session-tab-${target.clarificationSessionId}`, ); await expect(primaryTab).toBeVisible(); await expect(siblingTab).toBeVisible(); await expect(siblingTab).toHaveAttribute("data-state", "active"); await siblingTab .locator("span") .last() .evaluate((node) => { node.textContent = "A session label that is intentionally much longer than the compact metadata row"; }); const desktopTabGeometry = await targetColumn.evaluate((column) => { const switcher = column.querySelector('[data-testid="thread-session-switcher"]'); return { columnOverflow: column.scrollWidth > column.clientWidth, switcherOverflow: switcher ? switcher.scrollWidth > switcher.clientWidth : true, documentOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, }; }); expect(desktopTabGeometry).toEqual({ columnOverflow: false, switcherOverflow: false, documentOverflow: false, }); await expect(testPage).toHaveURL( new RegExp(`taskId=${target.id}.*sessionId=${target.clarificationSessionId}`), ); await expect .poll(() => sentSessionIds(capture.frames, "session.subscribe"), { timeout: 30_000, message: "Threads did not subscribe the deep-linked sibling", }) .toContain(target.clarificationSessionId); const initialVisibleTaskIds = await visibleColumnTaskIds(board); const initialExpectedSessionIds = initialVisibleTaskIds .map((taskId) => taskId === target.id ? target.clarificationSessionId : sessionByTask.get(taskId), ) .filter((sessionId): sessionId is string => Boolean(sessionId)); await expect .poll( () => { const subscribed = new Set(sentSessionIds(capture.frames, "session.subscribe")); return initialExpectedSessionIds.every((sessionId) => subscribed.has(sessionId)); }, { timeout: 30_000, message: "visible task columns did not activate their sessions" }, ) .toBe(true); const initialSubscribed = new Set(activeSessionIds(capture.frames)); expect(initialSubscribed).toEqual(new Set(initialExpectedSessionIds)); expect(initialSubscribed).not.toContain(target.primarySessionId); expect( sentSessionIds(capture.frames, "message.list"), "the unselected sibling must not request a transcript", ).not.toContain(target.primarySessionId); await expect(targetColumn.getByRole("button", { name: /add|new/i })).toHaveCount(0); // The session linked from the task page is a settled clarification sibling. // Switching back to the primary replaces only this column's detail stream; // the sibling remains visible as an exact inactive status item. await primaryTab.click(); await expect(primaryTab).toHaveAttribute("data-state", "active"); await expect(siblingTab.locator('[aria-label="Question from agent"]')).toBeVisible(); await expect .poll(() => sentSessionIds(capture.frames, "session.subscribe"), { timeout: 30_000, message: "switching tabs did not subscribe the primary session", }) .toContain(target.primarySessionId); await expect .poll(() => sentSessionIds(capture.frames, "session.unsubscribe"), { timeout: 30_000, message: "switching tabs did not release the sibling session", }) .toContain(target.clarificationSessionId); const reviewTask = first.id; await apiClient.updateTaskState(reviewTask, "REVIEW"); await expect .poll(() => apiClient.getTask(reviewTask).then((task) => task.state)) .toBe("REVIEW"); await expect(testPage.getByTestId(`thread-column-${reviewTask}`)).toContainText( "Ready for review", ); await expect( testPage.getByTestId(`thread-column-${reviewTask}`).getByTestId("thread-status-review-ready"), ).toBeVisible(); const orderBeforeScroll = await columnOrder(board); const visibleBeforeScroll = await visibleColumnTaskIds(board); const allTaskIds = [target.id, first.id, second.id, third.id]; const scrollTargetTaskId = allTaskIds.find((taskId) => !visibleBeforeScroll.includes(taskId)) ?? allTaskIds.at(-1); if (!scrollTargetTaskId) throw new Error("Threads did not produce a scroll target"); const scrollTargetSessionId = scrollTargetTaskId === target.id ? target.primarySessionId : sessionByTask.get(scrollTargetTaskId); if (!scrollTargetSessionId) throw new Error("Threads scroll target has no session"); const scrollBefore = await board.evaluate((node) => node.scrollLeft); const scrollTarget = testPage.getByTestId(`thread-column-${scrollTargetTaskId}`); await scrollTarget.evaluate((column) => column.scrollIntoView({ inline: "end", block: "nearest" }), ); await expect .poll(() => board.evaluate((node) => node.scrollLeft), { timeout: 15_000, message: "horizontal scroll did not move the Threads board", }) .not.toBe(scrollBefore); await expect .poll(() => sentSessionIds(capture.frames, "session.subscribe"), { timeout: 30_000, message: "scrolling did not activate the newly visible session", }) .toContain(scrollTargetSessionId); const visibleAfterScroll = await visibleColumnTaskIds(board); const departedSessionIds = visibleBeforeScroll .filter((taskId) => !visibleAfterScroll.includes(taskId)) .map((taskId) => (taskId === target.id ? target.primarySessionId : sessionByTask.get(taskId))) .filter((sessionId): sessionId is string => Boolean(sessionId)); await expect .poll( () => departedSessionIds.some((sessionId) => sentSessionIds(capture.frames, "session.unsubscribe").includes(sessionId), ), { timeout: 30_000, message: "scrolling did not release the departed detail", }, ) .toBe(true); expect(await columnOrder(board)).toEqual(orderBeforeScroll); await test.info().attach("threads-desktop-subscription-evidence.json", { body: JSON.stringify( { initialVisibleTaskIds, initialExpectedSessionIds, initialSubscribed: [...initialSubscribed], subscribeSessionIds: sentSessionIds(capture.frames, "session.subscribe"), unsubscribeSessionIds: sentSessionIds(capture.frames, "session.unsubscribe"), visibleBeforeScroll, visibleAfterScroll, scrollTargetTaskId, columnOrder: orderBeforeScroll, }, null, 2, ), contentType: "application/json", }); }); });