diff --git a/apps/web/e2e/app.spec.ts b/apps/web/e2e/app.spec.ts index 890cef214..683572bda 100644 --- a/apps/web/e2e/app.spec.ts +++ b/apps/web/e2e/app.spec.ts @@ -467,7 +467,7 @@ test("recent conversations only show records created from the conversation home" await expect(navigation.locator(".sidebar-recent-row").first()).toContainText("Second local conversation"); }); -test("normal conversation renders inline approvals and resolves them from the composer", async ({ page }) => { +test("normal conversation queues blocking approval dialogs without resizing the composer", async ({ page }) => { await useEnglishLocale(page); const state = createBaseState({ approvalStatus: "expired" }); const threadId = "thread_normal_inline_approvals"; @@ -555,30 +555,106 @@ test("normal conversation renders inline approvals and resolves them from the co await page.goto("/"); const navigation = page.getByLabel("Ling Stack navigation"); - await navigation.locator(".sidebar-recent-row").filter({ hasText: "Normal inline approvals" }).click(); + const recentRow = navigation.locator(".sidebar-recent-row").filter({ hasText: "Normal inline approvals" }); + await recentRow.click(); const conversation = page.getByLabel("Conversation page"); const composer = conversation.getByLabel("New conversation composer"); - const strip = composer.getByRole("region", { name: "Composer approval overlay" }); - await expect(strip).toBeVisible(); - await expect(strip).toContainText("Approve normal shell command"); - await expect(strip).toContainText("2 pending approvals"); - await strip.getByRole("button", { name: /Show other approvals/ }).click(); - await expect(strip).toContainText("Approve normal file edit"); - - await strip.getByRole("button", { name: "Details" }).first().click(); - const inbox = page.locator("[data-approvals-ui2-page]"); - await expect(inbox).toBeVisible(); - await expect(inbox.locator("[data-approval-detail-id='approval_normal_shell']")).toContainText("Approve normal shell command"); - - await navigation.locator(".sidebar-recent-row").filter({ hasText: "Normal inline approvals" }).click(); - const returnedStrip = page.getByLabel("New conversation composer").getByRole("region", { name: "Composer approval overlay" }); - await returnedStrip.getByRole("button", { name: "Approve" }).click(); + const dialog = page.getByRole("dialog", { name: "Allow this action?" }); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Run command: echo normal"); + await expect(dialog).not.toContainText("Approve normal shell command"); + await expect(dialog).not.toContainText("pending approvals"); + await expect(dialog).not.toContainText("Approve normal file edit"); + await expect(composer.locator(".approval-inline-strip")).toHaveCount(0); + const pendingWorkCell = conversation.locator('[data-approval-presentation-owner="dialog"]'); + await expect(pendingWorkCell).toBeVisible(); + await expect(pendingWorkCell).toContainText("Waiting for approval"); + await expect(pendingWorkCell).toContainText("Run command: echo normal"); + await expect(pendingWorkCell).not.toContainText("Approve normal shell command"); + await expect(pendingWorkCell).not.toContainText("Approve normal file edit"); + await expect(pendingWorkCell.locator(".conversation-work-cell-evidence-summary")).toHaveCount(0); + await expect(pendingWorkCell.locator(".conversation-operation-shell-body")).toHaveCount(0); + const composerHeight = await composer.evaluate((element) => element.getBoundingClientRect().height); + + await page.keyboard.press("Escape"); + await expect(dialog).toBeVisible(); + await page.mouse.click(4, 4); + await expect(dialog).toBeVisible(); + + await dialog.getByRole("button", { name: "Allow" }).click(); await expect.poll(() => state.approvals.find((approval) => approval.id === "approval_normal_shell")?.status).toBe("approved"); - await expect(returnedStrip).toContainText("1 pending approvals"); + await expect(dialog).toContainText("Modify file: README.md"); + await expect(dialog).not.toContainText("Approve normal file edit"); + await expect(dialog).not.toContainText("pending approvals"); - await returnedStrip.getByRole("button", { name: "Deny" }).click(); + await dialog.getByRole("button", { name: "Deny" }).click(); await expect.poll(() => state.approvals.find((approval) => approval.id === "approval_normal_edit")?.status).toBe("denied"); - await expect(page.getByLabel("New conversation composer").getByRole("region", { name: "Composer approval overlay" })).toHaveCount(0); + await expect(dialog).toHaveCount(0); + expect(await composer.evaluate((element) => element.getBoundingClientRect().height)).toBe(composerHeight); + await expect(recentRow).toBeFocused(); +}); + +test("teammate private chat resolves its runtime approval through the shared dialog", async ({ page }) => { + await useEnglishLocale(page); + const state = createBaseState({ approvalStatus: "expired" }); + const teammateId = "teammate_private_approval"; + const runId = "run_private_approval"; + const threadId = `tdthread_${teammateId}`; + state.aiTeammates = [createMockTeammate({ id: teammateId, displayName: "Private Approver" })]; + state.runs.unshift({ + ...state.runs[0], + id: runId, + teammateId, + threadId: "thread_private_approval", + status: "waiting_for_approval", + updatedAt: "2026-07-20T03:00:00.000Z", + }); + state.teammateDirectMessages = [{ + id: "tdmsg_private_approval", + workspaceId: "workspace_default", + threadId, + teammateId, + authorType: "ai-teammate", + authorId: teammateId, + body: "Waiting for the private approval.", + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [], + status: "ready", + linkedRunId: runId, + createdAt: "2026-07-20T03:00:00.000Z", + updatedAt: "2026-07-20T03:00:00.000Z", + }]; + state.approvals = [{ + id: "approval_private_edit", + runId, + actionType: "edit", + risk: "low", + title: "Approve private file edit", + description: "private-notes.md", + payload: { type: "file_edit", path: "private-notes.md" }, + status: "pending", + createdAt: "2026-07-20T03:00:00.000Z", + }]; + await mockDaemon(page, state); + await page.goto("/"); + + await page.getByLabel("Ling Stack navigation").getByRole("button", { name: "Private Approver" }).click(); + const composer = page.locator(".teammate-direct-composer"); + const composerHeight = await composer.evaluate((element) => element.getBoundingClientRect().height); + const dialog = page.getByRole("dialog", { name: "Allow this action?" }); + await expect(dialog).toContainText("Modify file: private-notes.md"); + await expect(dialog).not.toContainText("Approve private file edit"); + const pendingWorkCell = page.locator('.teammate-private-feed-owner [data-approval-presentation-owner="dialog"]'); + await expect(pendingWorkCell).toBeVisible(); + await expect(pendingWorkCell).toContainText("Waiting for approval"); + await expect(pendingWorkCell).toContainText("Modify file: private-notes.md"); + await expect(pendingWorkCell).not.toContainText("Approve private file edit"); + await expect(pendingWorkCell.locator(".conversation-operation-shell-body")).toHaveCount(0); + await dialog.getByRole("button", { name: "Deny" }).click(); + await expect.poll(() => state.approvals[0]?.status).toBe("denied"); + await expect(dialog).toHaveCount(0); + expect(await composer.evaluate((element) => element.getBoundingClientRect().height)).toBe(composerHeight); }); test("normal conversation full access confirms before creating the run", async ({ page }) => { @@ -626,6 +702,7 @@ test("normal conversation full access confirms before creating the run", async ( const snapshot = state.runs[0]?.requestSnapshot; return isRecord(snapshot) ? [snapshot.authorizationMode, snapshot.workspaceAccessMode] : []; }).toEqual(["always", "full_access"]); + await expect(page.getByRole("dialog", { name: "Allow this action?" })).toHaveCount(0); }); test("normal conversation composer sends and replays uploaded attachments", async ({ page }) => { diff --git a/apps/web/e2e/channel.spec.ts b/apps/web/e2e/channel.spec.ts index baba3e07a..25006fbf6 100644 --- a/apps/web/e2e/channel.spec.ts +++ b/apps/web/e2e/channel.spec.ts @@ -38,6 +38,23 @@ function channelComposerTextbox(scope: Page | Locator): Locator { return scope.getByRole("textbox", { name: "Message this channel or @ an AI teammate", exact: true }); } +async function openTaskProposalDialog(page: Page, title: string): Promise { + const dialog = page.getByRole("dialog", { name: "Task confirmation" }); + const summary = page.getByLabel("Task proposal summary").filter({ hasText: title }); + await expect.poll(async () => await dialog.isVisible() || await summary.isVisible()).toBe(true); + if (await dialog.isVisible()) { + if ((await dialog.innerText()).includes(title)) return dialog.locator(".channel-execution-proposal-decision"); + await dialog.getByRole("button", { name: "Close" }).click(); + } + try { + await summary.getByRole("button", { name: "View task" }).click(); + } catch (error) { + if (!await dialog.isVisible()) throw error; + } + await expect(dialog).toBeVisible(); + return dialog.locator(".channel-execution-proposal-decision"); +} + function createLineagePlan(overrides: Partial): ProposedPlanArtifact { return { id: "plan_lineage", @@ -265,7 +282,7 @@ async function readOverlappingVirtualFeedRows(page: Page): Promise { test("channel governance notifications stay in the header review queue", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.governanceNotifications = [ { id: "gov_memory_candidate_1", @@ -309,7 +326,7 @@ test("channel governance notifications stay in the header review queue", async ( test("channel header hides empty governance review queue while notifications load", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.governanceNotifications = []; state.governanceNotificationDelayMs = 1_000; let requestStarted = false; @@ -402,7 +419,7 @@ test("terminal bottom panel runs commands in the channel workspace", async ({ pa test("channel team metrics stay in settings without a conversation expert summary", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channelMessages = [ { id: "msg_team_source", @@ -585,7 +602,7 @@ test("channel team metrics stay in settings without a conversation expert summar test("global sidebar keeps fixed chrome, scrollable recents, and a true compact rail", async ({ page }) => { await useEnglishLocale(page); await page.setViewportSize({ width: 1280, height: 720 }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.aiTeammates = [ createMockTeammate({ id: "teammate_drawer_designer", displayName: "Designer" }), createMockTeammate({ id: "teammate_drawer_research", displayName: "Research" }), @@ -803,7 +820,7 @@ test("global sidebar keeps fixed chrome, scrollable recents, and a true compact test("narrow channel viewport keeps the conversation column usable", async ({ page }) => { await useEnglishLocale(page); await page.setViewportSize({ width: 390, height: 844 }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); await mockDaemon(page, state); await page.goto("/"); await openEngineeringChannel(page); @@ -1752,19 +1769,19 @@ test("conversation motion respects reduced motion preference", async ({ page }) test("approval event opens approval detail inside the conversation page", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); await mockDaemon(page, state); await page.goto("/"); await openChannelChatTools(page); - const approvalBubble = page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "Approve shell command" }); + const approvalBubble = page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "Approval expired" }); await expect(approvalBubble).toBeVisible(); await approvalBubble.getByRole("button", { name: "Open approval" }).click(); const approvalOverlay = page.locator(".conversation-detail-overlay"); await expect(approvalOverlay).toBeVisible(); await expect(approvalOverlay).toHaveAttribute("aria-label", "Approval details"); - await expect(approvalOverlay.locator(".channel-approval-detail-panel")).toContainText("Approve shell command"); + await expect(approvalOverlay.locator(".channel-approval-detail-panel")).toContainText("Approval expired"); await expect(page.locator(".channel-side-panel")).toHaveCount(0); }); @@ -1772,7 +1789,7 @@ test("channel drawer does not flash legacy create labels in section headers", as await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.aiTeammates = []; await mockDaemon(page, state); await page.goto("/"); @@ -1789,7 +1806,7 @@ test("channel drawer does not flash legacy create labels in section headers", as test("empty channel drawer opens the new channel dialog", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); configureReadyConversationModel(state); state.channels = []; state.channelBranches = []; @@ -1815,7 +1832,7 @@ test("empty channel drawer opens the new channel dialog", async ({ page }) => { test("new channel dialog selects a project folder before create", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); configureReadyConversationModel(state); await mockDaemon(page, state); await page.goto("/"); @@ -1849,7 +1866,7 @@ test("new channel dialog selects a project folder before create", async ({ page test("new channel dialog shows create failures inline", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); configureReadyConversationModel(state); state.failNextChannelCreate = { error: "channel_create_failed", @@ -1956,7 +1973,7 @@ test("channel composer does not render the resource lease active work strip", as test("wide channel progress popover does not resize the conversation", async ({ page }) => { await useEnglishLocale(page); await page.setViewportSize({ width: 2048, height: 1140 }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channelMessages = [ { id: "msg_attention_layout", @@ -2021,7 +2038,7 @@ test("wide channel progress popover does not resize the conversation", async ({ test("channel runtime policy is visible while preferred specialist controls stay hidden", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); configureReadyConversationModel(state); state.aiTeammates = [ createMockTeammate({ id: "engineer", displayName: "Ling Engineer" }), @@ -2142,7 +2159,7 @@ test("channel runtime policy is visible while preferred specialist controls stay test("channel reply routing mode is not exposed in ordinary settings", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); configureReadyConversationModel(state); state.aiTeammates = [ createMockTeammate({ id: "engineer", displayName: "Ling Engineer" }), @@ -2178,7 +2195,7 @@ test("multi-role channel messages create workflow proposals instead of AI replie await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channels = state.channels.map((channel) => isRecord(channel) && channel.id === "channel_engineering" ? { ...channel, defaultTeammateId: "teammate_builtin_engineer", autoReplyMode: "default_teammate" } @@ -2216,12 +2233,16 @@ test("multi-role channel messages create workflow proposals instead of AI replie expect(state.lastChannelTurnRequest).not.toHaveProperty("providerPresetId"); await expect.poll(() => state.channelExecutionProposals.length).toBe(1); await expect.poll(() => state.channelMessages.filter((message) => isRecord(message) && message.kind === "ai_reply").length).toBe(0); - const proposal = page.getByLabel("Task confirmation"); + const proposal = await openTaskProposalDialog(page, "Architect 先拆,Engineer 实现,QA 验证,可以吗?"); await expect(proposal).toContainText("Architect 先拆,Engineer 实现,QA 验证,可以吗?"); await expect(proposal).toContainText("Engineer"); - await expect(proposal.locator(".channel-proposal-collaboration")).toContainText("Collaboration 1 records"); - await expect(proposal).toContainText("Non-direct multi-agent collaboration waits for confirmation"); - await expect(proposal).toContainText("Starting this task does not approve future runtime actions"); + await expect(proposal).toContainText("Required permissions"); + await expect(proposal).toContainText("Read files"); + await expect(proposal).toContainText("Run commands"); + await expect(proposal).toContainText("Modify files"); + await expect(proposal).not.toContainText("Collaboration"); + await expect(proposal).not.toContainText("Non-direct multi-agent collaboration waits for confirmation"); + await expect(proposal).not.toContainText("Starting this task does not approve future runtime actions"); await expect(proposal.getByRole("button", { name: "Authorize and start task" })).toBeVisible(); await expect(page.getByText("AI teammate is preparing a reply...")).toHaveCount(0); await expect.poll(() => { @@ -2230,7 +2251,7 @@ test("multi-role channel messages create workflow proposals instead of AI replie }).toBe("workflow_proposal|accepted|deterministic"); }); -test("task confirmation cards explain bounded tool permission and target", async ({ page }) => { +test("task confirmation shows only the task, Agent, and product permission labels", async ({ page }) => { await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); @@ -2312,26 +2333,29 @@ test("task confirmation cards explain bounded tool permission and target", async await page.goto("/"); await openEngineeringChannel(page); - const confirmations = page.getByLabel("Task confirmation"); - await expect(confirmations).toHaveCount(3); - const createCard = confirmations.filter({ hasText: "Create README.md" }); - await expect(createCard).toContainText("Need write permission to create /tmp/example/README.md for this task."); - await expect(createCard).toContainText("Required tool"); - await expect(createCard).toContainText("write"); - await expect(createCard).toContainText("Target"); - await expect(createCard).toContainText("/tmp/example/README.md"); - await expect(createCard).toContainText("Executor"); + const summaries = page.getByLabel("Task proposal summary"); + await expect(summaries).toHaveCount(3); + const createCard = await openTaskProposalDialog(page, "Create README.md"); + await expect(createCard.getByText("Task", { exact: true })).toBeVisible(); + await expect(createCard.getByText("Agent", { exact: true })).toBeVisible(); + await expect(createCard.getByText("Required permissions", { exact: true })).toBeVisible(); + await expect(createCard).toContainText("Modify files"); await expect(createCard).toContainText("Ling Agent"); + await expect(createCard).not.toContainText("Need write permission"); + await expect(createCard).not.toContainText("/tmp/example/README.md"); + await expect(createCard).not.toContainText(/\bwrite\b/u); - const editCard = confirmations.filter({ hasText: "Edit docs/guide.md" }); - await expect(editCard).toContainText("Need edit permission to update docs/guide.md for this task."); - await expect(editCard).toContainText("edit"); + const editCard = await openTaskProposalDialog(page, "Edit docs/guide.md"); + await expect(editCard).toContainText("Modify files"); + await expect(editCard).not.toContainText("Need edit permission"); + await expect(editCard.locator(".channel-proposal-permission-chips")).not.toContainText(/\bedit\b/u); - const commandCard = confirmations.filter({ hasText: "Run pnpm test" }); - await expect(commandCard).toContainText("Need bash permission to run pnpm test for this task."); - await expect(commandCard).toContainText("bash"); + const commandCard = await openTaskProposalDialog(page, "Run pnpm test"); + await expect(commandCard).toContainText("Run commands"); + await expect(commandCard).not.toContainText("Need bash permission"); + await expect(commandCard.locator(".channel-proposal-permission-chips")).not.toContainText(/\bbash\b/u); - await expect(commandCard).toContainText("Starting this task does not approve future runtime actions"); + await expect(commandCard).not.toContainText("Starting this task does not approve future runtime actions"); await expect(commandCard.getByRole("button", { name: "Authorize and start task" })).toBeVisible(); }); @@ -2515,9 +2539,14 @@ test("ordinary channel feed hides routing and intent decision internals", async await page.goto("/"); await openEngineeringChannel(page); + const autoOpenedProposal = page.getByRole("dialog", { name: /Task confirmation|任务确认/u }); + if (await autoOpenedProposal.isVisible().catch(() => false)) { + await expect(autoOpenedProposal).toContainText("Edit README.md"); + await autoOpenedProposal.getByRole("button", { name: /Close|关闭/u }).click(); + } const feed = page.locator(".activity-shell"); await expect(feed.getByText("晚上好")).toBeVisible(); - await expect(feed.locator(".channel-execution-proposal").filter({ hasText: "Edit README.md" })).toBeVisible(); + await expect(page.getByLabel(/Task proposal summary|任务提案摘要/u).filter({ hasText: "Edit README.md" })).toBeVisible({ timeout: 10_000 }); await expect(feed.getByText("未自动回复")).toHaveCount(0); await expect(feed.getByText("为什么没回复")).toHaveCount(0); await expect(feed.getByText("Ling 未回复")).toHaveCount(0); @@ -2533,7 +2562,7 @@ test("ordinary channel feed hides routing and intent decision internals", async test("channel context inspector exposes safe metadata and recovers from a stale segment correction", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); const channelId = "channel_engineering"; const threadId = "thread_context_inspect"; const turnId = "turn_msg_context_inspect"; @@ -2767,7 +2796,7 @@ test("channel settings can add and remove teammates without preferred specialist await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channels = state.channels.map((channel) => isRecord(channel) && channel.id === "channel_engineering" ? { ...channel, defaultTeammateId: undefined } @@ -2879,7 +2908,7 @@ test("channel settings attaches an AI council by reference", async ({ page }) => await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channels = state.channels.map((channel) => isRecord(channel) && channel.id === "channel_engineering" ? { ...channel, defaultTeammateId: undefined } @@ -2984,7 +3013,7 @@ test("channel settings attaches an AI council by reference", async ({ page }) => test("channel message feed renders AI teammate avatar images", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.aiTeammates = [ createMockTeammate({ id: "teammate_builtin_designer", @@ -3080,7 +3109,7 @@ test("channel message feed renders AI teammate avatar images", async ({ page }) test("channel message feed shows usable web sources and keeps diagnostics in the right dock", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.aiTeammates = [createMockTeammate({ id: "engineer", displayName: "Ling Engineer" })]; state.channelMessages = [ { @@ -3265,7 +3294,7 @@ test("channel message feed shows usable web sources and keeps diagnostics in the test("right dock browser evidence exposes failed read URL detail", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.aiTeammates = [createMockTeammate({ id: "engineer", displayName: "Ling Engineer" })]; state.channelMessages = [ { @@ -3332,7 +3361,7 @@ test("right dock browser evidence exposes failed read URL detail", async ({ page test("conversation turn transcript omits web diagnostics without usable sources", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channelMessages = [ { id: "msg_web_turn_user", @@ -3399,7 +3428,7 @@ test("channel settings manage workspace binding", async ({ page }) => { await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); await mockDaemon(page, state); await page.goto("/"); await openEngineeringChannel(page); @@ -3470,7 +3499,7 @@ test("channel settings manage workspace binding", async ({ page }) => { test("channel more menu renames the selected channel", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); await mockDaemon(page, state); await page.goto("/"); await openEngineeringChannel(page); @@ -3496,7 +3525,7 @@ test("channel more menu renames the selected channel", async ({ page }) => { test("paused channels disable task run actions", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channels = state.channels.map((channel) => isRecord(channel) ? { ...channel, status: "paused" } : channel); await mockDaemon(page, state); await page.goto("/"); @@ -3511,7 +3540,7 @@ test("channel activity supports mentions, messages, files, links, and topic repl await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.providers = [createChatProvider({ lastCheck: { ok: true, @@ -3703,7 +3732,7 @@ test("channel activity supports mentions, messages, files, links, and topic repl test("channel composer snapshots restore draft assets and topic replies across channel switches", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.linkPreviewDelayMs = 50; state.channels = [ ...state.channels, @@ -4088,7 +4117,7 @@ test("channel message body renders safe markdown, highlighted code, folded logs, }, }); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); const longJson = [ "{", ...Array.from({ length: 32 }, (_, index) => ` "item${index + 1}": ${index + 1},`), @@ -4414,7 +4443,7 @@ test("channel composer sends workspace file refs as native message payload", asy await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; state.tasks = []; state.approvals = []; @@ -4437,7 +4466,7 @@ test("channel composer sends workspace file refs as native message payload", asy const references = page.getByLabel("References"); const fileReference = references.getByRole("button", { name: /WorkspaceClientView\.tsx/ }); await expect(fileReference).toBeVisible(); - await fileReference.hover(); + await fileReference.focus(); await expect(references.getByRole("tooltip")).toContainText("apps/web/src/features/workspace/WorkspaceClientView.tsx"); await fileReference.click(); const filesDock = page.locator(".channel-side-panel.right-dock"); @@ -4450,7 +4479,7 @@ test("channel composer preserves IME composition until committed", async ({ page await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; state.tasks = []; state.approvals = []; @@ -4491,7 +4520,7 @@ test("channel composer does not render multi-agent slash command tokens as file await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; state.tasks = []; state.approvals = []; @@ -4515,7 +4544,7 @@ test("channel composer applies plan mode session commands without creating slash await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; state.tasks = []; state.approvals = []; @@ -4605,7 +4634,7 @@ test("channel composer manages goal lifecycle from the floating panel", async ({ await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; state.tasks = []; state.approvals = []; @@ -4728,7 +4757,7 @@ test("channel feed renders proposed_plan blocks as proposed plan cards", async ( await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; state.tasks = []; state.approvals = []; @@ -4868,7 +4897,7 @@ test("channel feed renders proposed_plan blocks as proposed plan cards", async ( test("workflow conversion preview infers owners without mutating until save", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; state.tasks = []; state.approvals = []; @@ -4964,7 +4993,7 @@ test("workflow conversion preview infers owners without mutating until save", as test("plan history groups lineage versions across thread and channel scopes", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; state.tasks = []; state.approvals = []; @@ -5129,7 +5158,7 @@ test("channel composer supports spaced teammate names before slash commands", as await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.aiTeammates = [createMockTeammate({ id: "ling_agent", displayName: "Ling Agent" })]; state.runs = []; state.tasks = []; @@ -5169,7 +5198,7 @@ test("channel composer opens slash picker for later slash tokens without multi-d await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; state.tasks = []; state.approvals = []; @@ -5202,7 +5231,7 @@ test("channel composer renders GitHub URLs as inline repo tokens", async ({ page await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; state.tasks = []; state.approvals = []; @@ -5223,7 +5252,7 @@ test("channel screenshot request shows materialization clarify reply instead of await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; state.tasks = []; state.approvals = []; @@ -5265,7 +5294,7 @@ test("channel local link request shows natural clarify copy without debug vocabu await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; state.tasks = []; state.approvals = []; @@ -5439,7 +5468,7 @@ test("channel execution proposal confirms into a multi-step workflow", async ({ } }); page.on("pageerror", (error) => runtimeErrors.push(`pageerror:${error.message}`)); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.providers = [ createChatProvider({ id: "provider_ready", @@ -5567,35 +5596,27 @@ test("channel execution proposal confirms into a multi-step workflow", async ({ await page.goto("/"); await openEngineeringChannel(page); - const proposal = page.getByLabel("Task confirmation"); + const autoOpenedDialog = page.getByRole("dialog", { name: "Task confirmation" }); + await expect(autoOpenedDialog).toBeVisible(); + await autoOpenedDialog.getByRole("button", { name: "Close" }).click(); + const proposalSummary = page.getByLabel("Task proposal summary").filter({ hasText: "Refine channel workspace copy" }); + await expect(proposalSummary).toBeVisible(); + await expect(proposalSummary.locator("xpath=..").locator(".channel-execution-proposal-summary-avatar")).toHaveAttribute("aria-label", "Ling Engineer"); + await maybeCaptureViewportScreenshot(page, "task-proposal-compact-summary-with-avatar.png"); + + const proposal = await openTaskProposalDialog(page, "Refine channel workspace copy"); await expect(proposal).toContainText("Refine channel workspace copy"); - await expect(proposal).toContainText("Ling is shaping the task"); - await expect(proposal).toContainText("Goal"); - await expect(proposal).toContainText("Participants"); - await expect(proposal).toContainText("Boundary"); await expect(proposal).toContainText("Ling Engineer"); - await expect(proposal).toContainText("Ling Designer"); - const collaboration = proposal.locator(".channel-proposal-collaboration"); - await expect(collaboration).not.toHaveAttribute("open", /.+/); - await expect(collaboration.locator("summary")).toContainText("Collaboration 2 records"); - await expect(collaboration.locator("summary")).toContainText("Ling Engineer Summarized discussion into one execution proposal."); - await expect(collaboration.locator("summary")).toContainText("Ling Designer Checked visual hierarchy before execution."); - const overflowingProposalSections = await proposal.evaluate((element) => { - const proposalRect = element.getBoundingClientRect(); - return Array.from(element.querySelectorAll(":scope > p, .channel-proposal-meta, .channel-proposal-criteria, .channel-proposal-risk, .channel-proposal-collaboration summary")) - .filter((node) => { - const rect = node.getBoundingClientRect(); - return rect.left < proposalRect.left - 1 || rect.right > proposalRect.right + 1; - }) - .map((node) => node.className || node.tagName.toLowerCase()); - }); - expect(overflowingProposalSections).toEqual([]); - await expect(collaboration.locator("li").first()).not.toBeVisible(); - await collaboration.locator("summary").click(); - await expect(collaboration.locator("li").first()).toBeVisible(); - await expect(collaboration).toContainText("Coordinated"); - await expect(collaboration).toContainText("Reviewed"); - await expect(collaboration.locator(".channel-proposal-step-source")).toContainText("From Development Council"); + await expect(proposal).toContainText("Required permissions"); + await expect(proposal).toContainText("Read files"); + await expect(proposal).toContainText("Run commands"); + await expect(proposal).toContainText("Modify files"); + await expect(proposal).not.toContainText("Ling is shaping the task"); + await expect(proposal).not.toContainText("Participants"); + await expect(proposal).not.toContainText("Boundary"); + await expect(proposal).not.toContainText("Ling Designer"); + await expect(proposal.locator(".channel-proposal-details, .channel-proposal-collaboration")).toHaveCount(0); + await maybeCaptureViewportScreenshot(page, "task-proposal-confirmation-dialog.png"); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth )).toBeLessThanOrEqual(1); @@ -5667,9 +5688,10 @@ test("channel execution proposal confirms into a multi-step workflow", async ({ await expect(workCell.getByRole("button", { name: "Open workflow evidence" })).toHaveCount(0); await openChannelChatTools(page); - const acceptedProposal = feed.getByLabel("Task confirmation").filter({ hasText: "Refine channel workspace copy" }); + const acceptedProposal = feed.getByLabel("Task proposal summary").filter({ hasText: "Refine channel workspace copy" }); await expect(acceptedProposal).toBeVisible(); - await expect(acceptedProposal.getByRole("button", { name: "Open workflow" })).toHaveCount(0); + await acceptedProposal.getByRole("button", { name: "View task" }).click(); + await expect(page.getByRole("dialog", { name: "Task confirmation" }).getByRole("button", { name: "Open workflow" })).toHaveCount(0); expect(runtimeErrors).toEqual([]); }); @@ -5683,6 +5705,7 @@ test("requirement shaping proposal persists clarifications before confirmation", localStorage.setItem("ling-stack.themeBootstrapPreference", "dark"); }); const state = createBaseState({ approvalStatus: "expired" }); + state.aiTeammates = [createMockTeammate({ id: "ling_agent", displayName: "Ling Agent" })]; state.channelMessages = [ { id: "msg_shaping_request", @@ -5744,43 +5767,43 @@ test("requirement shaping proposal persists clarifications before confirmation", await mockDaemon(page, state); await page.goto("/"); await openEngineeringChannel(page); + const autoOpenedProposal = page.getByRole("dialog", { name: "Task confirmation" }); + if (await autoOpenedProposal.isVisible()) { + await autoOpenedProposal.getByRole("button", { name: "Close" }).click(); + } const transcript = page.locator(".conversation-turn-assistant-transcript"); const shapingBlock = transcript.locator(".transcript-block-view.kind-requirement_shaping"); - await expect(shapingBlock).toBeVisible(); - await expect(shapingBlock).toHaveAttribute("aria-label", "Requirement shaping"); - await expect(shapingBlock.getByRole("button", { name: "Collapse transcript block" })).toHaveAttribute("aria-expanded", "true"); - await expect(shapingBlock).toContainText("Clarify workspace copy scope before execution."); - await expect(shapingBlock).toContainText("Needs user clarification"); - await expect(shapingBlock).toContainText("ling_agent: Clarify the task, then prepare an executable proposal."); - await expect(shapingBlock).not.toContainText("Synced on availability only."); - await expect(shapingBlock).toContainText("Only workspace drawer copy is in scope."); + await expect(shapingBlock).toHaveCount(0); const decisionBlock = transcript.locator(".transcript-block-view.kind-decision_needed"); await expect(decisionBlock).toBeVisible(); await expect(decisionBlock).toHaveAttribute("aria-label", "Decision needed"); await expect(decisionBlock).toContainText("Which copy sections are in scope?"); - - const proposal = page.getByLabel("Task confirmation"); - await expect(proposal).toContainText("Needs user clarification"); - await expect(proposal).toContainText("Which copy sections are in scope?"); - await expect(proposal).toContainText("Only workspace drawer copy is in scope."); + const summary = page.getByLabel("Task proposal summary").filter({ hasText: "Refine workspace copy" }); + await expect(summary).toHaveCount(1); + await expect(summary).toContainText("Ling Agent · Needs input"); + await expect(page.getByLabel("Channel message feed")).not.toContainText("Requirement shaping"); + await expect(page.getByLabel("Channel message feed")).not.toContainText("coordinated"); + await expect(page.getByLabel("Channel message feed")).not.toContainText("ling_agent"); + await expect(page.getByLabel("Channel message feed")).not.toContainText("Only workspace drawer copy is in scope."); + + const proposal = await openTaskProposalDialog(page, "Refine workspace copy"); + await expect(proposal).toContainText("Refine workspace copy"); + await expect(proposal).toContainText("Ling Agent"); + await expect(proposal).toContainText("Read files"); + await expect(proposal).toContainText("Modify files"); + await expect(proposal).not.toContainText("Needs user clarification"); + await expect(proposal).not.toContainText("Which copy sections are in scope?"); + await expect(proposal).not.toContainText("Only workspace drawer copy is in scope."); await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "ling-dark"); - const proposalSurfaceChannels = await proposal.evaluate((element) => { - const readChannels = (node: Element): number[] => - getComputedStyle(node).backgroundColor.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? []; - const collaborationSummary = element.querySelector(".channel-proposal-collaboration summary"); - return [ - ...readChannels(element), - ...(collaborationSummary ? readChannels(collaborationSummary) : []), - ]; - }); - expect(Math.max(...proposalSurfaceChannels)).toBeLessThanOrEqual(45); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1); await page.reload(); await openEngineeringChannel(page); - await expect(proposal).toContainText("Needs user clarification"); - await expect(proposal).toContainText("Which copy sections are in scope?"); - await expect(proposal).toContainText("Only workspace drawer copy is in scope."); + await openTaskProposalDialog(page, "Refine workspace copy"); + await expect(proposal).not.toContainText("Needs user clarification"); + await expect(proposal).not.toContainText("Which copy sections are in scope?"); + await expect(proposal).not.toContainText("Only workspace drawer copy is in scope."); await proposal.getByRole("button", { name: "Edit" }).click(); const editForm = proposal.locator(".channel-proposal-edit-form"); @@ -5788,8 +5811,8 @@ test("requirement shaping proposal persists clarifications before confirmation", await editForm.getByLabel("User clarifications").fill("Only workspace drawer copy is in scope.\nKeep provider checks out of this task."); await proposal.getByRole("button", { name: "Save" }).click(); await expect(editForm).toHaveCount(0); - await expect(proposal).toContainText("Should QA cover accessibility?"); - await expect(proposal).toContainText("Keep provider checks out of this task."); + await expect(proposal).not.toContainText("Should QA cover accessibility?"); + await expect(proposal).not.toContainText("Keep provider checks out of this task."); await expect.poll(() => { const updated = state.channelExecutionProposals.find((item) => isRecord(item) && item.id === "proposal_shaping"); if (!isRecord(updated) || !isRecord(updated.shaping)) { @@ -6007,7 +6030,7 @@ test("completed workflow hides accepted proposal and leaves final result in defa await expect(feed.locator(".conversation-work-cell")).toHaveCount(1); await openChannelChatTools(page); - await expect(feed.getByLabel("Task confirmation")).toContainText("Create /Users/example/ling-stack-test/README.md"); + await expect(feed.getByLabel("Task proposal summary")).toContainText("Create /Users/example/ling-stack-test/README.md"); }); test("terminal workflow step suppresses stale running run surfaces", async ({ page }) => { @@ -7040,7 +7063,7 @@ test("blocked workflow hides accepted proposal and leaves blocker report in defa await expect(workCell).not.toContainText("No validation commands recorded."); await openChannelChatTools(page); - await expect(feed.getByLabel("Task confirmation")).toContainText("Create README.md"); + await expect(feed.getByLabel("Task proposal summary")).toContainText("Create README.md"); }); test("resolved approval-only workflow stays out of the conversation", async ({ page }) => { @@ -8068,7 +8091,7 @@ test("channel workspace utility panel follows dark theme surfaces", async ({ pag localStorage.setItem("ling-stack.locale", "en-US"); localStorage.setItem("ling-stack.themePreference", "dark"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.runs = []; await mockDaemon(page, state); await page.goto("/"); @@ -8132,7 +8155,7 @@ test("channel activity filter chips follow dark theme tokens", async ({ page }) localStorage.setItem("ling-stack.locale", "en-US"); localStorage.setItem("ling-stack.themePreference", "dark"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); await mockDaemon(page, state); await page.goto("/"); @@ -8163,7 +8186,7 @@ test("channel activity keeps the composer visible while the message feed scrolls await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channelMessages = Array.from({ length: 36 }, (_, index) => ({ id: `msg_scroll_${index + 1}`, workspaceId: "workspace_default", @@ -8307,7 +8330,7 @@ test("channel message feed supports focused block shortcuts and safe action menu }, }); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channelMessages = [ { id: "msg_focus_1", @@ -8398,7 +8421,7 @@ test("channel message feed stays pinned while AI reply text streams", async ({ p await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); const channel = state.channels.find((item) => isRecord(item) && item.id === "channel_engineering"); if (isRecord(channel)) { channel.autoReplyMode = "auto_route"; @@ -8440,7 +8463,7 @@ test("channel message feed stays pinned while AI reply text streams", async ({ p test("channel message feed measures long turns without overlapping the next row", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); const longReply = (label: string) => Array.from( { length: 18 }, (_, index) => `${label} paragraph ${index + 1} contains enough text to create a variable-height conversation row.`, @@ -8559,7 +8582,7 @@ test("channel message feed preserves the visible anchor while active content gro await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channelMessages = [ ...Array.from({ length: 36 }, (_, index) => ({ id: `msg_anchor_${index + 1}`, @@ -8624,7 +8647,7 @@ test("channel activity paginates long message history and virtualizes visible ro await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channelMessages = Array.from({ length: 3000 }, (_, index) => ({ id: `msg_long_${index + 1}`, workspaceId: "workspace_default", @@ -8678,7 +8701,7 @@ test("channel composer preserves failed sends and can retry", async ({ page }) = await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.failNextChannelMessage = true; await mockDaemon(page, state); await page.goto("/"); @@ -8707,7 +8730,7 @@ test("channel composer moves sent messages into the feed while routing is pendin await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); const channel = state.channels.find((item) => isRecord(item) && item.id === "channel_engineering"); if (isRecord(channel)) { channel.autoReplyMode = "auto_route"; @@ -8734,7 +8757,7 @@ test("channel composer keeps thinking visible for acknowledged sends before comp await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "zh-CN"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); const channel = state.channels.find((item) => isRecord(item) && item.id === "channel_engineering"); if (isRecord(channel)) { channel.autoReplyMode = "mentions_only"; @@ -8775,7 +8798,7 @@ test("channel composer keeps thinking visible for acknowledged sends before comp test("pending channel reply polling refreshes messages without refreshing global activity", async ({ page }) => { await useEnglishLocale(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channelMessages = [{ id: "msg_pending_background_poll", workspaceId: "workspace_default", @@ -8815,7 +8838,7 @@ test("channel conversation turn shows thinking for empty pending ai replies", as await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "zh-CN"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); const channel = state.channels.find((item) => isRecord(item) && item.id === "channel_engineering"); if (isRecord(channel)) { channel.autoReplyMode = "default_teammate"; @@ -8852,7 +8875,7 @@ test("channel URL preview blocked state stays in Activity and still allows sendi await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); await mockDaemon(page, state); await page.goto("/"); await openEngineeringChannel(page); @@ -8871,7 +8894,7 @@ test("channel attachment uploads support PDF and Office files without AI ingesti await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); await mockDaemon(page, state); await page.goto("/"); await openEngineeringChannel(page); @@ -8925,7 +8948,7 @@ test("channel run artifacts stay isolated without default drawer leakage", async await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channels.push({ id: "channel_design", workspaceId: "workspace_default", @@ -9035,7 +9058,7 @@ test("channel review source stays in conversation instead of the legacy context await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channelContextSummaries = [ { id: "summary_seed_context", @@ -9085,7 +9108,7 @@ test("handoff activity starts workflows without restoring the legacy context dra await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.providers = [createChatProvider()]; await mockDaemon(page, state); await page.goto("/"); @@ -9136,7 +9159,7 @@ test("workflow activity uses review source instead of the legacy context run sur await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.providers = [createChatProvider()]; state.channelWorkflows = [ { @@ -9737,6 +9760,22 @@ test("control-plane snapshot-required subscribes before refresh and applies an e (globalThis as typeof globalThis & { __snapshotHandoff: { urls: string[] } }).__snapshotHandoff.urls.length )).toBe(2); const itemQueriesBeforeHandoff = state.channelMessageQueries?.length ?? 0; + state.channelMessages.push({ + id: "msg_snapshot_handoff_seq_9", + workspaceId: "workspace_default", + channelId: "channel_engineering", + kind: "message", + intent: "discuss", + authorType: "local-user", + authorId: "tian", + body: "Snapshot handoff seq 9 applied", + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + status: "ready", + createdAt: "2026-08-10T00:01:09.000Z", + updatedAt: "2026-08-10T00:01:09.000Z", + }); await page.evaluate(() => { const handoff = (globalThis as typeof globalThis & { __snapshotHandoff: { eventAfterSnapshot: () => void; replayReady: () => void }; @@ -9745,8 +9784,9 @@ test("control-plane snapshot-required subscribes before refresh and applies an e handoff.replayReady(); }); await expect.poll(() => state.channelMessageQueries?.length ?? 0, { timeout: 5_000 }) - .toBeGreaterThan(itemQueriesBeforeHandoff); - await page.waitForTimeout(300); + .toBeGreaterThanOrEqual(itemQueriesBeforeHandoff + 2); + await page.waitForLoadState("networkidle"); + await expect(page.getByText("Snapshot handoff seq 9 applied")).toBeVisible(); await page.evaluate(() => { (globalThis as typeof globalThis & { __snapshotHandoff: { disconnect: () => void } }) .__snapshotHandoff.disconnect(); @@ -10766,11 +10806,19 @@ test("failed conversation work cell does not label terminal rows as working", as await expect(workCell.getByLabel("Active work stream")).not.toContainText("Working"); }); -test("active conversation work cell renders sparse rows while runtime approvals stay in composer overlay", async ({ page }) => { +test("active conversation work cell renders sparse rows while runtime approvals stay in a blocking dialog", async ({ page }) => { await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); const state = createBaseState({ approvalStatus: "pending" }); + state.providers = [createChatProvider({ + lastCheck: { + ok: true, + code: "ready", + message: "Provider is reachable.", + checkedAt: "2026-05-22T00:00:00.000Z", + }, + })]; const run = state.runs[0]; run.threadId = "thread_active_approval"; run.status = "waiting_for_approval"; @@ -10815,25 +10863,33 @@ test("active conversation work cell renders sparse rows while runtime approvals const workCell = feed.locator(".conversation-work-cell"); await expect(workCell).toBeVisible(); await expect(workCell).toHaveAttribute("data-operation-source", "run_thread"); + await expect(workCell).toHaveAttribute("data-approval-presentation-owner", "dialog"); await expect(workCell).not.toContainText("Task waiting for approval"); await expect(workCell).not.toContainText("Waiting for approval to continue."); - await expect(workCell.getByLabel("Active work stream")).toContainText("Waiting for approval"); - await expect(workCell.getByLabel("Active work stream")).toContainText("Approve shell command"); + await expect(workCell).toContainText("Waiting for approval"); + await expect(workCell).toContainText("Run command: echo inspect"); + await expect(workCell).not.toContainText("Approve shell command"); + await expect(workCell.locator(".conversation-operation-shell-body")).toHaveCount(0); + await expect(workCell.locator(".conversation-work-cell-evidence-summary")).toHaveCount(0); await expect(workCell.getByRole("button", { name: "Open workflow evidence" })).toHaveCount(0); await expect(workCell.getByRole("button", { name: "Approve", exact: true })).toHaveCount(0); await expect(workCell.getByRole("button", { name: "Deny", exact: true })).toHaveCount(0); const composer = page.getByLabel("Channel composer"); - const overlay = composer.getByLabel("Composer approval overlay"); - await expect(overlay).toBeVisible(); - await expect(overlay).toContainText("Runtime approval needed"); - await expect(overlay).toContainText("Highest risk: medium"); - await expect(overlay).toContainText("Approve shell command"); + const composerHeight = await composer.evaluate((element) => element.getBoundingClientRect().height); + const dialog = page.getByRole("dialog", { name: "Allow this action?" }); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Run command: echo inspect"); + await expect(dialog).not.toContainText("medium"); + await expect(dialog).not.toContainText("Approve shell command"); + await expect(composer.locator(".approval-inline-strip")).toHaveCount(0); await expect(workCell.getByRole("button", { name: "Approve", exact: true })).toHaveCount(0); + await maybeCaptureViewportScreenshot(page, "runtime-approval-dialog-clean-background.png"); - await overlay.getByRole("button", { name: "Approve" }).click(); - await expect(composer.getByLabel("Composer approval overlay")).toHaveCount(0); + await dialog.getByRole("button", { name: "Allow" }).click(); + await expect(dialog).toHaveCount(0); await expect(composer).not.toContainText("Approve shell command"); + expect(await composer.evaluate((element) => element.getBoundingClientRect().height)).toBe(composerHeight); expect(state.approvals.find((approval) => isRecord(approval) && approval.id === "approval_1")?.status).toBe("approved"); expect(state.approvalPolicy.toolDecisions.bash).toBe("ask"); expect(state.approvalPolicy.toolDecisions.write).toBe("ask"); @@ -11436,7 +11492,7 @@ test("retained channel activity does not flash a loading notice during backgroun ))).toBe(0); }); -test("composer approval overlay groups multiple local approvals and excludes review queue actions", async ({ page }) => { +test("runtime approval dialog queues multiple local approvals and excludes review queue actions", async ({ page }) => { await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); @@ -11474,23 +11530,23 @@ test("composer approval overlay groups multiple local approvals and excludes rev await page.goto("/"); await openEngineeringChannel(page); - const overlay = page.getByLabel("Channel composer").getByLabel("Composer approval overlay"); - await expect(overlay).toContainText("Runtime approvals needed"); - await expect(overlay).toContainText("Highest risk: high"); - const primaryApproval = overlay.locator(".inline-approval-item.primary"); - await expect(primaryApproval).toContainText("Approve file write"); - await expect(primaryApproval).not.toContainText("Approve shell command"); - await expect(overlay).not.toContainText("Approve external send"); - - await overlay.getByText("Show other approvals").click(); - await expect(overlay).toContainText("Approve shell command"); + const dialog = page.getByRole("dialog", { name: "Allow this action?" }); + await expect(dialog).toContainText("Create file:"); + await expect(dialog).not.toContainText("Approve file write"); + await expect(dialog).not.toContainText("pending approvals"); + await expect(dialog).not.toContainText("Approve shell command"); + await expect(dialog).not.toContainText("Approve external send"); + await dialog.getByRole("button", { name: "Deny" }).click(); + await expect(dialog).toContainText("Run command:"); + await expect(dialog).not.toContainText("Approve shell command"); + await expect(dialog).not.toContainText("pending approvals"); }); test("channel activity derives task run approval transition and workflow events", async ({ page }) => { await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channelMessages = [ { id: "msg_timeline", @@ -11557,9 +11613,10 @@ test("channel activity derives task run approval transition and workflow events" title: "Other channel approval leak", description: "echo design", payload: {}, - status: "pending", + status: "expired", createdAt: "2026-05-25T00:03:00.000Z", - expiresAt: "2099-01-01T00:01:00.000Z", + expiresAt: "2000-01-01T00:01:00.000Z", + resolvedAt: "2000-01-01T00:01:00.000Z", }); state.channelWorkflows = [ { @@ -11622,7 +11679,7 @@ test("channel activity derives task run approval transition and workflow events" const messageBubble = page.locator(".channel-message-row").filter({ hasText: "Timeline source message" }); const taskEventBubble = page.locator(".message-bubble").filter({ hasText: "Task event" }).filter({ hasText: "Inspect workspace" }); const runEventBubble = page.locator(".message-bubble").filter({ hasText: "Run event" }).filter({ hasText: "Inspect workspace" }); - const approvalEventBubble = page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "Approve shell command" }); + const approvalEventBubble = page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "Approval expired" }); const stateTransitionBubble = page.locator(".message-bubble").filter({ hasText: "State change" }).filter({ hasText: "task_failed" }); const workflowStepBubble = page.locator(".message-bubble").filter({ hasText: "Workflow step" }).filter({ hasText: "Blocked timeline step" }); const activityDetails = page.getByLabel("Activity details"); @@ -11665,7 +11722,7 @@ test("channel activity derives task run approval transition and workflow events" await openChannelChatTools(page); await expect(taskEventBubble).toContainText("failed"); await expect(runEventBubble).toContainText("failed"); - await expect(approvalEventBubble).toContainText("pending"); + await expect(approvalEventBubble).toContainText("expired"); await expect(stateTransitionBubble).toBeVisible(); await expect(workflowStepBubble).toContainText("Runtime policy is waiting."); await expect(taskEventBubble).toHaveClass(/message-bubble-work/); @@ -11687,7 +11744,7 @@ test("channel activity derives task run approval transition and workflow events" await openDetailsFrom(taskEventBubble, "Inspect workspace"); await openDetailsFrom(runEventBubble, "Run run_failed"); - await openDetailsFrom(approvalEventBubble, "Approve shell command"); + await openDetailsFrom(approvalEventBubble, "Approval expired"); await openDetailsFrom(stateTransitionBubble, "task_failed"); await openDetailsFrom(workflowStepBubble, "Blocked timeline step"); @@ -11705,7 +11762,7 @@ test("channel activity derives task run approval transition and workflow events" await approvalEventBubble.getByRole("button", { name: "Details" }).click(); await clickActivityDetailsAction("Open approval"); - await expectApprovalDetailOverlay(page, "Approve shell command"); + await expect(page.locator(".conversation-detail-overlay").filter({ hasText: "Approval details" })).toContainText("No pending approval"); await page.getByLabel("Ling Stack navigation").getByRole("button", { name: "Engineering" }).click(); await openChannelChatTools(page); @@ -11740,8 +11797,8 @@ test("channel activity derives task run approval transition and workflow events" await expect(savedViewSelect).toContainText("All (7)"); await expect(savedViewSelect).toContainText("Needs attention (5)"); await expect(savedViewSelect).toContainText("Blocked (1)"); - await expect(savedViewSelect).toContainText("Failed (3)"); - await expect(savedViewSelect).toContainText("Pending approvals (1)"); + await expect(savedViewSelect).toContainText("Failed (4)"); + await expect(savedViewSelect).toContainText("Pending approvals (0)"); await expect(savedViewSelect).toContainText("Failed work (2)"); await expect(savedViewSelect).toContainText("Runtime (3)"); await expect(activityFilters.getByLabel("Activity categories")).toHaveCount(0); @@ -11772,7 +11829,7 @@ test("channel activity derives task run approval transition and workflow events" await expect(page.locator(".message-bubble").filter({ hasText: "Workflow step" }).filter({ hasText: "Blocked timeline step" })).toBeVisible(); await expect(activityFeed.getByText("Timeline source message")).toHaveCount(0); await selectSavedView("Pending approvals"); - await expect(page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "Approve shell command" })).toBeVisible(); + await expect(page.locator(".message-bubble").filter({ hasText: "Approval event" })).toHaveCount(0); await expect(page.locator(".message-bubble").filter({ hasText: "Task event" }).filter({ hasText: "Inspect workspace" })).toHaveCount(0); await selectSavedView("Failed work"); await expect(page.locator(".message-bubble").filter({ hasText: "Task event" }).filter({ hasText: "Inspect workspace" })).toBeVisible(); @@ -11797,8 +11854,8 @@ test("channel activity derives task run approval transition and workflow events" await searchInput.fill("run_failed"); await expect(page.locator(".message-bubble").filter({ hasText: "Run event" }).filter({ hasText: "Inspect workspace" })).toBeVisible(); - await searchInput.fill("Approve shell command"); - await expect(page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "pending" })).toBeVisible(); + await searchInput.fill("Approval expired"); + await expect(page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "expired" })).toBeVisible(); await searchInput.fill("Wait for runtime recovery"); await expect(page.locator(".message-bubble").filter({ hasText: "Workflow step" }).filter({ hasText: "Blocked timeline step" })).toBeVisible(); @@ -11823,9 +11880,9 @@ test("channel activity derives task run approval transition and workflow events" await expect(activityFeed.getByText("Timeline source message")).toHaveCount(0); await attentionChip("Failed").click(); await expect(page.locator(".message-bubble").filter({ hasText: "Task event" }).filter({ hasText: "failed" })).toBeVisible(); - await expect(page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "pending" })).toHaveCount(0); + await expect(page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "expired" })).toBeVisible(); await attentionChip("Pending").click(); - await expect(page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "pending" })).toBeVisible(); + await expect(page.locator(".message-bubble").filter({ hasText: "Approval event" })).toHaveCount(0); await expect(page.locator(".message-bubble").filter({ hasText: "Task event" }).filter({ hasText: "failed" })).toHaveCount(0); await activityFilters.getByRole("button", { name: /Clear/ }).click(); @@ -11896,8 +11953,8 @@ test("channel activity derives task run approval transition and workflow events" await page.getByLabel("Ling Stack navigation").getByRole("button", { name: "Engineering" }).click(); await openChannelChatTools(page); - await page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "Approve shell command" }).getByRole("button", { name: "Open approval" }).click(); - await expectApprovalDetailOverlay(page, "Approve shell command"); + await page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "Approval expired" }).getByRole("button", { name: "Open approval" }).click(); + await expectApprovalDetailOverlay(page, "Approval expired"); await page.getByLabel("Ling Stack navigation").getByRole("button", { name: "Engineering" }).click(); await openChannelChatTools(page); @@ -11909,7 +11966,7 @@ test("channel activity triage actions create follow-up records and preserve loca await page.addInitScript(() => { localStorage.setItem("ling-stack.locale", "en-US"); }); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.channelMessages = [ { id: "msg_triage", @@ -11978,7 +12035,7 @@ test("channel activity triage actions create follow-up records and preserve loca const activityDetails = page.getByLabel("Activity details"); const runEventBubble = page.locator(".message-bubble").filter({ hasText: "Run event" }).filter({ hasText: "Inspect workspace" }); const workflowStepBubble = page.locator(".message-bubble").filter({ hasText: "Workflow step" }).filter({ hasText: "Blocked triage step" }); - const approvalEventBubble = page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "Approve shell command" }); + const approvalEventBubble = page.locator(".message-bubble").filter({ hasText: "Approval event" }).filter({ hasText: "Approval expired" }); const messageBubble = page.locator(".message-bubble").filter({ hasText: "Timeline source message for triage" }); const openTriageActions = async () => { const triageSection = activityDetails.locator(".activity-triage-section"); @@ -12028,10 +12085,10 @@ test("channel activity triage actions create follow-up records and preserve loca await approvalEventBubble.getByRole("button", { name: "Details" }).click(); await openTriageActions(); await activityDetails.getByRole("button", { name: "Record decision" }).click(); - await activityDetails.getByLabel("Triage title").fill("Decision: approval stays pending"); - await activityDetails.getByLabel("Decision text").fill("Keep the approval pending until the user reviews the shell command."); + await activityDetails.getByLabel("Triage title").fill("Decision: review the expired approval"); + await activityDetails.getByLabel("Decision text").fill("Review why the shell command approval expired before retrying."); await activityDetails.getByRole("button", { name: "Run triage action" }).click(); - await expect.poll(() => state.channelDecisions.some((decision) => isRecord(decision) && decision.title === "Decision: approval stays pending")).toBe(true); + await expect.poll(() => state.channelDecisions.some((decision) => isRecord(decision) && decision.title === "Decision: review the expired approval")).toBe(true); await activityDetails.getByRole("button", { name: "Review created source" }).click(); await expectConversationReviewSurface(page); @@ -12185,3 +12242,15 @@ async function maybeCaptureConversationScreenshots( }); } } + +async function maybeCaptureViewportScreenshot(page: Page, name: string): Promise { + const outputDir = process.env.LING_R32_SCREENSHOTS_DIR; + if (!outputDir) { + return; + } + await mkdir(outputDir, { recursive: true }); + await page.screenshot({ + animations: "disabled", + path: join(outputDir, name), + }); +} diff --git a/apps/web/e2e/ui2-approvals.spec.ts b/apps/web/e2e/ui2-approvals.spec.ts index 35a11bd74..1f4629677 100644 --- a/apps/web/e2e/ui2-approvals.spec.ts +++ b/apps/web/e2e/ui2-approvals.spec.ts @@ -11,7 +11,7 @@ import { createPermissionApproval, installApprovalsFaults, } from "./support/approvals-fixture"; -import { createBaseState, type MockState } from "./support/mock-state"; +import { createBaseState, createChatProvider, type MockState } from "./support/mock-state"; import { openPendingInbox } from "./support/pending-primary-navigation"; const evidenceDirectory = fileURLToPath(new URL( @@ -294,38 +294,66 @@ test("A pending external approval becomes executable without executing implicitl await runtime.assertClean(); }); -test("Inline Strip deduplicates, sorts highest risk, folds secondary items, and never duplicates Inbox ownership", async ({ page }) => { +test("Runtime dialog queue deduplicates, sorts highest risk, and preserves composer geometry", async ({ page }) => { await prepareSurface(page, "en-US", "light", 1440, 960); const state = createBaseState({ approvalStatus: "pending" }); + state.providers = [createChatProvider({ + lastCheck: { + ok: true, + code: "ready", + message: "Provider is reachable.", + checkedAt: "2026-05-22T00:00:00.000Z", + }, + })]; const runId = state.runs[0]?.id; const critical = createPermissionApproval({ id: "inline_critical", runId, channelId: undefined, risk: "critical", title: "Critical runtime permission" }); const medium = createApproval({ id: "inline_medium", runId, actionType: "bash", risk: "medium", title: "Medium shell approval", payload: { type: "bash", command: "pnpm test" } }); const low = createApproval({ id: "inline_low", runId, actionType: "edit", risk: "low", title: "Low file approval", payload: { type: "file_edit", path: "README.md" } }); - state.approvals = [medium, critical, low, { ...critical }]; + state.approvals = [medium, critical, low, critical]; await mockDaemon(page, state); + let failMediumDeny = true; + await page.route(/\/approvals\/inline_medium\/deny(?:\?.*)?$/u, async (route) => { + if (failMediumDeny) { + failMediumDeny = false; + await route.fulfill({ status: 503, json: { error: "mock_approval_failed", message: "Synthetic approval decision failure" } }); + return; + } + await route.fallback(); + }); const runtime = watchRuntime(page); await page.goto("/"); await page.getByLabel(/Ling Stack navigation|Ling Stack 导航/).getByRole("button", { name: "Engineering" }).first().click(); - const strip = page.locator(".approval-inline-strip"); - await expect(strip).toBeVisible(); - await expect(strip).toContainText("Critical runtime permission"); - await expect(strip).toContainText("3 pending approvals"); - await expect(strip.locator('[role="status"]')).toHaveCount(1); - await strip.getByRole("button", { name: /Show other approvals/ }).click(); - await expect(strip.locator(".approval-inline-item")).toHaveCount(3); - await assertInlineComposerGeometry(page, strip); - await captureEvidence(page, "1440x960-ling-light-inline-approval-strip.png"); - - const lowItem = strip.locator(".approval-inline-item").filter({ hasText: "Low file approval" }); - await lowItem.getByRole("button", { name: "Deny" }).click(); + const composer = page.locator(".channel-composer").first(); + const dialog = page.getByRole("dialog", { name: "Allow this action?" }); + await expect(dialog).toBeVisible(); + await expect(composer.locator(".channel-active-status-strip")).toHaveCount(0); + const composerHeight = await composer.evaluate((element) => element.getBoundingClientRect().height); + await expect(dialog).toContainText("Grant runtime permission: core_tool: Release repair needs one additional editor tool."); + await expect(dialog).not.toContainText("Critical runtime permission"); + await expect(dialog).not.toContainText("pending approvals"); + await expect(dialog).not.toContainText("Medium shell approval"); + await expect(dialog.getByLabel("Decision reason")).toHaveCount(0); + await expect(dialog.locator("details")).toHaveCount(0); + await expect(dialog).not.toContainText(/critical|risk|Run ID|workspace/iu); + await expect(page.locator(".approval-inline-strip")).toHaveCount(0); + await captureEvidence(page, "1440x960-ling-light-runtime-approval-dialog.png"); + + await dialog.getByRole("button", { name: "Allow" }).click(); + await expect.poll(() => state.approvals.find((item) => item.id === "inline_critical")?.status).toBe("approved"); + await expect(dialog).toContainText("Run command: pnpm test"); + await expect(dialog).not.toContainText("Medium shell approval"); + await dialog.getByRole("button", { name: "Deny" }).click(); + await expect(dialog).toContainText("Synthetic approval decision failure"); + await expect(dialog.getByRole("button", { name: "Retry" })).toBeVisible(); + await dialog.getByRole("button", { name: "Retry" }).click(); + await expect.poll(() => state.approvals.find((item) => item.id === "inline_medium")?.status).toBe("denied"); + await expect(dialog).toContainText("Modify file: README.md"); + await expect(dialog).not.toContainText("Low file approval"); + await dialog.getByRole("button", { name: "Deny" }).click(); await expect.poll(() => state.approvals.find((item) => item.id === "inline_low")?.status).toBe("denied"); - await expect(strip).toContainText("2 pending approvals"); - - await openPendingInbox(page); - const surface = page.locator("[data-approvals-ui2-page]"); - await expect(surface.getByText("Critical runtime permission")).toHaveCount(0); - await expect(surface.getByText("Medium shell approval")).toHaveCount(0); - await runtime.assertClean(); + await expect(dialog).toHaveCount(0); + expect(await composer.evaluate((element) => element.getBoundingClientRect().height)).toBe(composerHeight); + await runtime.assertClean([503]); }); test("Expired long payload stays folded and contained at 1280 Dark Chinese", async ({ page }) => { @@ -411,23 +439,6 @@ async function assertNoHorizontalOverflow(page: Page, surface: Locator): Promise } } -async function assertInlineComposerGeometry(page: Page, strip: Locator): Promise { - const composer = page.locator(".channel-composer").first(); - const geometry = await page.evaluate(({ stripSelector, composerSelector }) => { - const stripElement = document.querySelector(stripSelector); - const composerElement = document.querySelector(composerSelector); - if (!stripElement || !composerElement) return undefined; - const stripRect = stripElement.getBoundingClientRect(); - const composerRect = composerElement.getBoundingClientRect(); - return { stripBottom: stripRect.bottom, composerBottom: composerRect.bottom, stripWidth: stripRect.width, composerWidth: composerRect.width }; - }, { stripSelector: ".approval-inline-strip", composerSelector: ".channel-composer" }); - expect(geometry).toBeTruthy(); - expect(geometry!.stripBottom).toBeLessThanOrEqual(geometry!.composerBottom + 1); - expect(geometry!.stripWidth).toBeLessThanOrEqual(geometry!.composerWidth + 1); - await expect(strip).toBeVisible(); - await expect(composer).toBeVisible(); -} - async function captureEvidence(page: Page, filename: string): Promise { if (process.env.LING_CAPTURE_UI2_EVIDENCE !== "1") return; await mkdir(evidenceDirectory, { recursive: true }); diff --git a/apps/web/e2e/ui2-channel-proposal-input-migration.spec.ts b/apps/web/e2e/ui2-channel-proposal-input-migration.spec.ts index 744e728a2..0060bcdbc 100644 --- a/apps/web/e2e/ui2-channel-proposal-input-migration.spec.ts +++ b/apps/web/e2e/ui2-channel-proposal-input-migration.spec.ts @@ -42,19 +42,51 @@ test("editable Task confirmation uses shared fields and preserves dynamic propos await prepareSurface(page, "light", 1440, 960); const runtimeErrors = collectRuntimeErrors(page); const state = createProposalState(); + state.channelMessages.push(...Array.from({ length: 60 }, (_, index) => ({ + id: `msg_ui2_virtual_${index}`, + workspaceId: "workspace_default", + channelId: "channel_engineering", + kind: "message", + intent: "discuss", + authorType: "local-user", + authorId: "ling-local", + body: `Virtualized proposal context ${index + 1}`, + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + status: "ready", + createdAt: `2026-05-25T00:${String(19 + Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`, + updatedAt: `2026-05-25T00:${String(19 + Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`, + }))); await mockDaemon(page, state); await page.goto("/"); await openEngineeringChannel(page); - const proposal = page.getByLabel("Task confirmation"); + const proposalDialog = page.getByRole("dialog", { name: "Task confirmation" }); + await expect(proposalDialog).toBeVisible(); + const initialDecision = proposalDialog.locator(".channel-execution-proposal-decision"); + await expect(initialDecision).toContainText("Refine channel proposal inputs"); + await expect(initialDecision).toContainText("Deleted teammate"); + await expect(initialDecision).toContainText("Read files"); + await expect(initialDecision).toContainText("Modify files"); + await expect(initialDecision.locator(".channel-proposal-details, .channel-proposal-collaboration")).toHaveCount(0); + const feed = page.getByLabel("Channel message feed"); + await feed.evaluate((element) => element.scrollTo({ top: 0 })); + await expect(proposalDialog).toBeVisible(); + await proposalDialog.getByRole("button", { name: "Close" }).click(); + await expect(proposalDialog).toHaveCount(0); + const summary = page.getByLabel("Task proposal summary").filter({ hasText: "Refine channel proposal inputs" }); + await expect.poll(async () => { + await feed.evaluate((element) => element.scrollTo({ top: 0 })); + return summary.count(); + }).toBe(1); + await summary.getByRole("button", { name: "View task" }).click(); + const proposal = proposalDialog.locator(".channel-execution-proposal-decision"); await expect(proposal).toBeVisible(); - const collaborationSummary = proposal.locator(".channel-proposal-collaboration summary"); - await expect(collaborationSummary).toBeVisible(); - const transition = await readTransitionStyle(collaborationSummary); - expect(transition.properties).toEqual(["border-color", "background-color", "box-shadow"]); - expect(transition.durations.every((duration) => duration === "0.12s")).toBe(true); - expect(transition.timingFunctions.every((timingFunction) => timingFunction === "cubic-bezier(0.2, 0, 0, 1)")).toBe(true); + await expect(proposal).toContainText("Required permissions"); + await expect(proposal).not.toContainText("Requirement summary"); + await expect(proposal).not.toContainText("User clarifications"); await proposal.getByRole("button", { name: "Edit" }).click(); const editor = proposal.locator(".channel-proposal-edit-form"); await expect(editor).toBeVisible(); @@ -163,10 +195,18 @@ test("proposal validation and cancel reset stay contained in dark reduced-motion await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "ling-dark"); expect(await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true); - const proposal = page.getByLabel("Task confirmation"); - const collaborationSummary = proposal.locator(".channel-proposal-collaboration summary"); - await expect(collaborationSummary).toBeVisible(); - expect((await readTransitionStyle(collaborationSummary)).durations.every((duration) => duration === "0s")).toBe(true); + const proposalDialog = page.getByRole("dialog", { name: "Task confirmation" }); + if (!await proposalDialog.isVisible().catch(() => false)) { + const summary = page.getByLabel("Task proposal summary").filter({ hasText: "Refine channel proposal inputs" }); + await expect(summary).toBeVisible(); + if (!await proposalDialog.isVisible().catch(() => false)) { + await summary.getByRole("button", { name: "View task" }).click(); + } + } + await expect(proposalDialog).toBeVisible(); + const proposal = proposalDialog.locator(".channel-execution-proposal-decision"); + await expect(proposal).toContainText("Refine channel proposal inputs"); + await expect(proposal.locator(".channel-proposal-details, .channel-proposal-collaboration")).toHaveCount(0); await proposal.getByRole("button", { name: "Edit" }).click(); const editor = proposal.locator(".channel-proposal-edit-form"); const title = editor.getByLabel("Title"); @@ -386,21 +426,6 @@ async function expectDocumentContained(page: Page): Promise { expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1); } -async function readTransitionStyle(locator: Locator): Promise<{ - readonly durations: string[]; - readonly properties: string[]; - readonly timingFunctions: string[]; -}> { - return locator.evaluate((element) => { - const styles = getComputedStyle(element); - return { - durations: styles.transitionDuration.split(", "), - properties: styles.transitionProperty.split(", "), - timingFunctions: styles.transitionTimingFunction.match(/(?:cubic-bezier|steps)\([^)]*\)|[^,\s]+/gu) ?? [], - }; - }); -} - async function prepareSurface( page: Page, theme: "light" | "dark", diff --git a/apps/web/e2e/ui2-channel-settings-overlay-dock.spec.ts b/apps/web/e2e/ui2-channel-settings-overlay-dock.spec.ts index 8db5ea718..63be61931 100644 --- a/apps/web/e2e/ui2-channel-settings-overlay-dock.spec.ts +++ b/apps/web/e2e/ui2-channel-settings-overlay-dock.spec.ts @@ -14,7 +14,7 @@ const evidenceDirectory = fileURLToPath(new URL( test("channel settings uses shared tabs and stable section boundaries", async ({ page }) => { await prepareSurface(page, "light", 1440, 960); const runtimeErrors = collectRuntimeErrors(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); await mockDaemon(page, state); await page.goto("/"); await openChannelSettings(page); @@ -126,7 +126,7 @@ test("channel settings changes only the Ling Agent model and keeps teammate mode test("channel rename and governance use the shared overlay layer", async ({ page }) => { await prepareSurface(page, "light", 1440, 960); const runtimeErrors = collectRuntimeErrors(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.governanceNotifications = [governanceNotification()]; await mockDaemon(page, state); await page.goto("/"); @@ -160,7 +160,7 @@ test("channel rename and governance use the shared overlay layer", async ({ page test("channel dock uses shared controls across launcher and deep pages", async ({ page }) => { await prepareSurface(page, "light", 1440, 960); const runtimeErrors = collectRuntimeErrors(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); await mockDaemon(page, state); await page.goto("/"); await openEngineeringChannel(page); @@ -195,7 +195,7 @@ test("channel dock uses shared controls across launcher and deep pages", async ( expect(runtimeErrors).toEqual([]); }); -test("channel settings and dock remain contained at 1280 in dark mode", async ({ page }) => { +test("channel approval dialog, settings, and dock remain contained at 1280 in dark mode", async ({ page }) => { await prepareSurface(page, "dark", 1280, 800); const runtimeErrors = collectRuntimeErrors(page); const state = createBaseState({ approvalStatus: "pending" }); @@ -215,6 +215,17 @@ test("channel settings and dock remain contained at 1280 in dark mode", async ({ await route.fallback(); }); await page.goto("/"); + await openEngineeringChannel(page); + const approvalDialog = page.getByRole("dialog", { name: "Allow this action?" }); + await expect(approvalDialog).toBeVisible(); + await expect(approvalDialog).toHaveAttribute("data-size", "sm"); + await expect(approvalDialog).toHaveAttribute("data-dismissible", "false"); + await expectNoDocumentOverflow(page); + await captureEvidence(page, "1280x800-ling-dark-runtime-approval-dialog.png"); + await page.keyboard.press("Escape"); + await expect(approvalDialog).toBeVisible(); + await approvalDialog.getByRole("button", { name: "Deny" }).click(); + await expect(approvalDialog).toHaveCount(0); await openChannelSettings(page); const settings = page.getByRole("region", { name: "Channel settings" }); @@ -229,38 +240,6 @@ test("channel settings and dock remain contained at 1280 in dark mode", async ({ await captureEvidence(page, "1280x800-ling-dark-channel-settings-members-empty.png"); await page.getByRole("button", { name: "Back to channel" }).click(); - const approvalOverlay = page.getByRole("region", { name: "Composer approval overlay" }); - await expect(approvalOverlay).toBeVisible(); - const approvalTheme = await approvalOverlay.evaluate((element) => { - const rootStyle = getComputedStyle(document.documentElement); - const resolveColor = (value: string): string => { - const probe = document.createElement("span"); - probe.style.color = value; - document.body.append(probe); - const resolved = getComputedStyle(probe).color; - probe.remove(); - return resolved; - }; - const resolveBackground = (value: string): string => { - const probe = document.createElement("span"); - probe.style.backgroundColor = value; - document.body.append(probe); - const resolved = getComputedStyle(probe).backgroundColor; - probe.remove(); - return resolved; - }; - return { - background: getComputedStyle(element).backgroundColor, - expectedBackground: resolveBackground(rootStyle.getPropertyValue("--ling-status-warning-bg")), - title: getComputedStyle(element.querySelector(".inline-approval-strip-head strong")!).color, - expectedTitle: resolveColor(rootStyle.getPropertyValue("--ling-status-warning")), - itemBackground: getComputedStyle(element.querySelector(".inline-approval-item")!).backgroundColor, - expectedItemBackground: resolveBackground(rootStyle.getPropertyValue("--ling-bg-elevated")), - }; - }); - expect(approvalTheme.background).toBe(approvalTheme.expectedBackground); - expect(approvalTheme.title).toBe(approvalTheme.expectedTitle); - expect(approvalTheme.itemBackground).toBe(approvalTheme.expectedItemBackground); await page.getByRole("button", { name: "More channel views" }).click(); await page.getByRole("menuitem", { name: "Rename channel" }).click(); const rename = page.getByRole("dialog", { name: "Rename channel" }); @@ -305,7 +284,7 @@ test("channel settings and dock remain contained at 1280 in dark mode", async ({ test("channel settings exposes loading, empty, failure, and scoped busy states", async ({ page }) => { await prepareSurface(page, "light", 1440, 960); const runtimeErrors = collectRuntimeErrors(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.aiTeammates = []; state.aiCouncils = []; await mockDaemon(page, state); @@ -365,17 +344,17 @@ test("channel settings exposes loading, empty, failure, and scoped busy states", expect(runtimeErrors).toEqual([]); }); -test("channel inline approval detail remains an in-feed inspector", async ({ page }) => { +test("resolved channel approval detail remains an in-feed inspector", async ({ page }) => { await prepareSurface(page, "light", 1440, 960); const runtimeErrors = collectRuntimeErrors(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); await mockDaemon(page, state); await page.goto("/"); await openChannelChatTools(page); const approvalBubble = page.locator(".message-bubble") .filter({ hasText: "Approval event" }) - .filter({ hasText: "Approve shell command" }); + .filter({ hasText: "Approval expired" }); await approvalBubble.getByRole("button", { name: "Open approval" }).click(); const inspector = page.locator(".conversation-detail-overlay"); await expect(inspector).toBeVisible(); @@ -391,10 +370,10 @@ test("channel inline approval detail remains an in-feed inspector", async ({ pag test("channel dock reaches loaded, failure, and detail states across every launcher", async ({ page }) => { await prepareSurface(page, "light", 1440, 960); const runtimeErrors = collectRuntimeErrors(page); - const state = createBaseState({ approvalStatus: "pending" }); + const state = createBaseState({ approvalStatus: "expired" }); state.threads = [channelRootThread()]; state.proposedPlans = [channelPlanFixture()]; - state.channelMessages = [channelEvidenceMessage()]; + state.channelMessages = [channelFileReferenceMessage(), channelEvidenceMessage()]; await mockDaemon(page, state); let workspaceChangesRequestCount = 0; await page.route(/\/workspace\/changes(?:\?.*)?$/u, async (route) => { @@ -547,11 +526,6 @@ function channelEvidenceMessage(): MockState["channelMessages"][number] { mentions: [], attachmentIds: [], linkPreviewIds: [], - fileRefs: [{ - path: "apps/web/src/features/workspace/right-dock/WorkspaceRightDock.tsx", - label: "WorkspaceRightDock.tsx", - line: 1, - }], status: "ready", webEvidence: { status: "available", @@ -574,6 +548,31 @@ function channelEvidenceMessage(): MockState["channelMessages"][number] { }; } +function channelFileReferenceMessage(): MockState["channelMessages"][number] { + return { + id: "message_ui2_channel_file_reference", + workspaceId: "workspace_default", + channelId: "channel_engineering", + threadId: "thread_channel_main_channel_engineering", + kind: "message", + intent: "discuss", + authorType: "local-user", + authorId: "tian", + body: "Review the referenced dock implementation.", + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [{ + path: "apps/web/src/features/workspace/right-dock/WorkspaceRightDock.tsx", + label: "WorkspaceRightDock.tsx", + line: 1, + }], + status: "ready", + createdAt: "2026-07-19T03:02:30.000Z", + updatedAt: "2026-07-19T03:02:30.000Z", + }; +} + function workspaceChangesFixture(): WorkspaceChangesResponse { const longLine = `export const channelRightDockEvidence = "${"reachable-state-".repeat(24)}";`; return { diff --git a/apps/web/e2e/ui2-channel-workflow-status-presentation.spec.ts b/apps/web/e2e/ui2-channel-workflow-status-presentation.spec.ts index 67ab09702..441479efd 100644 --- a/apps/web/e2e/ui2-channel-workflow-status-presentation.spec.ts +++ b/apps/web/e2e/ui2-channel-workflow-status-presentation.spec.ts @@ -10,7 +10,7 @@ import { channelWorkflowStepStatusPresentation, } from "../src/features/workspace/channel-workflow-status-presentation.js"; import { mockDaemon } from "./support/mock-daemon"; -import { createBaseState } from "./support/mock-state"; +import { createBaseState, createMockTeammate } from "./support/mock-state"; import { openChannelContextPanel, openEngineeringChannel } from "./support/test-helpers"; const workflowCardPath = fileURLToPath(new URL("../src/features/workspace/ChannelWorkflowProposalCards.tsx", import.meta.url)); @@ -82,7 +82,7 @@ test("workflow, step, and run status presentation is exhaustive and the legacy o expect(styles).toContain('@import "./styles/channel-workflow-status.css";'); }); -test("Light workflow surfaces show product labels, actions, linked states, and contained long content", async ({ page }, testInfo) => { +test("Light workflow surfaces show compact proposal decisions and contained workflow status content", async ({ page }, testInfo) => { await prepareSurface(page, "light", 1440, 960); const runtimeErrors = collectRuntimeErrors(page); const state = createWorkflowStatusState(); @@ -90,7 +90,7 @@ test("Light workflow surfaces show product labels, actions, linked states, and c await page.goto("/"); await openEngineeringChannel(page); - await verifyProposalSurface(page); + await verifyProposalSurface(page, testInfo, "1440x960-ling-light"); const runtimePanel = await openRuntimePanel(page); await verifyWorkflowQueue(runtimePanel); await runtimePanel.locator(".workflow-queue-card").scrollIntoViewIfNeeded(); @@ -109,7 +109,7 @@ test("Dark workflow surfaces retain semantic tones and containment", async ({ pa await openEngineeringChannel(page); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); - await verifyProposalSurface(page); + await verifyProposalSurface(page, testInfo, "1280x800-ling-dark"); const runtimePanel = await openRuntimePanel(page); await verifyWorkflowQueue(runtimePanel); const queue = runtimePanel.locator(".workflow-queue-card"); @@ -123,6 +123,16 @@ test("Dark workflow surfaces retain semantic tones and containment", async ({ pa function createWorkflowStatusState(): ReturnType { const state = createBaseState({ approvalStatus: "expired" }); + state.aiTeammates = [ + createMockTeammate({ + id: "ling_agent", + displayName: "Ling Agent", + templateId: "ling_agent", + roleKey: "custom", + systemRole: "ling_agent", + }), + createMockTeammate({ id: "teammate_builtin_engineer", displayName: "Engineer" }), + ]; const timestamp = "2026-07-26T08:00:00.000Z"; const workflowId = "workflow_status_matrix"; const linkedRunId = "run_workflow_status"; @@ -168,7 +178,7 @@ function createWorkflowStatusState(): ReturnType { proposedByTeammateId: "ling_agent", title: longText("Editable workflow proposal"), prompt: longText("Preserve proposal editing, permissions, evidence, and all existing actions"), - assigneeTeammateId: "ling_agent", + assigneeTeammateId: "teammate_builtin_engineer", acceptanceCriteria: [longText("No workflow status or action may overflow the channel proposal surface")], collaborationSteps: [{ teammateId: "ling_agent", action: "reviewed", summary: longText("Reviewed workflow state semantics and evidence") }], requiredTools: ["read", "bash"], @@ -220,29 +230,52 @@ function channelMessage(id: string, body: string): Record { }; } -async function verifyProposalSurface(page: Page): Promise { - const editable = page.getByLabel("Task confirmation").filter({ hasText: "Editable workflow proposal" }); +async function verifyProposalSurface(page: Page, testInfo: TestInfo, evidencePrefix: string): Promise { + const dialog = page.getByRole("dialog", { name: "Task confirmation" }); + if (await dialog.isVisible().catch(() => false)) { + await dialog.getByRole("button", { name: "Close" }).click(); + } + const editableSummary = page.getByLabel("Task proposal summary").filter({ hasText: "Editable workflow proposal" }); + const editableSummaryRow = editableSummary.locator(".."); + await expect(editableSummaryRow).toHaveClass(/channel-execution-proposal-summary-row/u); + const assigneeAvatar = editableSummaryRow.locator(".channel-execution-proposal-summary-avatar"); + await expect(assigneeAvatar).toHaveCount(1); + await expect(assigneeAvatar).toHaveAttribute("aria-label", "Engineer"); + await expect(assigneeAvatar.locator(".teammate-avatar-image.compact")).toHaveAttribute("src", /avatars\/teammates\/engineer\.png$/u); + await expect(editableSummary).toContainText("Engineer · Pending confirmation"); + await capture(page, testInfo, `${evidencePrefix}-task-summary.png`); + await editableSummary.getByRole("button", { name: "View task" }).click(); + const editable = dialog.locator(".channel-execution-proposal-decision"); await expect(editable).toBeVisible(); - await expect(editable.getByLabel("Task permission confirmation")).toBeVisible(); + await expect(editable).toContainText("Task"); + await expect(editable).toContainText("Agent"); + await expect(editable).toContainText("Engineer"); + await expect(editable).toContainText("Required permissions"); + await expect(editable).toContainText("Read files"); + await expect(editable).toContainText("Run commands"); + await expect(editable).not.toContainText("Permission-sensitive actions still require confirmation"); + await expect(editable.locator(".channel-proposal-details, .channel-proposal-collaboration")).toHaveCount(0); await expect(editable.getByRole("button", { name: "Authorize and start task" })).toBeVisible(); await expect(editable.getByRole("button", { name: "Edit", exact: true })).toBeVisible(); - const evidence = editable.locator(".channel-proposal-collaboration"); - await expect(evidence).toBeVisible(); - await evidence.locator("summary").click(); - await expect(evidence.locator("li")).toBeVisible(); + await capture(page, testInfo, `${evidencePrefix}-task-confirmation.png`); await editable.getByRole("button", { name: "Edit", exact: true }).click(); await expect(editable.getByLabel("Scope")).toBeVisible(); await editable.getByRole("button", { name: "Cancel", exact: true }).click(); + await dialog.getByRole("button", { name: "Close" }).click(); - const linked = page.getByLabel("Task confirmation").filter({ hasText: "Linked status proposal" }); + const linkedSummary = page.getByLabel("Task proposal summary").filter({ hasText: "Linked status proposal" }); + await expect(linkedSummary).toContainText("Ling Agent · Execution failed"); + await linkedSummary.getByRole("button", { name: "View task" }).click(); + const linked = dialog.locator(".channel-execution-proposal-decision"); await expect(linked).toBeVisible(); - const linkedStatuses = linked.locator(".channel-workflow-linked-statuses"); - await expect(linkedStatuses.locator('[data-status-kind="linked-workflow"]')).toContainText("Waiting for approval"); - await expect(linkedStatuses.locator('[data-status-kind="linked-workflow"]')).toHaveAttribute("data-tone", "warning"); - await expect(linkedStatuses.locator('[data-status-kind="run"]')).toContainText("Failed"); - await expect(linkedStatuses.locator('[data-status-kind="run"]')).toHaveAttribute("data-tone", "danger"); + await expect(linked).toContainText("Linked status proposal"); + await expect(linked).toContainText("Ling Agent"); + await expect(linked).toContainText("Read files"); + await expect(linked).toContainText("The linked run stopped after a provider validation error"); + await expect(linked.locator(".channel-workflow-linked-statuses")).toHaveCount(0); await expect(linked.getByRole("button", { name: "Open run" })).toBeVisible(); await expect(linked).not.toContainText("waiting_for_approval"); + await dialog.getByRole("button", { name: "Close" }).click(); } async function openRuntimePanel(page: Page): Promise { @@ -309,7 +342,7 @@ function collectRuntimeErrors(page: Page): string[] { async function expectContained(page: Page, panel: Locator): Promise { const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(overflow).toBeLessThanOrEqual(1); - for (const locator of [panel.locator(".workflow-queue-card"), panel.locator(".workflow-step-card"), page.locator(".channel-execution-proposal"), page.locator(".channel-workflow-linked-statuses")]) { + for (const locator of [panel.locator(".workflow-queue-card"), panel.locator(".workflow-step-card"), page.locator(".channel-execution-proposal-summary"), page.locator(".channel-execution-proposal-decision")]) { const count = await locator.count(); for (let index = 0; index < count; index += 1) { expect(await locator.nth(index).evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1); diff --git a/apps/web/e2e/ui2-sidebar-foundation-layout.spec.ts b/apps/web/e2e/ui2-sidebar-foundation-layout.spec.ts index 29075e737..48c4705e1 100644 --- a/apps/web/e2e/ui2-sidebar-foundation-layout.spec.ts +++ b/apps/web/e2e/ui2-sidebar-foundation-layout.spec.ts @@ -63,10 +63,22 @@ test("Sidebar foundation keeps one bounded expanded navigation surface", async ( const longChannel = navigation.getByRole("button", { name: LONG_CHANNEL_NAME }); await longChannel.scrollIntoViewIfNeeded(); const longLabel = longChannel.locator('[data-ling-button-slot="label"]'); - await expect(longLabel).toHaveCSS("overflow", "hidden"); - await expect(longLabel).toHaveCSS("text-overflow", "ellipsis"); - await expect(longLabel).toHaveCSS("white-space", "nowrap"); - expect(await longLabel.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true); + const longShell = longChannel.locator("xpath=ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' sidebar-row-shell ')][1]"); + const titleViewport = longChannel.locator(".sidebar-row-title-viewport"); + const titleTrack = longChannel.locator(".sidebar-row-title-track"); + await expect(longLabel).toHaveCSS("overflow", "visible"); + await expect(longLabel).toHaveCSS("text-overflow", "clip"); + await expect(titleViewport).toHaveCSS("overflow", "hidden"); + await expect(titleViewport).toHaveCSS("white-space", "nowrap"); + await expect(longShell).toHaveAttribute("data-sidebar-row-overflow", "true"); + expect(await titleViewport.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true); + expect(await longShell.evaluate((element) => getComputedStyle(element).gridTemplateColumns.trim().split(/\s+/u))).toHaveLength(1); + await longShell.hover(); + await expect.poll(() => titleTrack.evaluate((element) => getComputedStyle(element).transform)) + .not.toBe("none"); + await page.mouse.move(1000, 700); + await expect.poll(() => titleTrack.evaluate((element) => getComputedStyle(element).transform), { timeout: 5_000 }) + .toMatch(/^(none|matrix\(1, 0, 0, 1, 0, 0\))$/u); await expectInside(navigation, longChannel); await expectNoHorizontalOverflow(page.locator("html")); await captureEvidence(page, "1440x960-ling-light-expanded-long-row.png"); @@ -85,6 +97,12 @@ test("Sidebar branch draft and section menu stay contained in dark reduced-motio await page.goto("/"); const navigation = page.getByLabel("Ling Stack navigation"); + const longTeammate = navigation.getByRole("button", { name: "A teammate identity that is intentionally longer than the available Sidebar row" }); + await longTeammate.scrollIntoViewIfNeeded(); + const longTeammateShell = longTeammate.locator("xpath=ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' sidebar-row-shell ')][1]"); + await expect(longTeammateShell).toHaveAttribute("data-sidebar-row-overflow", "true"); + await longTeammateShell.hover(); + await expect(longTeammate.locator(".sidebar-row-title-track")).toHaveCSS("transform", "none"); const engineeringGroup = navigation.locator(".sidebar-channel-group").filter({ hasText: "Engineering" }); await engineeringGroup.locator(".sidebar-row-shell").hover(); await engineeringGroup.getByRole("button", { name: "Create branch" }).click(); @@ -185,7 +203,7 @@ test("Sidebar foundation preserves shell geometry across the 900 and 901px bound expect(runtimeErrors).toEqual([]); }); -const LONG_CHANNEL_NAME = "Architecture coordination channel with a deliberately long identity that must ellipsize"; +const LONG_CHANNEL_NAME = "Architecture coordination channel with a deliberately long identity that must fade smoothly"; function createDenseSidebarState(): MockState { const state = createBaseState({ approvalStatus: "expired" }); diff --git a/apps/web/e2e/ui2-sidebar-object-rows.spec.ts b/apps/web/e2e/ui2-sidebar-object-rows.spec.ts index be316a094..e5b784bd6 100644 --- a/apps/web/e2e/ui2-sidebar-object-rows.spec.ts +++ b/apps/web/e2e/ui2-sidebar-object-rows.spec.ts @@ -11,13 +11,40 @@ const evidenceDirectory = fileURLToPath(new URL( test("UI 2.0 Sidebar object rows keep compact identity and actions in the expanded rail", async ({ page }) => { const runtimeErrors = collectRuntimeErrors(page); + let workspaceChangesRequests = 0; await prepareSurface(page, "light", 1440, 960); await mockDaemon(page, createSidebarState()); + await page.route("http://127.0.0.1:17891/workspace/changes?**", async (route) => { + workspaceChangesRequests += 1; + await route.fulfill({ + json: { + cwd: "/tmp/ling-stack/apps/web", + state: "dirty", + repoRoot: "/tmp/ling-stack", + branchName: "codex/sidebar-approval-dialogs", + additions: 12, + deletions: 4, + files: [], + totalFileCount: 2, + truncated: false, + checkedAt: "2026-08-26T00:00:00.000Z", + }, + }); + }); await page.goto("/"); const navigation = page.getByLabel("Ling Stack navigation"); await navigation.getByRole("button", { name: "Engineering" }).click(); await expect(page.getByLabel("Channel composer")).toBeVisible(); + const channel = navigation.getByRole("button", { name: "Engineering", exact: true }); + await page.mouse.move(720, 320); + await channel.hover(); + const channelPreview = page.locator('[role="tooltip"]:visible').filter({ hasText: "Engineering" }); + await expect(channelPreview).not.toContainText("Channel"); + await expect(channelPreview).not.toContainText("ling-stack"); + await expect(channelPreview).not.toContainText("codex/sidebar-approval-dialogs"); + await captureEvidence(page, "1440x960-ling-light-channel-preview.png"); + const teammate = navigation.getByRole("button", { name: "Designer", exact: true }); const avatar = teammate.locator(".teammate-avatar-image.compact"); const label = teammate.locator('[data-ling-button-slot="label"]'); @@ -29,6 +56,11 @@ test("UI 2.0 Sidebar object rows keep compact identity and actions in the expand expect(avatarBox!.width).toBeLessThanOrEqual(24); expect(labelBox!.x - (avatarBox!.x + avatarBox!.width)).toBeGreaterThanOrEqual(4); await teammate.hover(); + const teammatePreview = page.locator('[role="tooltip"]:visible').filter({ hasText: "Designer" }); + await expect(teammatePreview).not.toContainText("Private chat"); + await expect(teammatePreview).toContainText("ling-stack"); + await expect(teammatePreview).toContainText("codex/sidebar-approval-dialogs"); + await captureEvidence(page, "1440x960-ling-light-private-chat-preview.png"); const teammateRow = navigation.locator('.sidebar-row-shell:has(.sidebar-row-main[aria-label="Designer"])'); const archive = teammateRow.getByRole("button", { name: "Archive teammate" }); await expect(archive).toBeVisible(); @@ -37,10 +69,23 @@ test("UI 2.0 Sidebar object rows keep compact identity and actions in the expand const recent = navigation.locator(".sidebar-recent-row").filter({ hasText: "A deliberately long conversation title", }); + const recentShell = recent.locator("xpath=ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' sidebar-recent-row-shell ')][1]"); await expect(recent).toContainText("A deliberately long conversation title"); - await expect(recent.locator(".sidebar-recent-copy")).toHaveCSS("min-width", "0px"); + await expect(recentShell).toHaveAttribute("data-sidebar-row-overflow", "true"); + await expect(recent.locator(".sidebar-row-title-viewport")).toHaveCSS("overflow", "hidden"); + await expect(recent.locator(".sidebar-row-title-track")).not.toHaveCSS("text-overflow", "ellipsis"); + expect(await recentShell.evaluate((element) => getComputedStyle(element).gridTemplateColumns.trim().split(/\s+/u))).toHaveLength(1); + const recentTools = recentShell.locator(".sidebar-row-tools"); + await expect(recentTools).toHaveCSS("position", "absolute"); + await recent.hover(); + await expect(recentTools).toHaveCSS("opacity", "1"); + await expect(page.getByRole("tooltip")).toContainText("A deliberately long conversation title that must remain inside the Sidebar row"); + await expect(page.getByRole("tooltip")).not.toContainText("Conversation"); + await expect(page.getByRole("tooltip")).toContainText("ling-stack"); + await expect(page.getByRole("tooltip")).toContainText("codex/sidebar-approval-dialogs"); + expect(workspaceChangesRequests).toBe(1); await expectNoHorizontalOverflow(navigation); - await captureEvidence(page, "1440x960-ling-light-expanded.png"); + await captureEvidence(page, "1440x960-ling-light-conversation-preview.png"); expect(runtimeErrors).toEqual([]); }); @@ -65,9 +110,9 @@ test("UI 2.0 Sidebar compact rail keeps object rows behind a section launcher in await expect(navigation.getByRole("button", { name: "Archive channel" })).toBeHidden(); await channel.hover(); - const tooltip = page.getByRole("tooltip"); + const tooltip = page.locator('[role="tooltip"]:visible').filter({ hasText: "Channels" }); + await expect(tooltip).toBeVisible(); await expect(tooltip).toHaveText("Channels"); - await expect.poll(async () => Math.round((await tooltip.boundingBox())?.x ?? 0)).toBeGreaterThan(45); await channel.click(); await expect.poll(async () => Math.round((await navigation.boundingBox())?.width ?? 0)).toBe(240); await expect(navigation.getByRole("button", { name: "Engineering", exact: true })).toBeVisible(); @@ -100,6 +145,36 @@ function createSidebarState(): MockState { metadata: { schemaVersion: 1 as const, lastActivityAt: timestamp }, }; }); + const sourceRun = state.runs[0]!; + state.channels[0] = { + ...state.channels[0]!, + boundPath: cwd, + }; + const conversationRuns = state.threads.map((thread, index) => ({ + ...sourceRun, + id: `run_sidebar_${index}`, + taskId: `task_sidebar_${index}`, + channelId: undefined, + teammateId: undefined, + threadId: thread.id, + cwd: "/tmp/ling-stack/apps/web", + eventLogPath: `/tmp/ling-stack/.ling-stack/runs/run_sidebar_${index}/events.jsonl`, + updatedAt: thread.updatedAt, + })); + state.runs = [ + ...conversationRuns, + { + ...sourceRun, + id: "run_sidebar_private_chat", + taskId: "task_sidebar_private_chat", + channelId: undefined, + teammateId: state.aiTeammates[0]!.id, + threadId: "thread_sidebar_private_chat", + cwd: "/tmp/ling-stack/apps/web", + eventLogPath: "/tmp/ling-stack/.ling-stack/runs/run_sidebar_private_chat/events.jsonl", + updatedAt: "2026-08-26T00:11:00.000Z", + }, + ]; return state; } diff --git a/apps/web/src/app/AppShell.tsx b/apps/web/src/app/AppShell.tsx index f5a5907c1..045dc0e0c 100644 --- a/apps/web/src/app/AppShell.tsx +++ b/apps/web/src/app/AppShell.tsx @@ -173,6 +173,8 @@ import { useWorkspaceSettingsController } from "./use-workspace-settings-control import { useDesktopNavigationIntents } from "./use-desktop-navigation-intents.js"; import { useAppNarrowViewport } from "./use-app-viewport-state.js"; import { useShellErrorFeedback } from "./use-shell-error-feedback.js"; +import { buildSidebarObjectContextSeeds } from "./sidebar-object-context.js"; +import { useSidebarObjectContexts } from "./use-sidebar-object-contexts.js"; const builtinSkillPack = getBuiltinSkillPack(GSTACK_DEFAULT_SKILL_PACK_ID); const builtinTeammateTemplates = builtinSkillPack?.teammateTemplates ?? []; @@ -1038,6 +1040,15 @@ export function AppShell(): React.JSX.Element { } return items; }, [run, runs]); + const sidebarRuns = useMemo(() => [...runById.values()], [runById]); + const sidebarObjectContextSeeds = useMemo(() => buildSidebarObjectContextSeeds({ + channels, + conversationThreads, + runs: sidebarRuns, + teammates: aiTeammates, + workspaceCwd: form.cwd.trim(), + }), [aiTeammates, channels, conversationThreads, form.cwd, sidebarRuns]); + const sidebarObjectContexts = useSidebarObjectContexts(sidebarObjectContextSeeds); const sidebarChannelNotifications = useMemo( () => buildSidebarChannelNotifications({ approvals, @@ -2371,6 +2382,7 @@ export function AppShell(): React.JSX.Element { stale={daemonHealthy === false} channelNotifications={sidebarChannelNotifications} teammateNotifications={sidebarTeammateNotifications} + objectContexts={sidebarObjectContexts} recentConversations={recentConversations} recentConversationsInitialLoading={conversationThreadsLoading && recentConversations.length === 0} recentConversationActionError={conversationSidebarError} @@ -2531,6 +2543,7 @@ export function AppShell(): React.JSX.Element { externalActionDetails={externalActionDetails} approvalsLoading={approvalsLoading} resolvingApprovals={resolvingApprovals} + approvalErrors={approvalErrors} resourceLeases={resourceLeases} runtimeDiagnostics={runtimeDiagnostics} workUnits={workUnits} @@ -2569,8 +2582,8 @@ export function AppShell(): React.JSX.Element { onCreateTask={saveTask} onCreateTaskFromSlashCommand={createTaskFromSlashCommand} onSendMessage={sendChannelMessage} - onConfirmExecutionProposal={(proposal) => void confirmChannelExecutionProposal(proposal)} - onCancelExecutionProposal={(proposal) => void cancelChannelExecutionProposal(proposal)} + onConfirmExecutionProposal={confirmChannelExecutionProposal} + onCancelExecutionProposal={cancelChannelExecutionProposal} onUpdateExecutionProposal={markChannelExecutionProposal} onUploadAttachments={(files) => uploadChannelFiles(files)} onCreateLinkPreview={(url) => addChannelLinkPreview(url)} @@ -2684,6 +2697,7 @@ export function AppShell(): React.JSX.Element { } : undefined} inlineApprovalViews={normalConversationInlineApprovalViews} resolvingApprovals={resolvingApprovals} + approvalErrors={approvalErrors} conversationIdentity={conversationIdentity} defaultProviderLabel={defaultConversationProviderLabel} defaultProviderPresetId={defaultConversationProviderPresetId} @@ -2695,16 +2709,6 @@ export function AppShell(): React.JSX.Element { activeRun={activeConversationRun} stopping={Boolean(activeConversationRun && cancellingRunId === activeConversationRun.id)} onOpenRun={(runId) => void openRunFromWorkspace(runId)} - onOpenApproval={(approvalId) => { - const approval = selectedConversationDetail?.approvals.find((candidate) => candidate.id === approvalId) - ?? approvals.find((candidate) => candidate.id === approvalId); - if (approval) { - upsertApprovalRecord(approval); - } - setSelectedApprovalId(approvalId); - void refreshApprovals(form.cwd.trim()); - openPlainView("approvals"); - }} onOpenModelWorkbench={() => openSettingsWorkspaceTool("providers")} fullAccessConfirmed={fullAccessConfirmation.isConfirmed(normalConversationConfirmationScope)} onConfirmFullAccess={() => fullAccessConfirmation.request(normalConversationConfirmationScope)} @@ -2827,6 +2831,9 @@ export function AppShell(): React.JSX.Element { workspacePolicyLoading={approvalPolicyLoading} workspacePolicyError={settingsDomain.error?.scope === "load" ? settingsDomain.error.message : undefined} onRefreshWorkspacePolicy={() => void refreshApprovalPolicy()} + approvalErrors={approvalErrors} + resolvingApprovals={resolvingApprovals} + onResolveApproval={resolveApproval} settingsOpenRequest={teammateSettingsOpenRequest} conversationRefreshToken={teammateConversationRefreshToken} onSettingsOpenRequestConsumed={(requestId) => { diff --git a/apps/web/src/app/sidebar-object-context.ts b/apps/web/src/app/sidebar-object-context.ts new file mode 100644 index 000000000..57ff13384 --- /dev/null +++ b/apps/web/src/app/sidebar-object-context.ts @@ -0,0 +1,138 @@ +import type { + AiTeammate, + Channel, + LingThread, + RunSession, + WorkspaceChangesResponse, +} from "../types.js"; + +export type SidebarObjectKind = "channel" | "private_chat" | "conversation"; + +export interface SidebarObjectGitContext { + readonly repositoryLabel: string; + readonly branchName: string; +} + +export interface SidebarObjectContextSeed { + readonly key: string; + readonly activityAt: string; + readonly cwd?: string; +} + +export interface SidebarObjectContext { + readonly activityAt: string; + readonly git?: SidebarObjectGitContext; +} + +export function sidebarObjectContextKey(kind: SidebarObjectKind, id: string): string { + return `${kind}:${id}`; +} + +export function buildSidebarObjectContextSeeds(input: { + readonly channels: readonly Channel[]; + readonly conversationThreads: readonly LingThread[]; + readonly runs: readonly RunSession[]; + readonly teammates: readonly AiTeammate[]; + readonly workspaceCwd: string; + readonly recentConversationLimit?: number; +}): readonly SidebarObjectContextSeed[] { + const latestRunByThreadId = latestRunMap(input.runs, (run) => run.threadId); + const latestRunByChannelId = latestRunMap(input.runs, (run) => run.channelId); + const latestDirectRunByTeammateId = latestRunMap( + input.runs.filter((run) => !run.channelId && Boolean(run.threadId)), + (run) => run.teammateId, + ); + const recentConversationLimit = input.recentConversationLimit ?? 5; + + return [ + ...input.channels.map((channel): SidebarObjectContextSeed => { + const latestRun = latestRunByChannelId.get(channel.id); + const cwd = firstNonEmpty( + distinctPath(channel.workspaceBinding?.worktreePath, input.workspaceCwd), + distinctPath(channel.workspaceBinding?.executionPath, input.workspaceCwd), + distinctPath(latestRun?.cwd, input.workspaceCwd), + distinctPath(channel.boundPath, input.workspaceCwd), + ); + return { + key: sidebarObjectContextKey("channel", channel.id), + activityAt: latestActivityAt(channel.updatedAt, latestRun?.updatedAt), + ...(cwd ? { cwd } : {}), + }; + }), + ...input.teammates.map((teammate): SidebarObjectContextSeed => { + const latestRun = latestDirectRunByTeammateId.get(teammate.id); + const cwd = distinctPath(latestRun?.cwd, input.workspaceCwd); + return { + key: sidebarObjectContextKey("private_chat", teammate.id), + activityAt: latestActivityAt(teammate.updatedAt, latestRun?.updatedAt), + ...(cwd ? { cwd } : {}), + }; + }), + ...[...input.conversationThreads] + .sort((left, right) => threadActivityAt(right).localeCompare(threadActivityAt(left))) + .slice(0, recentConversationLimit) + .map((thread): SidebarObjectContextSeed => { + const latestRun = latestRunByThreadId.get(thread.id); + const cwd = distinctPath(latestRun?.cwd, input.workspaceCwd); + return { + key: sidebarObjectContextKey("conversation", thread.id), + activityAt: latestActivityAt(threadActivityAt(thread), latestRun?.updatedAt), + ...(cwd ? { cwd } : {}), + }; + }), + ]; +} + +export function projectSidebarObjectGitContext( + changes: WorkspaceChangesResponse, +): SidebarObjectGitContext | undefined { + if (changes.state !== "clean" && changes.state !== "dirty") return undefined; + const repositoryLabel = pathBaseName(changes.repoRoot?.trim() || changes.cwd.trim()); + const branchName = changes.branchName?.trim() || changes.headSha?.trim().slice(0, 8); + if (!repositoryLabel || !branchName) return undefined; + return { repositoryLabel, branchName }; +} + +function latestRunMap( + runs: readonly RunSession[], + readKey: (run: RunSession) => string | undefined, +): ReadonlyMap { + const latest = new Map(); + for (const run of runs) { + const key = readKey(run)?.trim(); + if (!key) continue; + const current = latest.get(key); + if (!current || run.updatedAt.localeCompare(current.updatedAt) > 0) { + latest.set(key, run); + } + } + return latest; +} + +function latestActivityAt(fallback: string, candidate?: string): string { + return candidate && candidate.localeCompare(fallback) > 0 ? candidate : fallback; +} + +function firstNonEmpty(...values: readonly (string | undefined)[]): string | undefined { + return values.map((value) => value?.trim()).find(Boolean); +} + +function distinctPath(candidate: string | undefined, excluded: string): string | undefined { + const normalizedCandidate = normalizePath(candidate); + if (!normalizedCandidate || normalizedCandidate === normalizePath(excluded)) return undefined; + return candidate?.trim(); +} + +function normalizePath(path: string | undefined): string { + const trimmed = path?.trim() ?? ""; + if (trimmed === "/" || /^[A-Za-z]:[\\/]$/u.test(trimmed)) return trimmed; + return trimmed.replace(/[\\/]+$/u, ""); +} + +function threadActivityAt(thread: LingThread): string { + return thread.metadata?.lastActivityAt ?? thread.updatedAt; +} + +function pathBaseName(path: string): string { + return path.split(/[\\/]/u).filter(Boolean).at(-1) ?? ""; +} diff --git a/apps/web/src/app/use-sidebar-object-contexts.ts b/apps/web/src/app/use-sidebar-object-contexts.ts new file mode 100644 index 000000000..6b83d20a8 --- /dev/null +++ b/apps/web/src/app/use-sidebar-object-contexts.ts @@ -0,0 +1,57 @@ +import { useEffect, useMemo, useState } from "react"; +import { getWorkspaceChanges } from "../api/workspace-changes.js"; +import { + projectSidebarObjectGitContext, + type SidebarObjectContext, + type SidebarObjectContextSeed, + type SidebarObjectGitContext, +} from "./sidebar-object-context.js"; + +export function useSidebarObjectContexts( + seeds: readonly SidebarObjectContextSeed[], +): Readonly> { + const uniqueCwds = useMemo( + () => [...new Set(seeds.flatMap((seed) => seed.cwd ? [seed.cwd] : []))], + [seeds], + ); + const cwdKey = uniqueCwds.join("\u0001"); + const [gitByCwd, setGitByCwd] = useState>>({}); + + useEffect(() => { + let cancelled = false; + if (uniqueCwds.length === 0) { + setGitByCwd({}); + return () => { + cancelled = true; + }; + } + + void Promise.all(uniqueCwds.map(async (cwd) => { + try { + const changes = await getWorkspaceChanges(cwd); + return [cwd, projectSidebarObjectGitContext(changes)] as const; + } catch { + return [cwd, undefined] as const; + } + })).then((entries) => { + if (cancelled) return; + const next: Record = {}; + for (const [cwd, git] of entries) { + if (git) next[cwd] = git; + } + setGitByCwd(next); + }); + + return () => { + cancelled = true; + }; + }, [cwdKey]); + + return useMemo(() => Object.fromEntries(seeds.map((seed) => [ + seed.key, + { + activityAt: seed.activityAt, + ...(seed.cwd && gitByCwd[seed.cwd] ? { git: gitByCwd[seed.cwd] } : {}), + } satisfies SidebarObjectContext, + ])), [gitByCwd, seeds]); +} diff --git a/apps/web/src/features/approvals/RuntimeApprovalDialogQueue.tsx b/apps/web/src/features/approvals/RuntimeApprovalDialogQueue.tsx new file mode 100644 index 000000000..6db052814 --- /dev/null +++ b/apps/web/src/features/approvals/RuntimeApprovalDialogQueue.tsx @@ -0,0 +1,126 @@ +import { CheckCircle2, CircleAlert, ShieldCheck } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { useI18n } from "../../i18n.js"; +import type { InlineApprovalView } from "../../lib/inline-approvals.js"; +import { runtimeApprovalActionSentence } from "../../lib/runtime-approval-presentation.js"; +import { + Button, + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + InlineFeedback, +} from "../../shared/ui/index.js"; +import type { ApprovalResolution } from "./types.js"; +import { useApprovalDecisionController } from "./useApprovalDecisionController.js"; + +export function RuntimeApprovalDialogQueue({ + approvals, + approvalErrors, + resolvingApprovals, + onResolve, +}: { + readonly approvals: readonly InlineApprovalView[]; + readonly approvalErrors: Readonly>; + readonly resolvingApprovals: Readonly>; + readonly onResolve: (approvalId: string, decision: ApprovalResolution, reason?: string) => void; +}): React.JSX.Element | null { + const { t } = useI18n(); + const returnFocusRef = useRef(null); + const denyButtonRef = useRef(null); + const previousApprovalIdRef = useRef(""); + const uniqueApprovals = [...new Map(approvals.map((item) => [item.approval.id, item])).values()]; + const current = uniqueApprovals[0]; + const decision = useApprovalDecisionController({ + approval: current?.approval ?? emptyApproval, + resolvingDecision: current ? resolvingApprovals[current.approval.id] : undefined, + errorMessage: current ? approvalErrors[current.approval.id] : undefined, + reasonPolicy: "none", + onResolve, + }); + + useEffect(() => { + const currentId = current?.approval.id ?? ""; + if (!currentId && previousApprovalIdRef.current) { + const focusTarget = returnFocusRef.current; + globalThis.requestAnimationFrame(() => { + if (focusTarget?.isConnected) focusTarget.focus(); + }); + returnFocusRef.current = null; + } + previousApprovalIdRef.current = currentId; + }, [current?.approval.id]); + + useEffect(() => { + if (!current) return undefined; + const frame = globalThis.requestAnimationFrame(() => denyButtonRef.current?.focus()); + return () => globalThis.cancelAnimationFrame(frame); + }, [current?.approval.id]); + + if (!current) return null; + const approval = current.approval; + const resolvingDecision = resolvingApprovals[approval.id]; + const actionSentence = runtimeApprovalActionSentence(approval, current.summary, t); + + return ( + undefined}> + { + event.preventDefault(); + if (!returnFocusRef.current && document.activeElement instanceof HTMLElement) { + returnFocusRef.current = document.activeElement; + } + denyButtonRef.current?.focus(); + }} + onCloseAutoFocus={(event) => event.preventDefault()} + > + + + ); +} + +const emptyApproval = { + id: "runtime-approval-placeholder", + actionType: "runtime_permission_request", + title: "", + description: "", + risk: "low", + status: "pending", + payload: {}, + createdAt: "", +} as const; diff --git a/apps/web/src/features/approvals/useApprovalDecisionController.ts b/apps/web/src/features/approvals/useApprovalDecisionController.ts index 80b03c106..a6e07f9a8 100644 --- a/apps/web/src/features/approvals/useApprovalDecisionController.ts +++ b/apps/web/src/features/approvals/useApprovalDecisionController.ts @@ -7,15 +7,17 @@ export function useApprovalDecisionController(input: { readonly approval: ApprovalRequest; readonly resolvingDecision?: ApprovalResolution; readonly errorMessage?: string; + readonly reasonPolicy?: "risk_based" | "none"; readonly onResolve: (approvalId: string, decision: ApprovalResolution, reason?: string) => void; }) { const [decisionReason, setDecisionReason] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); const [reasonError, setReasonError] = useState(""); const [attempted, setAttempted] = useState(false); + const [attemptedDecision, setAttemptedDecision] = useState(); const status = effectiveApprovalStatus(input.approval); const highRisk = input.approval.risk === "high" || input.approval.risk === "critical"; - const reasonRequired = highRisk && input.approval.actionType !== "media_cost"; + const reasonRequired = input.reasonPolicy !== "none" && highRisk && input.approval.actionType !== "media_cost"; const isResolving = Boolean(input.resolvingDecision); const trimmedReason = decisionReason.trim(); const mutationError = useMemo( @@ -28,6 +30,7 @@ export function useApprovalDecisionController(input: { setDialogOpen(false); setReasonError(""); setAttempted(false); + setAttemptedDecision(undefined); }, [input.approval.id]); useEffect(() => { @@ -35,6 +38,7 @@ export function useApprovalDecisionController(input: { setDialogOpen(false); setReasonError(""); setAttempted(false); + setAttemptedDecision(undefined); }, [status]); function updateReason(value: string): void { @@ -60,6 +64,7 @@ export function useApprovalDecisionController(input: { } setReasonError(""); setAttempted(true); + setAttemptedDecision(decision); input.onResolve(input.approval.id, decision, trimmedReason || undefined); } @@ -71,6 +76,7 @@ export function useApprovalDecisionController(input: { return { decisionReason, + attemptedDecision, dialogOpen, reasonError, mutationError, diff --git a/apps/web/src/features/navigation/SidebarNavigation.tsx b/apps/web/src/features/navigation/SidebarNavigation.tsx index d42487c43..14a42cba2 100644 --- a/apps/web/src/features/navigation/SidebarNavigation.tsx +++ b/apps/web/src/features/navigation/SidebarNavigation.tsx @@ -16,6 +16,7 @@ import { Settings, } from "lucide-react"; import { useI18n } from "../../i18n.js"; +import { sidebarObjectContextKey, type SidebarObjectContext } from "../../app/sidebar-object-context.js"; import { Icon, IconButton, InlineFeedback, Input, StatusIndicator } from "../../shared/ui/index.js"; import { channelSidebarTarget, @@ -27,6 +28,7 @@ import type { AiTeammate, Channel, ChannelBranch } from "../../types.js"; import { TeammateAvatarView } from "../teammates/TeammateAvatarView.js"; import { SidebarPrimaryNavigation } from "./SidebarPrimaryNavigation.js"; import { channelAvatarMeta } from "./sidebar-channel-avatar.js"; +import { SidebarObjectPreview } from "./SidebarObjectPreview.js"; import { SidebarObjectRow } from "./SidebarObjectRow.js"; import { SidebarArchiveAllConversationsDialog } from "./SidebarArchiveAllConversationsDialog.js"; import { SidebarSection, SidebarSectionSkeleton, type SidebarSectionId } from "./SidebarSection.js"; @@ -62,6 +64,7 @@ export function SidebarNavigation({ stale = false, channelNotifications = {}, teammateNotifications = {}, + objectContexts = {}, recentConversations = [], recentConversationsInitialLoading = false, recentConversationActionError = "", @@ -97,6 +100,7 @@ export function SidebarNavigation({ readonly stale?: boolean; readonly channelNotifications?: Readonly>; readonly teammateNotifications?: Readonly>; + readonly objectContexts?: Readonly>; readonly recentConversations?: readonly SidebarRecentConversation[]; readonly recentConversationsInitialLoading?: boolean; readonly recentConversationActionError?: string; @@ -257,15 +261,22 @@ export function SidebarNavigation({ const channelAvatar = channelAvatarMeta(channel); return (
- )) )} diff --git a/apps/web/src/features/navigation/SidebarObjectPreview.tsx b/apps/web/src/features/navigation/SidebarObjectPreview.tsx new file mode 100644 index 000000000..0afd2e9f9 --- /dev/null +++ b/apps/web/src/features/navigation/SidebarObjectPreview.tsx @@ -0,0 +1,25 @@ +import { Folder, GitBranch } from "lucide-react"; +import { formatDateTime } from "../../lib/formatting.js"; +import type { SidebarObjectContext } from "../../app/sidebar-object-context.js"; +import { Icon } from "../../shared/ui/index.js"; + +export function SidebarObjectPreview({ + context, + title, +}: { + readonly context: SidebarObjectContext; + readonly title: string; +}): React.JSX.Element { + return ( + + {title} + {context.git ? ( + <> + {context.git.repositoryLabel} + {context.git.branchName} + + ) : null} + + + ); +} diff --git a/apps/web/src/features/navigation/SidebarObjectRow.tsx b/apps/web/src/features/navigation/SidebarObjectRow.tsx index f27826c0c..72579c092 100644 --- a/apps/web/src/features/navigation/SidebarObjectRow.tsx +++ b/apps/web/src/features/navigation/SidebarObjectRow.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { Button } from "../../shared/ui/index.js"; import { SidebarTooltip } from "./SidebarTooltip.js"; @@ -8,6 +8,7 @@ export function SidebarObjectRow({ ariaLabel, buttonClassName, children, + description, icon, onSelect, selected = false, @@ -15,28 +16,62 @@ export function SidebarObjectRow({ status, statusId, tooltip = ariaLabel, + tooltipClassName, }: { readonly actions?: ReactNode; readonly actionsLabel?: string; readonly ariaLabel: string; readonly buttonClassName?: string; readonly children: ReactNode; + readonly description?: ReactNode; readonly icon: ReactNode; readonly onSelect: () => void; readonly selected?: boolean; readonly shellClassName?: string; readonly status?: ReactNode; readonly statusId?: string; - readonly tooltip?: string; + readonly tooltip?: ReactNode; + readonly tooltipClassName?: string; }): React.JSX.Element { + const titleViewportRef = useRef(null); + const titleTrackRef = useRef(null); + const [titleOverflow, setTitleOverflow] = useState(0); const shellClasses = ["sidebar-row-shell", shellClassName, selected ? "selected" : ""] .filter(Boolean) .join(" "); const buttonClasses = ["sidebar-row-main", buttonClassName].filter(Boolean).join(" "); + useEffect(() => { + const viewport = titleViewportRef.current; + const track = titleTrackRef.current; + if (!viewport || !track) return undefined; + + const measure = (): void => { + const nextOverflow = Math.max(0, Math.ceil(track.scrollWidth - viewport.clientWidth)); + setTitleOverflow((current) => current === nextOverflow ? current : nextOverflow); + }; + measure(); + if (typeof ResizeObserver === "undefined") return undefined; + const observer = new ResizeObserver(measure); + observer.observe(viewport); + observer.observe(track); + return () => observer.disconnect(); + }, [children, description]); + + const scrollDuration = Math.min(3200, Math.max(700, titleOverflow * 20)); + return ( -
- +
0 ? "true" : "false"} + data-sidebar-row-status={statusId} + style={{ + "--sidebar-title-scroll-distance": `${titleOverflow}px`, + "--sidebar-title-scroll-duration": `${scrollDuration}ms`, + } as CSSProperties} + > + {status ? {status} : null} diff --git a/apps/web/src/features/navigation/SidebarTooltip.tsx b/apps/web/src/features/navigation/SidebarTooltip.tsx index 98205957c..9139bbec3 100644 --- a/apps/web/src/features/navigation/SidebarTooltip.tsx +++ b/apps/web/src/features/navigation/SidebarTooltip.tsx @@ -1,15 +1,17 @@ -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; import { Tooltip } from "../../shared/ui/index.js"; export function SidebarTooltip({ children, + className, label, }: { readonly children: ReactElement; - readonly label: string; + readonly className?: string; + readonly label: ReactNode; }): React.JSX.Element { return ( - + {children} ); diff --git a/apps/web/src/features/teammates/TeammatePrivateChatFeed.tsx b/apps/web/src/features/teammates/TeammatePrivateChatFeed.tsx index a12653eca..f9f590647 100644 --- a/apps/web/src/features/teammates/TeammatePrivateChatFeed.tsx +++ b/apps/web/src/features/teammates/TeammatePrivateChatFeed.tsx @@ -4,6 +4,10 @@ import { attachmentContentUrl } from "../../api.js"; import { useI18n } from "../../i18n.js"; import { formatDateTime } from "../../lib/formatting.js"; import { attachmentCanPreview, loadPreviewContent } from "../../lib/previews.js"; +import { + buildDialogOwnedApprovalPresentationByRunId, + dialogOwnedApprovalPresentationForWorkCell, +} from "../../lib/runtime-approval-presentation.js"; import { AvatarInitial, UsedMemorySummary, @@ -42,6 +46,7 @@ export type TeammatePrivateChatFeedProps = Pick new Map(runs.map((run) => [run.id, run])), [runs]); + const dialogOwnedApprovalByRunId = useMemo( + () => buildDialogOwnedApprovalPresentationByRunId(dialogOwnedApprovals, t), + [dialogOwnedApprovals, t], + ); const workCellByMessageId = useMemo(() => buildTeammatePrivateWorkCellMap({ messages: visibleMessages, teammateId: teammate.id, @@ -285,6 +295,7 @@ export function TeammatePrivateChatFeed({ void; + readonly approvalErrors: Readonly>; + readonly resolvingApprovals: Readonly>; + readonly onResolveApproval: (approvalId: string, decision: ApprovalResolution, reason?: string) => MaybePromise; readonly settingsOpenRequest?: { readonly teammateId: string; readonly requestId: number; @@ -468,6 +477,13 @@ export function TeammateWorkspace({ .filter((runId): runId is string => Boolean(runId && teammateRunIds.has(runId))))]; }, [runs, teammate.id, visibleMessages]); const visibleDirectRunKey = visibleDirectRunIds.join("|"); + const directApprovalViews = useMemo( + () => buildInlineApprovalViews(currentDirectRuntime?.approvals ?? [], { + activeRunId: runs.find((run) => visibleDirectRunIds.includes(run.id) && run.status === "waiting_for_approval")?.id, + visibleRunIds: visibleDirectRunIds, + }), + [currentDirectRuntime?.approvals, runs, visibleDirectRunKey], + ); const hasActiveVisibleDirectRun = useMemo(() => { const visibleRunIds = new Set(visibleDirectRunIds); return runs.some((run) => visibleRunIds.has(run.id) && ( @@ -547,6 +563,7 @@ export function TeammateWorkspace({ estimateSize: () => 112, overscan: 8, getItemKey: (index) => visibleMessages[index]?.id ?? index, + useFlushSync: false, }); const privateMessageFeedVirtualRows = privateMessageFeedVirtualizer.getVirtualItems(); const directAttachments = useMemo( @@ -1725,6 +1742,7 @@ export function TeammateWorkspace({ runs={runs} executionActions={directExecutionActions} approvals={currentDirectRuntime?.approvals ?? []} + dialogOwnedApprovals={directApprovalViews} terminalReports={terminalReports} dockViewModel={privateDockViewModel} providerRecovered={providerRecovered} @@ -1890,6 +1908,14 @@ export function TeammateWorkspace({ ) : null}
) : null} + { + void Promise.resolve(onResolveApproval(approvalId, decision, reason)).then(() => refreshDirectRuntime()); + }} + />
); } diff --git a/apps/web/src/features/teammates/teammate-private-chat-types.ts b/apps/web/src/features/teammates/teammate-private-chat-types.ts index 9bf091835..2ad0c062d 100644 --- a/apps/web/src/features/teammates/teammate-private-chat-types.ts +++ b/apps/web/src/features/teammates/teammate-private-chat-types.ts @@ -8,6 +8,7 @@ import type { UIEvent, } from "react"; import type { ComposerInputPart, PendingAttachmentItem } from "../../shared/components/index.js"; +import type { InlineApprovalView } from "../../lib/inline-approvals.js"; import type { AiTeammate, ApprovalRequest, @@ -60,6 +61,7 @@ export interface TeammatePrivateChatSurfaceProps { readonly runs: readonly RunSession[]; readonly executionActions: readonly ExecutionAction[]; readonly approvals: readonly ApprovalRequest[]; + readonly dialogOwnedApprovals: readonly InlineApprovalView[]; readonly terminalReports: readonly TerminalReportDto[]; readonly providerRecovered: boolean; readonly modelNotice?: TeammatePrivateModelNotice; diff --git a/apps/web/src/features/workspace/ChannelComposer.tsx b/apps/web/src/features/workspace/ChannelComposer.tsx index 4d0519e9d..2f35a751f 100644 --- a/apps/web/src/features/workspace/ChannelComposer.tsx +++ b/apps/web/src/features/workspace/ChannelComposer.tsx @@ -30,7 +30,6 @@ import { } from "lucide-react"; import type { ComposerSlashCommandOption } from "@ling-stack/core/composer-slash-intents"; import { conversationPermissionPresetMenuOptions } from "../../lib/session-permissions.js"; -import type { InlineApprovalView } from "../../lib/inline-approvals.js"; import { useI18n } from "../../i18n.js"; import { ComposerPendingAssetStrip, @@ -52,8 +51,6 @@ import type { LingThreadGoal, PermissionPreset, } from "../../types.js"; -import { InlineApprovalStrip } from "../approvals/InlineApprovalStrip.js"; -import type { ApprovalResolution } from "../approvals/types.js"; import { ChannelComposerFeedback, type ChannelComposerInlineFeedback, @@ -87,7 +84,6 @@ interface ChannelComposerProps { readonly dropZoneClassName?: string; readonly inputParts: TokenizedComposerInputProps["parts"]; readonly inputRef: Ref; - readonly inlineApprovalViews: readonly InlineApprovalView[]; readonly isCancellingRun: boolean; readonly mentionActiveIndex: number; readonly mentionPicker: ActiveMentionPicker | undefined; @@ -107,7 +103,6 @@ interface ChannelComposerProps { readonly goalExpanded: boolean; readonly goalChipVisible: boolean; readonly channelTeammateIds: ReadonlySet; - readonly resolvingApprovals: Readonly>; readonly runGuidanceError: string; readonly runGuidanceItems: readonly ConversationGuidanceItem[]; readonly runGuidanceLoading: boolean; @@ -134,7 +129,6 @@ interface ChannelComposerProps { readonly onInputPasteFiles: (files: readonly File[]) => void; readonly onInputSelect: (value: string, caret: number) => void; readonly onInsertMentionTrigger: () => void; - readonly onOpenApproval: (approvalId: string) => void; readonly onPermissionPresetChange: (preset: PermissionPreset) => void; readonly onTogglePlanMode: () => void; readonly onRevealPlanModeChip: () => void; @@ -152,7 +146,6 @@ interface ChannelComposerProps { readonly onResizePointerDown: (event: ReactPointerEvent) => void; readonly onResizePointerMove: (event: ReactPointerEvent) => void; readonly onResizePointerUp: (event: ReactPointerEvent) => void; - readonly onResolveApproval: (approvalId: string, decision: ApprovalResolution, reason?: string) => void; readonly onRetrySend: () => void; readonly onSubmit: () => void; } @@ -168,7 +161,6 @@ export function ChannelComposer({ composerSlashTargetLabel, executableSlashCommandId, dropZoneClassName, - inlineApprovalViews, inputParts, inputRef, isCancellingRun, @@ -187,7 +179,6 @@ export function ChannelComposer({ onInputPasteFiles, onInputSelect, onInsertMentionTrigger, - onOpenApproval, onPermissionPresetChange, onTogglePlanMode, onRevealPlanModeChip, @@ -205,7 +196,6 @@ export function ChannelComposer({ onResizePointerDown, onResizePointerMove, onResizePointerUp, - onResolveApproval, onRetrySend, onSubmit, pendingAttachments, @@ -225,7 +215,6 @@ export function ChannelComposer({ guidanceModeActive, replyTargetActive, channelTeammateIds, - resolvingApprovals, runGuidanceError, runGuidanceItems, runGuidanceLoading, @@ -382,14 +371,6 @@ export function ChannelComposer({ ) : null} ) : null} - approvalNode={( - - )} dropZoneClassName={dropZoneClassName} dropZoneStyle={composerDropZoneStyle} onDragOver={onDragOver} diff --git a/apps/web/src/features/workspace/ChannelExecutionProposalDialog.tsx b/apps/web/src/features/workspace/ChannelExecutionProposalDialog.tsx new file mode 100644 index 000000000..8271111cd --- /dev/null +++ b/apps/web/src/features/workspace/ChannelExecutionProposalDialog.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from "react"; +import { useI18n } from "../../i18n.js"; +import { Dialog, DialogBody, DialogContent, DialogHeader } from "../../shared/ui/index.js"; +import type { ChannelExecutionProposal, ChannelMention, UpdateChannelExecutionProposalRequest } from "../../types.js"; +import { ChannelExecutionProposalDecision } from "./ChannelWorkflowProposalCards.js"; + +export function ChannelExecutionProposalDialog({ + proposal, + assigneeLabel, + teammates, + onClose, + onConfirm, + onCancel, + onUpdate, + onOpenRun, + onRepairModelBinding, +}: { + readonly proposal: ChannelExecutionProposal | undefined; + readonly assigneeLabel: string; + readonly teammates: readonly ChannelMention[]; + readonly onClose: () => void; + readonly onConfirm: (proposal: ChannelExecutionProposal) => Promise; + readonly onCancel: (proposal: ChannelExecutionProposal) => Promise; + readonly onUpdate: ( + proposal: ChannelExecutionProposal, + input: UpdateChannelExecutionProposalRequest, + ) => Promise; + readonly onOpenRun: (runId: string) => void; + readonly onRepairModelBinding: (blocker: NonNullable) => void; +}): React.JSX.Element | null { + const { t } = useI18n(); + const [busyAction, setBusyAction] = useState<"confirm" | "cancel" | "">(""); + + useEffect(() => { + setBusyAction(""); + }, [proposal?.id, proposal?.status, proposal?.updatedAt]); + + if (!proposal) return null; + const activeProposal = proposal; + const busy = Boolean(busyAction); + + async function runAction(action: "confirm" | "cancel"): Promise { + if (busy) return; + setBusyAction(action); + try { + if (action === "confirm") { + await onConfirm(activeProposal); + } else { + await onCancel(activeProposal); + } + } finally { + setBusyAction(""); + } + } + + return ( + { if (!open) onClose(); }}> + + + + void runAction("confirm")} + onCancel={() => void runAction("cancel")} + onUpdate={onUpdate} + onOpenRun={onOpenRun} + onRepairModelBinding={onRepairModelBinding} + /> + + + + ); +} diff --git a/apps/web/src/features/workspace/ChannelExecutionProposalPresentation.tsx b/apps/web/src/features/workspace/ChannelExecutionProposalPresentation.tsx new file mode 100644 index 000000000..f5785696b --- /dev/null +++ b/apps/web/src/features/workspace/ChannelExecutionProposalPresentation.tsx @@ -0,0 +1,63 @@ +import type { ReactNode } from "react"; +import { CheckCircle2, CircleAlert, Loader2, Play } from "lucide-react"; +import { useI18n } from "../../i18n.js"; +import type { ChannelExecutionProposal } from "../../types.js"; + +export function ChannelExecutionProposalSummary({ + proposal, + assigneeAvatar, + assigneeLabel, + onOpen, +}: { + readonly proposal: ChannelExecutionProposal; + readonly assigneeAvatar: ReactNode; + readonly assigneeLabel: string; + readonly onOpen: () => void; +}): React.JSX.Element { + const { t } = useI18n(); + const statusIcon = proposal.status === "accepted" + ?