From f013598213cf36517225a467589c4788937dd14f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E4=B9=90=E4=B9=90?= <798622053@qq.com> Date: Wed, 26 Aug 2026 14:40:48 +0800 Subject: [PATCH] feat(web): unify sidebar previews and approval dialogs --- apps/web/e2e/app.spec.ts | 141 ++++- apps/web/e2e/channel.spec.ts | 258 +++++---- apps/web/e2e/ui2-approvals.spec.ts | 89 ++-- ...2-channel-proposal-input-migration.spec.ts | 77 ++- .../ui2-channel-settings-overlay-dock.spec.ts | 93 ++-- ...annel-workflow-status-presentation.spec.ts | 71 ++- .../e2e/ui2-sidebar-foundation-layout.spec.ts | 28 +- apps/web/e2e/ui2-sidebar-object-rows.spec.ts | 83 ++- apps/web/src/app/AppShell.tsx | 31 +- apps/web/src/app/sidebar-object-context.ts | 138 +++++ .../src/app/use-sidebar-object-contexts.ts | 57 ++ .../approvals/RuntimeApprovalDialogQueue.tsx | 126 +++++ .../useApprovalDecisionController.ts | 8 +- .../features/navigation/SidebarNavigation.tsx | 38 +- .../navigation/SidebarObjectPreview.tsx | 25 + .../features/navigation/SidebarObjectRow.tsx | 50 +- .../features/navigation/SidebarSection.tsx | 20 +- .../features/navigation/SidebarTooltip.tsx | 8 +- .../teammates/TeammatePrivateChatFeed.tsx | 11 + .../features/teammates/TeammateWorkspace.tsx | 26 + .../teammates/teammate-private-chat-types.ts | 2 + .../features/workspace/ChannelComposer.tsx | 19 - .../ChannelExecutionProposalDialog.tsx | 79 +++ .../ChannelExecutionProposalPresentation.tsx | 63 +++ .../features/workspace/ChannelMessageFeed.tsx | 1 + .../workspace/ChannelMessageRenderers.tsx | 68 ++- .../ChannelWorkflowProposalCards.tsx | 493 ++---------------- .../features/workspace/ConversationHome.tsx | 31 +- .../workspace/NormalConversationComposer.tsx | 19 - .../workspace/NormalConversationFeed.tsx | 18 + .../workspace/WorkspaceClientView.tsx | 102 ++-- .../workspace/normal-conversation-types.ts | 2 +- apps/web/src/i18n/locales/en-US.ts | 21 + apps/web/src/i18n/locales/zh-CN.ts | 18 + .../src/lib/conversation-turn-projector.ts | 111 +--- .../src/lib/runtime-approval-presentation.ts | 99 ++++ .../blocks/ConversationWorkCell.tsx | 41 +- apps/web/src/styles.css | 1 + apps/web/src/styles/app/sidebar-layout.css | 128 ++++- apps/web/src/styles/app/sidebar-ui2.css | 12 +- .../web/src/styles/approvals/dialog-queue.css | 21 + .../src/styles/channel-execution-proposal.css | 206 ++++++++ ...conversation-work-cell-shell-artifacts.css | 28 + .../runtime-approval-presentation.test.mjs | 72 +++ apps/web/test/sidebar-object-context.test.mjs | 277 ++++++++++ docs/design/approval-policy.md | 10 +- ...sation-session-full-access-confirmation.md | 4 +- ...on-surface-contract-implementation-plan.md | 38 +- docs/design/conversation-surface-contract.md | 36 ++ ...inline-runtime-approval-detailed-design.md | 178 ++++--- docs/design/ling-agent-operation-cell-ui.md | 60 ++- ...al-proposal-dialogs-implementation-plan.md | 120 +++++ ...60-ling-light-runtime-approval-dialog.png" | Bin 0 -> 56639 bytes .../1280x800-ling-dark-launcher-expanded.png" | Bin 0 -> 72386 bytes .../1440x960-ling-light-channel-preview.png" | Bin 0 -> 76943 bytes ...0x960-ling-light-conversation-preview.png" | Bin 0 -> 84824 bytes .../1440x960-ling-light-expanded.png" | Bin 0 -> 85307 bytes ...0x960-ling-light-private-chat-preview.png" | Bin 0 -> 80526 bytes ...50\345\261\200\345\257\274\350\210\252.md" | 13 + .../1280x800-ling-dark-task-confirmation.png | Bin 0 -> 201917 bytes .../1280x800-ling-dark-task-summary.png | Bin 0 -> 124822 bytes .../1280x800-ling-dark-workflow-statuses.png | Bin 0 -> 200343 bytes .../1280x800-task-confirmation-dark.jpg | Bin 0 -> 55982 bytes .../1280x800-task-summary-dark.jpg | Bin 0 -> 63927 bytes .../1440x960-ling-light-task-confirmation.png | Bin 0 -> 182303 bytes .../1440x960-ling-light-task-summary.png | Bin 0 -> 130343 bytes .../1440x960-ling-light-workflow-statuses.png | Bin 0 -> 217543 bytes .../1440x960-runtime-approval-light.jpg | Bin 0 -> 69560 bytes .../1440x960-task-confirmation-light.jpg | Bin 0 -> 69940 bytes .../1440x960-task-summary-light.jpg | Bin 0 -> 75127 bytes ...ntime-approval-dialog-clean-background.png | Bin 0 -> 56651 bytes ...k-proposal-compact-summary-with-avatar.png | Bin 0 -> 68271 bytes .../task-proposal-confirmation-dialog.png | Bin 0 -> 87400 bytes 73 files changed, 2670 insertions(+), 1099 deletions(-) create mode 100644 apps/web/src/app/sidebar-object-context.ts create mode 100644 apps/web/src/app/use-sidebar-object-contexts.ts create mode 100644 apps/web/src/features/approvals/RuntimeApprovalDialogQueue.tsx create mode 100644 apps/web/src/features/navigation/SidebarObjectPreview.tsx create mode 100644 apps/web/src/features/workspace/ChannelExecutionProposalDialog.tsx create mode 100644 apps/web/src/features/workspace/ChannelExecutionProposalPresentation.tsx create mode 100644 apps/web/src/lib/runtime-approval-presentation.ts create mode 100644 apps/web/src/styles/approvals/dialog-queue.css create mode 100644 apps/web/test/runtime-approval-presentation.test.mjs create mode 100644 apps/web/test/sidebar-object-context.test.mjs create mode 100644 docs/design/sidebar-approval-proposal-dialogs-implementation-plan.md create mode 100644 "docs/design/ui2.0\345\215\207\347\272\247/evidence/features/approvals/1440x960-ling-light-runtime-approval-dialog.png" create mode 100644 "docs/design/ui2.0\345\215\207\347\272\247/evidence/shell/sidebar-object-rows/1280x800-ling-dark-launcher-expanded.png" create mode 100644 "docs/design/ui2.0\345\215\207\347\272\247/evidence/shell/sidebar-object-rows/1440x960-ling-light-channel-preview.png" create mode 100644 "docs/design/ui2.0\345\215\207\347\272\247/evidence/shell/sidebar-object-rows/1440x960-ling-light-conversation-preview.png" create mode 100644 "docs/design/ui2.0\345\215\207\347\272\247/evidence/shell/sidebar-object-rows/1440x960-ling-light-expanded.png" create mode 100644 "docs/design/ui2.0\345\215\207\347\272\247/evidence/shell/sidebar-object-rows/1440x960-ling-light-private-chat-preview.png" create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/1280x800-ling-dark-task-confirmation.png create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/1280x800-ling-dark-task-summary.png create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/1280x800-ling-dark-workflow-statuses.png create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/1280x800-task-confirmation-dark.jpg create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/1280x800-task-summary-dark.jpg create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/1440x960-ling-light-task-confirmation.png create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/1440x960-ling-light-task-summary.png create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/1440x960-ling-light-workflow-statuses.png create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/1440x960-runtime-approval-light.jpg create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/1440x960-task-confirmation-light.jpg create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/1440x960-task-summary-light.jpg create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/runtime-approval-dialog-clean-background.png create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/task-proposal-compact-summary-with-avatar.png create mode 100644 docs/design/validation/sidebar-approval-dialogs-2026-08-26/task-proposal-confirmation-dialog.png diff --git a/apps/web/e2e/app.spec.ts b/apps/web/e2e/app.spec.ts index ab2bde59f..4652616e7 100644 --- a/apps/web/e2e/app.spec.ts +++ b/apps/web/e2e/app.spec.ts @@ -279,7 +279,17 @@ test("sidebar sections collapse locally and keep group action menus", async ({ p test("sidebar archive hides conversations and teammates without deleting data", async ({ page }) => { await useEnglishLocale(page); const state = createBaseState({ approvalStatus: "expired" }); - state.providers = [createChatProvider({ id: "provider_local", displayName: "Local Relay", model: "gpt-5.5" })]; + state.providers = [createChatProvider({ + id: "provider_local", + displayName: "Local Relay", + model: "gpt-5.5", + lastCheck: { + ok: true, + code: "ready", + message: "Provider is reachable.", + checkedAt: "2026-07-20T04:00:00.000Z", + }, + })]; state.aiTeammates = [createMockTeammate({ id: "teammate_sidebar_designer", displayName: "Designer" })]; await mockDaemon(page, state); await page.goto("/"); @@ -459,7 +469,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"; @@ -547,36 +557,122 @@ 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 }) => { await useEnglishLocale(page); const state = createBaseState({ approvalStatus: "expired" }); - state.providers = [createChatProvider({ id: "provider_local", displayName: "Local Relay", model: "gpt-5.5" })]; + state.providers = [createChatProvider({ + id: "provider_local", + displayName: "Local Relay", + model: "gpt-5.5", + lastCheck: { + ok: true, + code: "ready", + message: "Provider is reachable.", + checkedAt: "2026-07-20T04:00:00.000Z", + }, + })]; state.workspaceSettings = { ...state.workspaceSettings, defaultAuthorizationMode: "always", @@ -618,6 +714,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 fc187bc7d..c427d1a04 100644 --- a/apps/web/e2e/channel.spec.ts +++ b/apps/web/e2e/channel.spec.ts @@ -38,6 +38,18 @@ 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" }); + if (await dialog.isVisible()) { + if ((await dialog.innerText()).includes(title)) return dialog.locator(".channel-execution-proposal-decision"); + await dialog.getByRole("button", { name: "Close" }).click(); + } + const summary = page.getByLabel("Task proposal summary").filter({ hasText: title }); + await summary.getByRole("button", { name: "View task" }).click(); + await expect(dialog).toBeVisible(); + return dialog.locator(".channel-execution-proposal-decision"); +} + function createLineagePlan(overrides: Partial): ProposedPlanArtifact { return { id: "plan_lineage", @@ -585,7 +597,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" }), @@ -2173,7 +2185,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" } @@ -2211,12 +2223,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(() => { @@ -2225,7 +2241,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"); }); @@ -2307,26 +2323,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(); }); @@ -2510,9 +2529,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); @@ -2528,7 +2552,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"; @@ -5434,7 +5458,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", @@ -5562,35 +5586,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); @@ -5662,9 +5678,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([]); }); @@ -5678,6 +5695,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", @@ -5739,43 +5757,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"); @@ -5783,8 +5801,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)) { @@ -6002,7 +6020,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 }) => { @@ -7035,7 +7053,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 }) => { @@ -10758,11 +10776,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"; @@ -10807,25 +10833,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"); @@ -11428,7 +11462,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"); }); @@ -11466,16 +11500,16 @@ 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 }) => { @@ -12174,3 +12208,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 c5347af66..e017b506a 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 ?? []; @@ -1035,6 +1037,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, @@ -2366,6 +2377,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} @@ -2525,6 +2537,7 @@ export function AppShell(): React.JSX.Element { externalActionDetails={externalActionDetails} approvalsLoading={approvalsLoading} resolvingApprovals={resolvingApprovals} + approvalErrors={approvalErrors} resourceLeases={resourceLeases} runtimeDiagnostics={runtimeDiagnostics} workUnits={workUnits} @@ -2563,8 +2576,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)} @@ -2678,6 +2691,7 @@ export function AppShell(): React.JSX.Element { } : undefined} inlineApprovalViews={normalConversationInlineApprovalViews} resolvingApprovals={resolvingApprovals} + approvalErrors={approvalErrors} conversationIdentity={conversationIdentity} defaultProviderLabel={defaultConversationProviderLabel} defaultProviderPresetId={defaultConversationProviderPresetId} @@ -2689,16 +2703,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)} @@ -2821,6 +2825,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/SidebarSection.tsx b/apps/web/src/features/navigation/SidebarSection.tsx index 71ddabe6f..15b501202 100644 --- a/apps/web/src/features/navigation/SidebarSection.tsx +++ b/apps/web/src/features/navigation/SidebarSection.tsx @@ -10,6 +10,7 @@ import { StatusIndicator, } from "../../shared/ui/index.js"; import { SidebarSectionMenu, type SidebarSectionMenuItem } from "./SidebarSectionMenu.js"; +import { SidebarTooltip } from "./SidebarTooltip.js"; export type SidebarSectionId = "channels" | "teammates" | "recent"; @@ -61,14 +62,17 @@ export function SidebarSection({ return (
- onRailActivate(sectionId)} - pressed={railActive} - /> + + onRailActivate(sectionId)} + pressed={railActive} + tooltip="" + /> + {stale ? ( + {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" + ?