From 93a7cba9376feebe07d42c17fa48c81541df9dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 20 Sep 2026 11:33:30 +0100 Subject: [PATCH] fix: restore canvas runtime and panel toolbar behavior (#3827) --- .../canvas_authoring_scaffold_test.go | 4 +- .../internal/mcp/canvasskill/bundled.go | 2 +- .../internal/mcp/canvasskill/bundled_test.go | 17 +- .../internal/mcp/canvasskill/files/SKILL.md | 11 +- .../canvasskill/files/references/security.md | 9 +- .../backend/internal/plugins/webapp/policy.go | 2 +- .../internal/plugins/webapp/policy_test.go | 13 +- .../plugins/webapp/runtime_bootstrap.go | 4 +- .../plugins/webapp/runtime_bootstrap_test.go | 16 + .../internal/plugins/webapp/runtime_test.go | 20 +- .../internal/plugins/webapp_protocol.go | 4 +- .../internal/plugins/webapp_protocol_test.go | 52 ++ .../codemirror/codemirror-code-editor.tsx | 359 +--------- .../codemirror/codemirror-editor-toolbar.tsx | 468 +++++++++++++ .../editors/editor-toolbar-overflow.tsx | 257 ++++++++ .../monaco/monaco-editor-toolbar.test.tsx | 113 +++- .../editors/monaco/monaco-editor-toolbar.tsx | 241 +++++-- apps/web/components/page-shell.tsx | 4 + apps/web/components/page-topbar.tsx | 61 +- .../components/plugins/web-app-frame.test.tsx | 5 +- apps/web/components/plugins/web-app-frame.tsx | 6 +- .../settings/canvas-host-actions.tsx | 65 ++ .../settings/canvas-host-components.test.tsx | 100 ++- .../settings/canvas-host-components.tsx | 264 ++------ .../settings/canvas-host-desktop-actions.tsx | 274 ++++++++ .../components/settings/canvas-host-frame.tsx | 75 +++ .../settings/canvas-host-route-view.tsx | 244 +++++++ .../settings/canvas-host-route.test.tsx | 38 +- .../components/settings/canvas-host-route.tsx | 93 +-- .../components/task/bottom-terminal-panel.tsx | 13 +- apps/web/components/task/browser-panel.tsx | 150 +++-- .../task/changes-panel-header-actions.tsx | 559 ++++++++++++++++ .../task/changes-panel-header.test.tsx | 54 ++ .../components/task/changes-panel-header.tsx | 615 +++--------------- apps/web/components/task/changes-top-bar.tsx | 223 +++++-- .../task/dockview-panel-content.tsx | 5 +- .../task/file-browser-search-header.tsx | 2 +- .../components/task/file-browser-toolbar.tsx | 18 +- .../components/task/file-viewer-header.tsx | 25 +- .../components/task/html-preview-content.tsx | 6 +- .../task/markdown-preview-content.tsx | 2 +- .../task/mobile/mobile-file-viewer-panel.tsx | 9 +- .../task/mobile/session-mobile-layout.tsx | 2 +- .../components/task/panel-primitives.test.tsx | 139 ++++ apps/web/components/task/panel-primitives.tsx | 309 +++++++-- .../task-chat-panel.scroll-target.test.tsx | 63 ++ apps/web/components/task/task-chat-panel.tsx | 83 ++- apps/web/e2e/pages/session-page.ts | 18 + .../canvas-authenticated-proxy-fixture.ts | 284 ++++++++ .../canvas/canvas-authenticated-proxy.spec.ts | 263 ++++++++ apps/web/e2e/tests/canvas/canvas-fixture.ts | 63 +- .../mobile-canvas-authenticated-proxy.spec.ts | 121 ++++ .../e2e/tests/canvas/plugin-canvas.spec.ts | 19 +- .../mobile-message-queue-management.spec.ts | 6 + .../e2e/tests/git/git-changes-panel.spec.ts | 2 +- .../e2e/tests/mobile-panel-toolbars.spec.ts | 177 +++++ apps/web/e2e/tests/panel-toolbars.spec.ts | 437 +++++++++++++ .../review/code-review-on-demand.spec.ts | 15 +- .../tests/review/review-file-status.spec.ts | 5 +- .../review/review-markdown-preview.spec.ts | 2 +- .../mobile-workspace-repository-sets.spec.ts | 4 +- .../workspace-repository-sets.spec.ts | 5 +- .../mobile-plan-toolbar-implement.spec.ts | 15 +- ...26-08-26-plugin-backed-web-app-canvases.md | 4 + ...2026-09-19-trusted-same-origin-canvases.md | 68 ++ .../canvas-runtime-permission-fixes/plan.md | 7 + docs/plans/canvas-same-origin-auth/plan.md | 207 ++++++ .../task-01-runtime.md | 122 ++++ .../task-02-proxy-verification.md | 164 +++++ .../task-03-panel-toolbars.md | 208 ++++++ docs/plans/plugin-backed-canvases/plan.md | 7 + docs/public/canvases.md | 12 +- docs/public/plugins-authoring.md | 22 +- docs/public/plugins.md | 11 +- docs/public/security.md | 29 +- docs/specs/canvases/README.md | 2 +- .../requirements/agent-authored-web-apps.md | 16 +- .../requirements/marketplace-sharing.md | 4 +- .../system-design/agent-authored-web-apps.md | 15 +- .../system-design/canvas-host-chrome.md | 58 ++ .../system-design/marketplace-sharing.md | 6 +- docs/specs/plugins/README.md | 4 +- .../isolated-web-app-contributions.md | 103 ++- .../isolated-web-app-contributions.md | 181 +++--- docs/specs/ui/requirements/panel-toolbars.md | 54 ++ docs/specs/ui/system-design/panel-toolbars.md | 97 +++ 86 files changed, 6295 insertions(+), 1640 deletions(-) create mode 100644 apps/backend/internal/plugins/webapp/runtime_bootstrap_test.go create mode 100644 apps/web/components/editors/codemirror/codemirror-editor-toolbar.tsx create mode 100644 apps/web/components/editors/editor-toolbar-overflow.tsx create mode 100644 apps/web/components/settings/canvas-host-actions.tsx create mode 100644 apps/web/components/settings/canvas-host-desktop-actions.tsx create mode 100644 apps/web/components/settings/canvas-host-frame.tsx create mode 100644 apps/web/components/settings/canvas-host-route-view.tsx create mode 100644 apps/web/components/task/changes-panel-header-actions.tsx create mode 100644 apps/web/components/task/panel-primitives.test.tsx create mode 100644 apps/web/e2e/tests/canvas/canvas-authenticated-proxy-fixture.ts create mode 100644 apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts create mode 100644 apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts create mode 100644 apps/web/e2e/tests/mobile-panel-toolbars.spec.ts create mode 100644 apps/web/e2e/tests/panel-toolbars.spec.ts create mode 100644 docs/decisions/2026-09-19-trusted-same-origin-canvases.md create mode 100644 docs/plans/canvas-same-origin-auth/plan.md create mode 100644 docs/plans/canvas-same-origin-auth/task-01-runtime.md create mode 100644 docs/plans/canvas-same-origin-auth/task-02-proxy-verification.md create mode 100644 docs/plans/canvas-same-origin-auth/task-03-panel-toolbars.md create mode 100644 docs/specs/canvases/system-design/canvas-host-chrome.md create mode 100644 docs/specs/ui/requirements/panel-toolbars.md create mode 100644 docs/specs/ui/system-design/panel-toolbars.md diff --git a/apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go b/apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go index 1a67cea9aa..9e37a4ecca 100644 --- a/apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go +++ b/apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go @@ -67,7 +67,7 @@ func TestCanvasCoreBundle_ContainsOneReadContract(t *testing.T) { files, ok := core["files"].([]map[string]string) require.True(t, ok) require.Len(t, files, len(canvasskill.CoreInventory())) - require.Equal(t, "2", canvasskill.Version) + require.Equal(t, "3", canvasskill.Version) } func TestCanvasCreateResponseCarriesCurrentAuthoringVersion(t *testing.T) { @@ -79,7 +79,7 @@ func TestCanvasCreateResponseCarriesCurrentAuthoringVersion(t *testing.T) { skill, ok := response["skill"].(map[string]any) require.True(t, ok) require.Equal(t, canvasskill.Version, skill["version"]) - require.Equal(t, "2", skill["version"]) + require.Equal(t, "3", skill["version"]) policy, ok := response["initial_permission_policy"].(map[string]any) require.True(t, ok) require.Equal(t, canvas.CreationAuthorityPolicyVersion, policy["version"]) diff --git a/apps/backend/internal/mcp/canvasskill/bundled.go b/apps/backend/internal/mcp/canvasskill/bundled.go index 0aecec06ac..8effa4f7a3 100644 --- a/apps/backend/internal/mcp/canvasskill/bundled.go +++ b/apps/backend/internal/mcp/canvasskill/bundled.go @@ -22,7 +22,7 @@ const ( Slug = "kandev-canvas-authoring" // Version changes when the authoring contract or its supporting references // change. Canvas releases keep the version in their authoring metadata. - Version = "2" + Version = "3" materializedDirectory = "system-skills" versionFileName = ".kandev-version" diff --git a/apps/backend/internal/mcp/canvasskill/bundled_test.go b/apps/backend/internal/mcp/canvasskill/bundled_test.go index 9049d5caa6..5ff55696e2 100644 --- a/apps/backend/internal/mcp/canvasskill/bundled_test.go +++ b/apps/backend/internal/mcp/canvasskill/bundled_test.go @@ -20,10 +20,25 @@ func TestEnsureMaterialized_WritesKandevOwnedCanvasSkill(t *testing.T) { require.NoError(t, err) require.Contains(t, string(skill), "./_kandev/v1") require.Contains(t, string(skill), "browser storage") + require.Contains(t, string(skill), "trusted same-origin") + require.NotContains(t, string(skill), "opaque origin") require.FileExists(t, filepath.Join(root, "references", "browser-api.md")) require.NoDirExists(t, filepath.Join(home, "skills", Slug)) } +func TestAuthoringSecurityReferenceDescribesSameOriginTrust(t *testing.T) { + home := t.TempDir() + require.NoError(t, EnsureMaterialized(home)) + + security, err := ReadMaterialized(home, "references/security.md") + require.NoError(t, err) + securitySource := string(security) + require.Contains(t, securitySource, "same-origin") + require.Contains(t, securitySource, "ordinary user-session authority") + require.Contains(t, securitySource, "Capability grants still govern Kandev protocol operations") + require.NotContains(t, securitySource, "opaque origin") +} + func TestReadMaterialized_UsesAllowlistedInventory(t *testing.T) { home := t.TempDir() require.NoError(t, EnsureMaterialized(home)) @@ -32,7 +47,7 @@ func TestReadMaterialized_UsesAllowlistedInventory(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, content) require.Equal(t, Version, MaterializedVersion(home)) - require.Equal(t, "2", MaterializedVersion(home)) + require.Equal(t, "3", MaterializedVersion(home)) for _, path := range []string{"../outside.txt", filepath.Join(string(os.PathSeparator), "etc", "passwd"), "missing.md"} { _, err := ReadMaterialized(home, path) diff --git a/apps/backend/internal/mcp/canvasskill/files/SKILL.md b/apps/backend/internal/mcp/canvasskill/files/SKILL.md index 8a92426152..bca7c1a9d9 100644 --- a/apps/backend/internal/mcp/canvasskill/files/SKILL.md +++ b/apps/backend/internal/mcp/canvasskill/files/SKILL.md @@ -42,9 +42,12 @@ manifest, and do not request permissions outside the policy. in memory instead of storing a second copy of domain records. - Store only small application-specific shared values in instance state. Keep temporary input in memory and use conditional revisions for writes. -- The canvas has an opaque origin. Do not use browser storage, service workers, - origin-wide cookies, host URLs, or authorization headers. -- Avoid secrets in source, URLs, query strings, logs, and client state. +- The canvas runs in a trusted same-origin iframe. Treat its source as trusted + user-session code: it can use same-origin browser storage and cookies and can + access the host DOM. It has the viewing user's ordinary API authority. +- Keep Kandev protocol requests relative and do not copy capability URLs or + tokens into source, URLs, query strings, logs, or client state. Same-origin + cookies do not replace capability validation or the grants on protocol routes. - Render loading, empty, error, and retry states. Keep destructive actions explicit and explain their result. - Use accessible labels, keyboard operation, visible focus, and touch targets. @@ -97,7 +100,7 @@ Read a supporting reference only when its topic is needed: - `references/manifest.md` for the full manifest shape and validation rules. - `references/data-and-state.md` for domain data and instance state. - `references/events-and-recovery.md` for events, reconnect, and retries. -- `references/security.md` for opaque-origin and source safety rules. +- `references/security.md` for same-origin trust and source safety rules. - `references/ui-patterns.md` for responsive and accessible UI patterns. ## Distribution checklist diff --git a/apps/backend/internal/mcp/canvasskill/files/references/security.md b/apps/backend/internal/mcp/canvasskill/files/references/security.md index 115d60c9b9..055438b08c 100644 --- a/apps/backend/internal/mcp/canvasskill/files/references/security.md +++ b/apps/backend/internal/mcp/canvasskill/files/references/security.md @@ -1,7 +1,12 @@ # Security rules -Canvas source runs in an opaque origin. Never depend on browser storage, -service workers, origin-wide cookies, or a stable origin identifier. +Canvas source runs in a trusted same-origin iframe. It can use the viewing +browser's ordinary user-session authority, including same-origin storage, +cookies, and host DOM access. Treat the source as trusted user-session code. + +Capability grants still govern Kandev protocol operations. A session cookie does +not replace the capability URL, release binding, scope checks, or per-operation +permission checks. Keep source and assets inside the assigned canvas source directory. Use workspace-relative paths only. Do not create symlinks, device files, sockets, diff --git a/apps/backend/internal/plugins/webapp/policy.go b/apps/backend/internal/plugins/webapp/policy.go index f4555633e2..4159c38c77 100644 --- a/apps/backend/internal/plugins/webapp/policy.go +++ b/apps/backend/internal/plugins/webapp/policy.go @@ -46,7 +46,7 @@ func BuildContentSecurityPolicy(networkOrigins, frameAncestors []string) (string images := append([]string{"'self'", "data:"}, network...) fonts := append([]string{"'self'", "data:"}, network...) return strings.Join([]string{ - "sandbox allow-scripts allow-forms", + "sandbox allow-scripts allow-forms allow-same-origin", "default-src 'none'", "form-action 'none'", "base-uri 'none'", diff --git a/apps/backend/internal/plugins/webapp/policy_test.go b/apps/backend/internal/plugins/webapp/policy_test.go index 60e0135d8e..2df6e620bd 100644 --- a/apps/backend/internal/plugins/webapp/policy_test.go +++ b/apps/backend/internal/plugins/webapp/policy_test.go @@ -5,13 +5,12 @@ import ( "testing" ) -func TestBuildContentSecurityPolicyIsGrantBoundAndOpaque(t *testing.T) { +func TestBuildContentSecurityPolicyIsGrantBoundAndSameOrigin(t *testing.T) { policy, err := BuildContentSecurityPolicy([]string{"https://api.example.com:443", "https://api.example.com:443"}, []string{"http://127.0.0.1:38429", "tauri://localhost", "http://tauri.localhost"}) if err != nil { t.Fatalf("BuildContentSecurityPolicy() unexpected error: %v", err) } for _, required := range []string{ - "sandbox allow-scripts allow-forms", "default-src 'none'", "form-action 'none'", "base-uri 'none'", @@ -24,6 +23,16 @@ func TestBuildContentSecurityPolicyIsGrantBoundAndOpaque(t *testing.T) { t.Fatalf("policy %q missing %q", policy, required) } } + sandbox := "" + for _, directive := range strings.Split(policy, "; ") { + if strings.HasPrefix(directive, "sandbox ") { + sandbox = directive + break + } + } + if sandbox != "sandbox allow-scripts allow-forms allow-same-origin" { + t.Fatalf("sandbox directive = %q, want the exact trusted same-origin token set", sandbox) + } if strings.Count(policy, "api.example.com:443") != 3 { t.Fatalf("policy includes a duplicate or missing origin: %q", policy) } diff --git a/apps/backend/internal/plugins/webapp/runtime_bootstrap.go b/apps/backend/internal/plugins/webapp/runtime_bootstrap.go index 7a2f3350a1..be87c4d637 100644 --- a/apps/backend/internal/plugins/webapp/runtime_bootstrap.go +++ b/apps/backend/internal/plugins/webapp/runtime_bootstrap.go @@ -31,7 +31,7 @@ const hostRuntimeBootstrap = `(() => { result: outcome.result, }; if (outcome.code) message.code = outcome.code; - window.parent.postMessage(message, "*"); // Sandboxed iframes have a null origin, so "*" is the only viable target. + window.parent.postMessage(message, "*"); // The host validates the current frame window and nonce. }; const finish = (result, code) => { @@ -59,7 +59,7 @@ const hostRuntimeBootstrap = `(() => { const checkContext = () => { if (outcome) return; - fetch("./_kandev/v1/context", { credentials: "omit", cache: "no-store" }) + fetch("./_kandev/v1/context", { credentials: "same-origin", cache: "no-store" }) .then((response) => { if (!response.ok) throw new Error("context unavailable"); return response.json(); diff --git a/apps/backend/internal/plugins/webapp/runtime_bootstrap_test.go b/apps/backend/internal/plugins/webapp/runtime_bootstrap_test.go new file mode 100644 index 0000000000..131614d955 --- /dev/null +++ b/apps/backend/internal/plugins/webapp/runtime_bootstrap_test.go @@ -0,0 +1,16 @@ +package webapp + +import ( + "strings" + "testing" +) + +func TestHostRuntimeBootstrapUsesSameOriginCredentials(t *testing.T) { + const request = `fetch("./_kandev/v1/context", { credentials: "same-origin", cache: "no-store" })` + if !strings.Contains(hostRuntimeBootstrap, request) { + t.Fatalf("host bootstrap does not use same-origin credentials: %s", hostRuntimeBootstrap) + } + if strings.Contains(hostRuntimeBootstrap, `credentials: "omit"`) { + t.Fatal("host bootstrap still omits same-origin credentials") + } +} diff --git a/apps/backend/internal/plugins/webapp/runtime_test.go b/apps/backend/internal/plugins/webapp/runtime_test.go index a90d20f6ac..17552c6629 100644 --- a/apps/backend/internal/plugins/webapp/runtime_test.go +++ b/apps/backend/internal/plugins/webapp/runtime_test.go @@ -23,7 +23,7 @@ func containsString(values []string, target string) bool { return false } -func TestRuntimeServesEntryWithSecurityHeadersAndNoCookies(t *testing.T) { +func TestRuntimeServesEntryWithSecurityHeadersAndCookiesDoNotReplaceCapability(t *testing.T) { archive := canvasArchive(t, map[string]string{ "manifest.yaml": staticManifestYAML, "ui/index.html": "safe", @@ -59,7 +59,7 @@ func TestRuntimeServesEntryWithSecurityHeadersAndNoCookies(t *testing.T) { if !strings.Contains(response.Body.String(), "safe") { t.Fatalf("body = %q", response.Body.String()) } - if got := response.Header().Get("Content-Security-Policy"); !strings.Contains(got, "sandbox allow-scripts allow-forms") || !strings.Contains(got, "form-action 'none'") || !strings.Contains(got, "frame-ancestors 'self' http://127.0.0.1:38429") { + if got := response.Header().Get("Content-Security-Policy"); !strings.Contains(got, "sandbox allow-scripts allow-forms allow-same-origin") || !strings.Contains(got, "form-action 'none'") || !strings.Contains(got, "frame-ancestors 'self' http://127.0.0.1:38429") { t.Fatalf("CSP = %q", got) } for key, want := range map[string]string{ @@ -78,6 +78,22 @@ func TestRuntimeServesEntryWithSecurityHeadersAndNoCookies(t *testing.T) { } } +func TestRuntimeCookieDoesNotReplaceCapability(t *testing.T) { + runtime := NewRuntime(NewTokenManager(nil), nil, nil, nil) + request := httptest.NewRequest(http.MethodGet, "/", nil) + request.AddCookie(&http.Cookie{Name: "kandev_session", Value: "valid-session"}) + response := httptest.NewRecorder() + + runtime.Serve(response, request, "missing-runtime-capability", "_kandev/v1/context") + + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d, want missing capability to be rejected despite cookie", response.Code) + } + if !strings.Contains(response.Body.String(), "runtime_token_invalid") { + t.Fatalf("body = %q, want the runtime token error", response.Body.String()) + } +} + func TestRuntimeResolvesNestedEntryAssetsAndKeepsProtocolRoot(t *testing.T) { archive := canvasArchive(t, map[string]string{ "manifest.yaml": staticManifestYAML, diff --git a/apps/backend/internal/plugins/webapp_protocol.go b/apps/backend/internal/plugins/webapp_protocol.go index 1aa14caec4..edf2df75ef 100644 --- a/apps/backend/internal/plugins/webapp_protocol.go +++ b/apps/backend/internal/plugins/webapp_protocol.go @@ -240,11 +240,11 @@ func (s *Service) handleWebAppWorkflows(ctx context.Context, w http.ResponseWrit } func (s *Service) handleWebAppAction(w http.ResponseWriter, r *http.Request, binding webapp.CapabilityBinding, parts []string) { - if r.Method != http.MethodPost || len(parts) != 2 || !validWebAppKey(parts[1]) { + if r.Method != http.MethodPost || len(parts) != 3 || parts[1] != "actions" || !validWebAppKey(parts[2]) { writeWebAppError(w, http.StatusMethodNotAllowed, "method_not_allowed") return } - if !webAppHasActionPermission(binding.Permissions, parts[1]) { + if !webAppHasActionPermission(binding.Permissions, parts[2]) { writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied") return } diff --git a/apps/backend/internal/plugins/webapp_protocol_test.go b/apps/backend/internal/plugins/webapp_protocol_test.go index f09910f217..dc8a024443 100644 --- a/apps/backend/internal/plugins/webapp_protocol_test.go +++ b/apps/backend/internal/plugins/webapp_protocol_test.go @@ -54,6 +54,58 @@ func TestWebAppProtocolContextDoesNotExposeCredentials(t *testing.T) { } } +func TestWebAppProtocolCookieDoesNotExpandPermissions(t *testing.T) { + svc := &Service{} + binding := webapp.CapabilityBinding{ + UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1", + ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas", + Permissions: nil, + } + request := httptest.NewRequest(http.MethodGet, "/", nil) + request.AddCookie(&http.Cookie{Name: "kandev_session", Value: "valid-session"}) + response := httptest.NewRecorder() + + svc.handleWebAppProtocol(response, request, "ignored", binding, "v1/state") + + if response.Code != http.StatusForbidden || !containsBody(response, "plugin_permission_denied") { + t.Fatalf("state request = %d %s, want the cookie to leave capability permissions unchanged", response.Code, response.Body.String()) + } +} + +func TestWebAppProtocolActionChecksCapability(t *testing.T) { + baseBinding := webapp.CapabilityBinding{ + UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1", + ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas", + Permissions: nil, + } + + denied := httptest.NewRecorder() + svc := &Service{} + svc.handleWebAppProtocol( + denied, + httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)), + "", + baseBinding, + "v1/actions/not-declared", + ) + if denied.Code != http.StatusForbidden || !containsBody(denied, "plugin_permission_denied") { + t.Fatalf("undeclared action = %d %s, want permission denial", denied.Code, denied.Body.String()) + } + + allowed := httptest.NewRecorder() + baseBinding.Permissions = []string{"action:declared"} + svc.handleWebAppProtocol( + allowed, + httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)), + "", + baseBinding, + "v1/actions/declared", + ) + if allowed.Code != http.StatusNotImplemented || !containsBody(allowed, "plugin_action_unavailable") { + t.Fatalf("declared action = %d %s, want unavailable action response", allowed.Code, allowed.Body.String()) + } +} + func TestWebAppProtocolStateUsesRevisionPreconditions(t *testing.T) { store := newWebAppProtocolStateStore(t) svc := &Service{instanceState: store} diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx index 1bad8e7a93..5d034ffabf 100644 --- a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx +++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx @@ -1,38 +1,18 @@ "use client"; -import { SymlinkIndicator } from "@/components/shared/symlink-indicator"; - import { useCallback, useEffect, useRef, useState } from "react"; import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror"; import type { EditorView } from "@codemirror/view"; import { Button } from "@kandev/ui/button"; -import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; -import { - IconDeviceFloppy, - IconLoader2, - IconDownload, - IconTrash, - IconTextWrap, - IconTextWrapDisabled, - IconMessagePlus, - IconRefresh, - IconEye, -} from "@tabler/icons-react"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { formatDiffStats } from "@/lib/utils/file-diff"; -import { toRelativePath } from "@/lib/utils"; +import { IconMessagePlus } from "@tabler/icons-react"; import { vscodeDark } from "@uiw/codemirror-theme-vscode"; import { EditorCommentPopover } from "@/components/task/editor-comment-popover"; import { CommentViewPopover } from "@/components/task/comment-view-popover"; -import { PanelHeaderBarSplit } from "@/components/task/panel-primitives"; -import { - ExternalVcsFileLink, - useExternalVcsFileStatus, -} from "@/components/editors/external-vcs-file-link"; import { registerCodeMirrorCursorRevealer } from "@/hooks/file-editor-cursor"; import { useCodeMirrorEditorState } from "./use-codemirror-editor-state"; import { useCodeMirrorWalkthroughRange } from "./use-codemirror-walkthrough-range"; import type { FilePreviewKind } from "@/lib/utils/file-types"; +import { CodeMirrorToolbar } from "./codemirror-editor-toolbar"; import { clearCodeMirrorCursorFlash, codeMirrorCursorFlashExtension, @@ -41,9 +21,6 @@ import { } from "./codemirror-cursor-navigation"; import { useTranslation } from "react-i18next"; -const SAVE_SHORTCUT = - typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "\u2318" : "Ctrl"; - type FileEditorContentProps = { path: string; content: string; @@ -70,338 +47,6 @@ type FileEditorContentProps = { onDownload?: () => void; }; -function CodeMirrorCommentBadge({ - enableComments, - sessionId, - commentCount, -}: { - enableComments: boolean; - sessionId?: string; - commentCount: number; -}) { - const { t } = useTranslation(); - if (!enableComments || !sessionId || commentCount <= 0) return null; - - return ( -
- - {t("editors:commentCount", { count: commentCount })} -
- ); -} - -function CodeMirrorWrapButton({ - wrapEnabled, - onToggleWrap, -}: { - wrapEnabled: boolean; - onToggleWrap: () => void; -}) { - const { t } = useTranslation(); - return ( - - - - - - {wrapEnabled ? t("editors:disableWordWrap") : t("editors:enableWordWrap")} - - - ); -} - -function CodeMirrorReloadButton({ - hasRemoteUpdate, - onReloadFromAgent, -}: { - hasRemoteUpdate?: boolean; - onReloadFromAgent?: () => void; -}) { - const { t } = useTranslation(); - if (!hasRemoteUpdate || !onReloadFromAgent) return null; - - return ( - - - - - {t("editors:applyLatestAgentChangesToFile")} - - ); -} - -function CodeMirrorDeleteButton({ onDelete }: { onDelete?: () => void }) { - const { t } = useTranslation(); - if (!onDelete) return null; - - return ( - - - - - {t("editors:deleteFile")} - - ); -} - -function CodeMirrorDownloadButton({ onDownload }: { onDownload?: () => void }) { - const { t } = useTranslation(); - if (!onDownload) return null; - - return ( - - - - - {t("editors:downloadFile")} - - ); -} - -function CodeMirrorSaveButton({ - isDirty, - isSaving, - onSave, -}: { - isDirty: boolean; - isSaving: boolean; - onSave: () => void; -}) { - const { t } = useTranslation(); - return ( - - ); -} - -function CodeMirrorPreviewButton({ - previewKind, - onToggle, - onPreviewHtml, - isPublishingHtmlPreview, -}: { - previewKind: FilePreviewKind; - onToggle?: () => void; - onPreviewHtml?: () => void; - isPublishingHtmlPreview?: boolean; -}) { - const { t } = useTranslation(); - if (previewKind === "none") return null; - const isHtml = previewKind === "html"; - const action = isHtml ? onPreviewHtml : onToggle; - if (!action) return null; - const label = isHtml ? t("editors:previewHtml") : t("editors:previewMarkdown"); - return ( - - - - - -

{label}

- {isHtml && ( -

{t("task:htmlPreviewTrustedCode")}

- )} -
-
- ); -} - -function CodeMirrorFileLabel({ - path, - worktreePath, - isSymlink, - isDirty, - diffStats, -}: { - path: string; - worktreePath?: string; - isSymlink?: boolean; - isDirty: boolean; - diffStats: { additions: number; deletions: number } | null; -}) { - return ( -
- - {toRelativePath(path, worktreePath)} - - - {isDirty && diffStats && ( - - {formatDiffStats(diffStats.additions, diffStats.deletions)} - - )} -
- ); -} - -/** Toolbar for the CodeMirror code editor. */ -function CodeMirrorToolbar({ - path, - worktreePath, - isSymlink, - isDirty, - isSaving, - diffStats, - wrapEnabled, - enableComments, - sessionId, - taskId, - repositoryId, - repositoryName, - commentCount, - hasRemoteUpdate, - onToggleWrap, - onSave, - onReloadFromAgent, - onDelete, - onDownload, - previewKind = "none", - onTogglePreview, - onPreviewHtml, - isPublishingHtmlPreview, -}: { - path: string; - worktreePath?: string; - isSymlink?: boolean; - isDirty: boolean; - isSaving: boolean; - diffStats: { additions: number; deletions: number } | null; - wrapEnabled: boolean; - enableComments: boolean; - sessionId?: string; - taskId?: string | null; - repositoryId?: string | null; - repositoryName?: string; - commentCount: number; - hasRemoteUpdate?: boolean; - onToggleWrap: () => void; - onSave: () => void; - onReloadFromAgent?: () => void; - onDelete?: () => void; - onDownload?: () => void; - previewKind?: FilePreviewKind; - onTogglePreview?: () => void; - onPreviewHtml?: () => void; - isPublishingHtmlPreview?: boolean; -}) { - const fileStatus = useExternalVcsFileStatus(path, sessionId, repositoryName); - return ( - - } - right={ -
- - {(onTogglePreview || onPreviewHtml) && ( - - )} - - - - - - -
- } - /> - ); -} - type CodeMirrorEditorState = ReturnType; function CodeMirrorOverlays({ state }: { state: CodeMirrorEditorState }) { diff --git a/apps/web/components/editors/codemirror/codemirror-editor-toolbar.tsx b/apps/web/components/editors/codemirror/codemirror-editor-toolbar.tsx new file mode 100644 index 0000000000..2f60d6ee7a --- /dev/null +++ b/apps/web/components/editors/codemirror/codemirror-editor-toolbar.tsx @@ -0,0 +1,468 @@ +"use client"; + +import { SymlinkIndicator } from "@/components/shared/symlink-indicator"; +import { Button } from "@kandev/ui/button"; +import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; +import { + IconDeviceFloppy, + IconLoader2, + IconDownload, + IconTrash, + IconTextWrap, + IconTextWrapDisabled, + IconMessagePlus, + IconRefresh, + IconEye, +} from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { formatDiffStats } from "@/lib/utils/file-diff"; +import { toRelativePath } from "@/lib/utils"; +import { EditorToolbarOverflowActions } from "@/components/editors/editor-toolbar-overflow"; +import { + ExternalVcsFileLink, + useExternalVcsFileStatus, +} from "@/components/editors/external-vcs-file-link"; +import { PanelHeaderBarSplit } from "@/components/task/panel-primitives"; +import type { FilePreviewKind } from "@/lib/utils/file-types"; +import { useTranslation } from "react-i18next"; + +const SAVE_SHORTCUT = + typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "\u2318" : "Ctrl"; + +function CodeMirrorCommentBadge({ + enableComments, + sessionId, + commentCount, +}: { + enableComments: boolean; + sessionId?: string; + commentCount: number; +}) { + const { t } = useTranslation(); + if (!enableComments || !sessionId || commentCount <= 0) return null; + + return ( +
+ + {t("editors:commentCount", { count: commentCount })} +
+ ); +} + +function CodeMirrorWrapButton({ + wrapEnabled, + onToggleWrap, +}: { + wrapEnabled: boolean; + onToggleWrap: () => void; +}) { + const { t } = useTranslation(); + return ( + + + + + + {wrapEnabled ? t("editors:disableWordWrap") : t("editors:enableWordWrap")} + + + ); +} + +function CodeMirrorReloadButton({ + hasRemoteUpdate, + onReloadFromAgent, +}: { + hasRemoteUpdate?: boolean; + onReloadFromAgent?: () => void; +}) { + const { t } = useTranslation(); + if (!hasRemoteUpdate || !onReloadFromAgent) return null; + + return ( + + + + + {t("editors:applyLatestAgentChangesToFile")} + + ); +} + +function CodeMirrorDeleteButton({ onDelete }: { onDelete?: () => void }) { + const { t } = useTranslation(); + if (!onDelete) return null; + + return ( + + + + + {t("editors:deleteFile")} + + ); +} + +function CodeMirrorDownloadButton({ onDownload }: { onDownload?: () => void }) { + const { t } = useTranslation(); + if (!onDownload) return null; + + return ( + + + + + {t("editors:downloadFile")} + + ); +} + +function CodeMirrorSaveButton({ + isDirty, + isSaving, + onSave, +}: { + isDirty: boolean; + isSaving: boolean; + onSave: () => void; +}) { + const { t } = useTranslation(); + return ( + + ); +} + +function CodeMirrorPreviewButton({ + previewKind, + onToggle, + onPreviewHtml, + isPublishingHtmlPreview, +}: { + previewKind: FilePreviewKind; + onToggle?: () => void; + onPreviewHtml?: () => void; + isPublishingHtmlPreview?: boolean; +}) { + const { t } = useTranslation(); + if (previewKind === "none") return null; + const isHtml = previewKind === "html"; + const action = isHtml ? onPreviewHtml : onToggle; + if (!action) return null; + const label = isHtml ? t("editors:previewHtml") : t("editors:previewMarkdown"); + return ( + + + + + +

{label}

+ {isHtml && ( +

{t("task:htmlPreviewTrustedCode")}

+ )} +
+
+ ); +} + +function CodeMirrorFileLabel({ + path, + worktreePath, + isSymlink, + isDirty, + diffStats, +}: { + path: string; + worktreePath?: string; + isSymlink?: boolean; + isDirty: boolean; + diffStats: { additions: number; deletions: number } | null; +}) { + return ( +
+ + {toRelativePath(path, worktreePath)} + + + {isDirty && diffStats && ( + + {formatDiffStats(diffStats.additions, diffStats.deletions)} + + )} +
+ ); +} + +type CodeMirrorToolbarActionsProps = { + path: string; + taskId?: string | null; + sessionId?: string; + repositoryId?: string | null; + repositoryName?: string; + enableComments: boolean; + commentCount: number; + onTogglePreview?: () => void; + onPreviewHtml?: () => void; + isPublishingHtmlPreview?: boolean; + previewKind?: FilePreviewKind; + wrapEnabled: boolean; + onToggleWrap: () => void; + hasRemoteUpdate?: boolean; + onReloadFromAgent?: () => void; + onDownload?: () => void; + onDelete?: () => void; + isDirty: boolean; + isSaving: boolean; + onSave: () => void; + fileStatus?: { old_path?: string | null; status?: string | null }; +}; + +function CodeMirrorToolbarActions({ + path, + taskId, + sessionId, + repositoryId, + repositoryName, + enableComments, + commentCount, + onTogglePreview, + onPreviewHtml, + isPublishingHtmlPreview, + previewKind = "none", + wrapEnabled, + onToggleWrap, + hasRemoteUpdate, + onReloadFromAgent, + onDownload, + onDelete, + isDirty, + isSaving, + onSave, + fileStatus, +}: CodeMirrorToolbarActionsProps) { + return ( +
+ + {(onTogglePreview || onPreviewHtml) && ( + + )} + + + + + + +
+ ); +} + +/** Toolbar for the CodeMirror code editor. */ +export type CodeMirrorToolbarProps = { + path: string; + worktreePath?: string; + isSymlink?: boolean; + isDirty: boolean; + isSaving: boolean; + diffStats: { additions: number; deletions: number } | null; + wrapEnabled: boolean; + enableComments: boolean; + sessionId?: string; + taskId?: string | null; + repositoryId?: string | null; + repositoryName?: string; + commentCount: number; + hasRemoteUpdate?: boolean; + onToggleWrap: () => void; + onSave: () => void; + onReloadFromAgent?: () => void; + onDelete?: () => void; + onDownload?: () => void; + previewKind?: FilePreviewKind; + onTogglePreview?: () => void; + onPreviewHtml?: () => void; + isPublishingHtmlPreview?: boolean; +}; + +export function CodeMirrorToolbar(props: CodeMirrorToolbarProps) { + const { + path, + worktreePath, + isSymlink, + isDirty, + isSaving, + diffStats, + wrapEnabled, + enableComments, + sessionId, + taskId, + repositoryId, + repositoryName, + commentCount, + hasRemoteUpdate, + onToggleWrap, + onSave, + onReloadFromAgent, + onDelete, + onDownload, + previewKind = "none", + onTogglePreview, + onPreviewHtml, + isPublishingHtmlPreview, + } = props; + const fileStatus = useExternalVcsFileStatus(path, sessionId, repositoryName); + const overflowActions = ( + + ); + return ( + + } + right={ + + } + rightWhenOverflow={ + + } + overflow={overflowActions} + overflowAt={520} + /> + ); +} diff --git a/apps/web/components/editors/editor-toolbar-overflow.tsx b/apps/web/components/editors/editor-toolbar-overflow.tsx new file mode 100644 index 0000000000..135eafbd02 --- /dev/null +++ b/apps/web/components/editors/editor-toolbar-overflow.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { + IconArrowsDiff, + IconDownload, + IconEye, + IconMessagePlus, + IconRefresh, + IconTextWrap, + IconTrash, +} from "@tabler/icons-react"; +import { DropdownMenuItem } from "@kandev/ui/dropdown-menu"; +import { useTranslation } from "react-i18next"; +import { + ExternalVcsFileMenuItem, + type ExternalVcsFileMenuItemProps, +} from "./external-vcs-file-link"; +import { FileActionsMenuItems } from "./file-actions-dropdown"; +import type { FilePreviewKind } from "@/lib/utils/file-types"; +import { PanelHeaderOverflowMenu } from "@/components/task/panel-primitives"; + +export type EditorToolbarOverflowActionsProps = { + filePath: string; + previousPath?: string | null; + status?: string | null; + taskId?: string | null; + sessionId?: string | null; + repositoryId?: string | null; + repositoryName?: string | null; + isDirty: boolean; + hasVcsDiff?: boolean; + showDiffIndicators?: boolean; + onToggleDiffIndicators?: () => void; + wrapEnabled: boolean; + onToggleWrap: () => void; + lspLanguage?: string | null; + onToggleLsp?: () => void; + hasRemoteUpdate?: boolean; + onReloadFromAgent?: () => void; + previewKind: FilePreviewKind; + onTogglePreview?: () => void; + onPreviewHtml?: () => void; + isPublishingHtmlPreview?: boolean; + commentCount?: number; + enableComments?: boolean; + onDownload?: () => void; + onDelete?: () => void; +}; + +function EditorExternalVcsMenuItem({ + filePath, + previousPath, + status, + taskId, + sessionId, + repositoryId, + repositoryName, +}: Pick< + EditorToolbarOverflowActionsProps, + | "filePath" + | "previousPath" + | "status" + | "taskId" + | "sessionId" + | "repositoryId" + | "repositoryName" +>) { + const input: ExternalVcsFileMenuItemProps = { + filePath, + previousPath, + status, + taskId, + sessionId, + repositoryId, + repositoryName, + }; + return ; +} + +function EditorLspOverflowItem({ + lspLanguage, + onToggleLsp, +}: Pick) { + const { t } = useTranslation(); + if (!lspLanguage || !onToggleLsp) return null; + return ( + + {t("lsp:languageServerStatus")} + + ); +} + +function EditorCommentOverflowItem({ + enableComments, + commentCount, +}: Pick) { + const { t } = useTranslation(); + if (!enableComments || !commentCount) return null; + return ( + + + {t("editors:commentCount", { count: commentCount })} + + ); +} + +function EditorDiffOverflowItem({ + isDirty, + hasVcsDiff, + showDiffIndicators, + onToggleDiffIndicators, +}: Pick< + EditorToolbarOverflowActionsProps, + "isDirty" | "hasVcsDiff" | "showDiffIndicators" | "onToggleDiffIndicators" +>) { + const { t } = useTranslation(); + if (!(isDirty || hasVcsDiff) || !onToggleDiffIndicators) return null; + return ( + + + {showDiffIndicators ? t("editors:hideDiffIndicators") : t("editors:showDiffIndicators")} + + ); +} + +function EditorPreviewOverflowItem({ + previewKind, + onTogglePreview, + onPreviewHtml, + isPublishingHtmlPreview, +}: Pick< + EditorToolbarOverflowActionsProps, + "previewKind" | "onTogglePreview" | "onPreviewHtml" | "isPublishingHtmlPreview" +>) { + const { t } = useTranslation(); + const action = previewKind === "html" ? onPreviewHtml : onTogglePreview; + if (previewKind === "none" || !action) return null; + const label = previewKind === "html" ? t("editors:previewHtml") : t("editors:previewMarkdown"); + return ( + + + {label} + + ); +} + +function EditorReloadOverflowItem({ + hasRemoteUpdate, + onReloadFromAgent, +}: Pick) { + const { t } = useTranslation(); + if (!hasRemoteUpdate || !onReloadFromAgent) return null; + return ( + + + {t("editors:reload")} + + ); +} + +function EditorDownloadOverflowItem({ + onDownload, +}: Pick) { + const { t } = useTranslation(); + if (!onDownload) return null; + return ( + + + {t("editors:downloadFile")} + + ); +} + +function EditorDeleteOverflowItem({ + onDelete, +}: Pick) { + const { t } = useTranslation(); + if (!onDelete) return null; + return ( + + + {t("editors:deleteFile")} + + ); +} + +export function EditorToolbarOverflowActions({ + filePath, + previousPath, + status, + taskId, + sessionId, + repositoryId, + repositoryName, + isDirty, + hasVcsDiff = false, + showDiffIndicators = false, + onToggleDiffIndicators, + wrapEnabled, + onToggleWrap, + lspLanguage, + onToggleLsp, + hasRemoteUpdate = false, + onReloadFromAgent, + previewKind, + onTogglePreview, + onPreviewHtml, + isPublishingHtmlPreview = false, + commentCount = 0, + enableComments = false, + onDownload, + onDelete, +}: EditorToolbarOverflowActionsProps) { + const { t } = useTranslation(); + return ( + + + + + + + + {wrapEnabled ? t("editors:disableWordWrap") : t("editors:enableWordWrap")} + + + + + + + + ); +} diff --git a/apps/web/components/editors/monaco/monaco-editor-toolbar.test.tsx b/apps/web/components/editors/monaco/monaco-editor-toolbar.test.tsx index 5a39942ae2..727a319a5c 100644 --- a/apps/web/components/editors/monaco/monaco-editor-toolbar.test.tsx +++ b/apps/web/components/editors/monaco/monaco-editor-toolbar.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -6,11 +6,13 @@ vi.mock("@/components/editors/external-vcs-file-link", () => ({ ExternalVcsFileLink: (props: Record) => ( ), + ExternalVcsFileMenuItem: () => , useExternalVcsFileStatus: () => ({ status: "renamed", old_path: "src/old-name.ts" }), })); vi.mock("@/components/editors/file-actions-dropdown", () => ({ FileActionsDropdown: () => , + FileActionsMenuItems: () => , })); vi.mock("@/components/editors/lsp-status-button", () => ({ @@ -186,3 +188,112 @@ describe("MonacoEditorToolbar download action", () => { expect(screen.queryByRole("button", { name: "Download file" })).toBeNull(); }); }); + +describe("MonacoEditorToolbar narrow action overflow", () => { + it("keeps dirty Save visible while exposing secondary actions through overflow", async () => { + const originalResizeObserver = globalThis.ResizeObserver; + const originalRect = HTMLElement.prototype.getBoundingClientRect; + class ResizeObserverStub { + private readonly callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe(target: Element) { + this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver); + } + + disconnect() {} + + unobserve() {} + } + + globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver; + HTMLElement.prototype.getBoundingClientRect = function () { + if (this.getAttribute("data-panel-header") === "true") { + return { + x: 0, + y: 0, + top: 0, + left: 0, + right: 480, + bottom: 30, + width: 480, + height: 30, + toJSON: () => ({}), + } as DOMRect; + } + return originalRect.call(this); + }; + + try { + const onSave = vi.fn(); + const onToggleWrap = vi.fn(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Show more actions" })).toBeTruthy(); + }); + expect(screen.getAllByRole("button", { name: /Save/ })).toHaveLength(1); + expect( + screen + .getAllByRole("button", { name: /Save/ }) + .every((button) => !button.hasAttribute("disabled")), + ).toBe(true); + expect( + document.querySelector("[data-panel-header]")?.getAttribute("data-panel-overflow"), + ).toBe("true"); + fireEvent.pointerDown(screen.getByRole("button", { name: "Show more actions" }), { + button: 0, + ctrlKey: false, + }); + await waitFor(() => { + expect(screen.getByRole("menuitem", { name: "Enable word wrap" })).toBeTruthy(); + }); + expect(screen.getByRole("menuitem", { name: "Download file" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Delete file" })).toBeTruthy(); + fireEvent.click(screen.getByRole("menuitem", { name: "Enable word wrap" })); + expect(onToggleWrap).toHaveBeenCalledOnce(); + } finally { + HTMLElement.prototype.getBoundingClientRect = originalRect; + if (originalResizeObserver) { + globalThis.ResizeObserver = originalResizeObserver; + } else { + // @ts-expect-error jsdom may not define ResizeObserver + delete globalThis.ResizeObserver; + } + } + }); +}); diff --git a/apps/web/components/editors/monaco/monaco-editor-toolbar.tsx b/apps/web/components/editors/monaco/monaco-editor-toolbar.tsx index 94dbeee413..5b80968ed1 100644 --- a/apps/web/components/editors/monaco/monaco-editor-toolbar.tsx +++ b/apps/web/components/editors/monaco/monaco-editor-toolbar.tsx @@ -24,6 +24,7 @@ import { ExternalVcsFileLink, useExternalVcsFileStatus, } from "@/components/editors/external-vcs-file-link"; +import { EditorToolbarOverflowActions } from "@/components/editors/editor-toolbar-overflow"; import { PanelHeaderBarSplit } from "@/components/task/panel-primitives"; import { LspStatusButton } from "@/components/editors/lsp-status-button"; import type { FilePreviewKind } from "@/lib/utils/file-types"; @@ -50,7 +51,7 @@ function SaveButton({ variant="default" onClick={onSave} disabled={!isDirty || isSaving} - className="cursor-pointer gap-2" + className="min-h-6 cursor-pointer gap-2 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11" > {isSaving ? ( <> @@ -124,7 +125,7 @@ function DiffIndicatorsButton({ onToggle: () => void; }) { const { t } = useTranslation(); - const buttonClass = `h-8 w-8 p-0 cursor-pointer ${isVisible ? "text-foreground" : "text-muted-foreground"}`; + const buttonClass = `h-6 w-6 p-0 cursor-pointer max-md:h-11 [@media(pointer:coarse)]:h-11 max-md:w-11 [@media(pointer:coarse)]:w-11 ${isVisible ? "text-foreground" : "text-muted-foreground"}`; return ( @@ -147,7 +148,7 @@ function WrapButton({ onToggleWrap: () => void; }) { const { t } = useTranslation(); - const wrapClass = `h-8 w-8 p-0 cursor-pointer ${wrapEnabled ? "text-foreground" : "text-muted-foreground"}`; + const wrapClass = `h-6 w-6 p-0 cursor-pointer max-md:h-11 [@media(pointer:coarse)]:h-11 max-md:w-11 [@media(pointer:coarse)]:w-11 ${wrapEnabled ? "text-foreground" : "text-muted-foreground"}`; const wrapIcon = wrapEnabled ? ( ) : ( @@ -183,7 +184,7 @@ function ReloadFromAgentButton({ @@ -228,7 +229,7 @@ function DownloadButton({ onDownload }: { onDownload?: () => void }) { variant="ghost" onClick={onDownload} aria-label={t("editors:downloadFile")} - className="h-11 w-11 p-0 cursor-pointer sm:h-8 sm:w-8" + className="h-6 w-6 p-0 cursor-pointer max-md:h-11 [@media(pointer:coarse)]:h-11 max-md:w-11 [@media(pointer:coarse)]:w-11" > @@ -265,7 +266,7 @@ function PreviewButton({ disabled={isHtml && isPublishingHtmlPreview} aria-label={label} title={isHtml ? t("task:htmlPreviewTrustedCode") : undefined} - className="h-8 w-8 p-0 cursor-pointer" + className="h-6 w-6 p-0 cursor-pointer max-md:h-11 [@media(pointer:coarse)]:h-11 max-md:w-11 [@media(pointer:coarse)]:w-11" data-testid={isHtml ? "html-preview-toggle" : "markdown-preview-toggle"} > {isHtml && isPublishingHtmlPreview ? ( @@ -317,21 +318,47 @@ interface MonacoEditorToolbarProps { isPublishingHtmlPreview?: boolean; } -export function MonacoEditorToolbar({ +type MonacoToolbarActionsProps = Pick< + MonacoEditorToolbarProps, + | "path" + | "repositoryName" + | "isDirty" + | "isSaving" + | "wrapEnabled" + | "showDiffIndicators" + | "enableComments" + | "sessionId" + | "lspStatus" + | "lspProgress" + | "lspLanguage" + | "showLspStatus" + | "onToggleLsp" + | "onToggleWrap" + | "onToggleDiffIndicators" + | "onSave" + | "onReloadFromAgent" + | "onDelete" + | "onDownload" + | "previewKind" + | "onTogglePreview" + | "onPreviewHtml" + | "isPublishingHtmlPreview" + | "commentCount" + | "hasRemoteUpdate" + | "hasVcsDiff" +> & { + fileStatus?: { old_path?: string | null; status?: string | null }; +}; + +function MonacoToolbarActions({ path, repositoryName, - worktreePath, - isSymlink, isDirty, isSaving, - diffStats, wrapEnabled, showDiffIndicators, enableComments, sessionId, - commentCount, - hasRemoteUpdate = false, - hasVcsDiff = false, lspStatus, lspProgress, lspLanguage, @@ -347,67 +374,149 @@ export function MonacoEditorToolbar({ onTogglePreview, onPreviewHtml, isPublishingHtmlPreview, -}: MonacoEditorToolbarProps) { + commentCount, + hasRemoteUpdate, + hasVcsDiff, + fileStatus, +}: MonacoToolbarActionsProps) { + return ( +
+ + {showLspStatus ? ( + + ) : null} + {(isDirty || hasVcsDiff) && ( + + )} + {(onTogglePreview || onPreviewHtml) && ( + + )} + + + + + + + +
+ ); +} + +function MonacoToolbarOverflowPrimaryActions({ + showLspStatus, + lspStatus, + lspProgress, + lspLanguage, + onToggleLsp, + isDirty, + isSaving, + onSave, +}: Pick< + MonacoEditorToolbarProps, + | "showLspStatus" + | "lspStatus" + | "lspProgress" + | "lspLanguage" + | "onToggleLsp" + | "isDirty" + | "isSaving" + | "onSave" +>) { + return ( +
+ {showLspStatus !== false ? ( + + ) : null} + +
+ ); +} + +export function MonacoEditorToolbar(props: MonacoEditorToolbarProps) { + const { path, sessionId, repositoryName } = props; const fileStatus = useExternalVcsFileStatus(path, sessionId, repositoryName); + const overflowActions = ( + + ); return ( } - right={ -
- - {showLspStatus ? ( - - ) : null} - {(isDirty || hasVcsDiff) && ( - - )} - {(onTogglePreview || onPreviewHtml) && ( - - )} - - - - - - - -
+ right={} + rightWhenOverflow={ + } + overflow={overflowActions} + overflowAt={520} /> ); } diff --git a/apps/web/components/page-shell.tsx b/apps/web/components/page-shell.tsx index 2a30275ba3..c70143d487 100644 --- a/apps/web/components/page-shell.tsx +++ b/apps/web/components/page-shell.tsx @@ -20,6 +20,10 @@ type PageShellProps = { leftActions?: ReactNode; actions?: ReactNode; overflowActions?: ReactNode; + /** Raw menu items shown only when the topbar action budget is exceeded. */ + overflowMenuItems?: ReactNode; + /** Primary action retained beside the overflow menu at narrow widths. */ + overflowPrimaryAction?: ReactNode; className?: string; showStatusTrigger?: boolean; /** `data-testid` on the topbar header (e2e anchors like `office-topbar`). */ diff --git a/apps/web/components/page-topbar.tsx b/apps/web/components/page-topbar.tsx index 2dd6afc53a..e2cfd9990c 100644 --- a/apps/web/components/page-topbar.tsx +++ b/apps/web/components/page-topbar.tsx @@ -74,6 +74,10 @@ type PageTopbarProps = { * they unmount and remount when the fold engages. */ overflowActions?: ReactNode; + /** Raw menu items shown only when the action budget is exceeded. */ + overflowMenuItems?: ReactNode; + /** Primary action retained beside the overflow menu when it is open. */ + overflowPrimaryAction?: ReactNode; className?: string; /** * Which zone absorbs the bar's leftover width. "lead" (default) lets the @@ -472,22 +476,55 @@ type TopbarRightZoneProps = { zoneRef: RefObject; actions?: ReactNode; overflowActions?: ReactNode; + overflowMenuItems?: ReactNode; + overflowPrimaryAction?: ReactNode; actionsOverflowed: boolean; actionsClassName?: string; claimsFreeWidth: boolean; showStatusTrigger: boolean; }; +function TopbarActionCluster({ + actions, + overflowActions, + overflowMenuItems, + overflowPrimaryAction, + actionsOverflowed, + actionsClassName, +}: Pick< + TopbarRightZoneProps, + | "actions" + | "overflowActions" + | "overflowMenuItems" + | "overflowPrimaryAction" + | "actionsOverflowed" + | "actionsClassName" +>) { + const showOverflowMenu = overflowMenuItems != null && actionsOverflowed; + return ( +
+ {!actionsOverflowed && overflowActions} + {showOverflowMenu ? overflowPrimaryAction : actions} + {actionsOverflowed && overflowActions != null && ( + {overflowActions} + )} + {showOverflowMenu && {overflowMenuItems}} +
+ ); +} + function TopbarRightZone({ zoneRef, actions, overflowActions, + overflowMenuItems, + overflowPrimaryAction, actionsOverflowed, actionsClassName, claimsFreeWidth, showStatusTrigger, }: TopbarRightZoneProps) { - const hasCluster = Boolean(actions || overflowActions); + const hasCluster = Boolean(actions || overflowActions || overflowMenuItems); if (!hasCluster && !showStatusTrigger) return null; return ( // Default: no `min-w-0`, so the zone's automatic minimum is its min-content @@ -502,13 +539,14 @@ function TopbarRightZone({ className={cn("flex items-center gap-3", claimsFreeWidth ? "min-w-0 grow" : "shrink")} > {hasCluster && ( -
- {!actionsOverflowed && overflowActions} - {actions} - {actionsOverflowed && overflowActions != null && ( - {overflowActions} - )} -
+ )} {showStatusTrigger ? ( @@ -555,6 +593,8 @@ export const PageTopbar = forwardRef(function Page leftActions, actions, overflowActions, + overflowMenuItems, + overflowPrimaryAction, className, freeWidth = "lead", centerClassName, @@ -571,7 +611,8 @@ export const PageTopbar = forwardRef(function Page const rightZoneRef = useRef(null); const actionsClaimFreeWidth = freeWidth === "actions"; // Measurement only matters once there is something that can fold. - const measured = (parents?.length ?? 0) > 0 || overflowActions != null; + const measured = + (parents?.length ?? 0) > 0 || overflowActions != null || overflowMenuItems != null; const pressure = useTopbarPressure( { leadZone: leadZoneRef, ghost: ghostRef, rightZone: rightZoneRef }, measured, @@ -631,6 +672,8 @@ export const PageTopbar = forwardRef(function Page zoneRef={rightZoneRef} actions={actions} overflowActions={overflowActions} + overflowMenuItems={overflowMenuItems} + overflowPrimaryAction={overflowPrimaryAction} actionsOverflowed={pressure.actionsOverflowed} actionsClassName={actionsClassName} claimsFreeWidth={actionsClaimFreeWidth} diff --git a/apps/web/components/plugins/web-app-frame.test.tsx b/apps/web/components/plugins/web-app-frame.test.tsx index 43acc687bc..a801e7c1da 100644 --- a/apps/web/components/plugins/web-app-frame.test.tsx +++ b/apps/web/components/plugins/web-app-frame.test.tsx @@ -44,14 +44,13 @@ afterEach(() => { }); describe("WebAppFrame startup", () => { - it("uses an opaque sandbox and does not send host capabilities to the iframe", () => { + it("uses the trusted same-origin sandbox without adding host permissions", () => { render( , ); const frame = screen.getByTitle("Task board"); - expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms"); - expect(frame.getAttribute("allow-same-origin")).toBeNull(); + expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms allow-same-origin"); expect(frame.getAttribute("allow")).toBeNull(); expect(frame.getAttribute("referrerpolicy")).toBe("no-referrer"); expect(frame.getAttribute("src")).toContain("/api/v1/plugins/web-apps/runtime/"); diff --git a/apps/web/components/plugins/web-app-frame.tsx b/apps/web/components/plugins/web-app-frame.tsx index 3a35718bb5..f8f42d5b4c 100644 --- a/apps/web/components/plugins/web-app-frame.tsx +++ b/apps/web/components/plugins/web-app-frame.tsx @@ -143,8 +143,8 @@ function useWebAppFrameLifecycle({ /** * Hosts a static plugin web application without bringing it into the SPA - * process. The sandbox deliberately omits allow-same-origin, top navigation, - * popups, downloads, and a privileged host bridge. + * process. The sandbox preserves same-origin requests while omitting top + * navigation, popups, downloads, and a privileged host bridge. */ export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: WebAppFrameProps) { const { isMobile } = useResponsiveBreakpoint(); @@ -184,7 +184,7 @@ export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: W title={title} src={runtimeUrl} ref={iframeRef} - sandbox="allow-scripts allow-forms" + sandbox="allow-scripts allow-forms allow-same-origin" referrerPolicy="no-referrer" loading="eager" className="block h-full min-h-0 w-full min-w-0 flex-1 border-0" diff --git a/apps/web/components/settings/canvas-host-actions.tsx b/apps/web/components/settings/canvas-host-actions.tsx new file mode 100644 index 0000000000..8bd6940850 --- /dev/null +++ b/apps/web/components/settings/canvas-host-actions.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { IconDots } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { useTranslation } from "react-i18next"; +import type { Canvas } from "@/lib/api/domains/canvas-api"; +import { CanvasPromotionDialog, CanvasReleaseDialog } from "./canvas-lifecycle-dialogs"; + +export function CanvasHostDialogs({ + canvas, + promotionOpen, + onPromotionOpenChange, + releasesOpen, + onReleasesOpenChange, + onPromotionCompleted, + onChanged, +}: { + canvas: Canvas | null; + promotionOpen: boolean; + onPromotionOpenChange: (open: boolean) => void; + releasesOpen: boolean; + onReleasesOpenChange: (open: boolean) => void; + onPromotionCompleted: () => void; + onChanged: () => void; +}) { + return ( + <> + + + + ); +} + +export function CanvasMobileActionsButton({ + menuOpen, + onOpenActions, +}: { + menuOpen: boolean; + onOpenActions: () => void; +}) { + const { t } = useTranslation(); + return ( + + ); +} diff --git a/apps/web/components/settings/canvas-host-components.test.tsx b/apps/web/components/settings/canvas-host-components.test.tsx index addc18bc84..9ac02b1d8f 100644 --- a/apps/web/components/settings/canvas-host-components.test.tsx +++ b/apps/web/components/settings/canvas-host-components.test.tsx @@ -1,6 +1,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { DropdownMenu, DropdownMenuContent } from "@kandev/ui/dropdown-menu"; import type { Canvas } from "@/lib/api/domains/canvas-api"; const COPY: Record = { @@ -20,6 +21,10 @@ const COPY: Record = { "canvases:shareCanvas": "Share canvas", "canvases:shareCanvasDescription": "Prepare a verified bundle or source archive for review and sharing.", + "canvases:ready": "Ready", + "canvases:unavailable": "Canvas unavailable", + "canvases:unavailableDescription": "The canvas runtime is unavailable.", + "canvases:retry": "Try again", }; vi.mock("react-i18next", () => ({ @@ -42,7 +47,14 @@ vi.mock("./canvas-lifecycle-dialogs", () => ({ CanvasReleaseDialog: () => null, })); -import { CanvasDesktopActions, MobileCanvasActions } from "./canvas-host-components"; +import { + CanvasDesktopActions, + CanvasDesktopOverflowMenuItems, + CanvasHostBody, + CanvasHostHeader, + CanvasHostStatePanel, + MobileCanvasActions, +} from "./canvas-host-components"; const canvas: Canvas = { id: "canvas-1", @@ -83,6 +95,45 @@ describe("canvas host action guidance", () => { ).toBeTruthy(); }); + it("describes disabled overflow actions to assistive technology", () => { + const workspaceCanvas = { + ...canvas, + scope_kind: "workspace" as const, + status: "archived" as const, + }; + const taskCanvas = { ...canvas, active_release_status: "pending_permission" as const }; + + render( + + + + + + , + ); + + const edit = screen.getAllByRole("menuitem", { name: /Edit canvas/ })[0]; + const promote = screen.getAllByRole("menuitem", { name: /Promote canvas/ })[0]; + expect(edit.getAttribute("aria-describedby")).toBe("canvas-edit-overflow-help-canvas-1"); + expect(promote.getAttribute("aria-describedby")).toBe("canvas-promote-overflow-help-canvas-1"); + expect(screen.getByText(/archived.*changing it/i)).toBeTruthy(); + expect(screen.getByText(/valid release/i)).toBeTruthy(); + }); + it("shows lifecycle descriptions in the mobile action drawer", () => { render( { ); }); }); + +describe("canvas host chrome", () => { + it("keeps state content in the body and leaves the shared header for title/actions", () => { + render( + <> + Release actions} + /> + + , + ); + + expect(screen.getByTestId("canvas-host-header").textContent).toContain("Task canvas"); + expect(screen.getByTestId("canvas-host-header").textContent).toContain("Release actions"); + expect(screen.getByTestId("canvas-host-state").textContent).toContain("Canvas unavailable"); + expect( + screen + .getByTestId("canvas-host-state-panel") + .contains(screen.getByTestId("canvas-host-state")), + ).toBe(true); + }); + + it("announces readiness politely without adding a visible status toolbar", () => { + render( + , + ); + + const announcement = screen.getByTestId("canvas-host-ready-announcement"); + expect(announcement.textContent).toContain("Ready"); + expect(announcement.parentElement?.getAttribute("role")).toBe("status"); + expect(announcement.parentElement?.getAttribute("aria-live")).toBe("polite"); + expect(screen.getByTestId("canvas-host-route").textContent).not.toContain("Canvas unavailable"); + }); +}); diff --git a/apps/web/components/settings/canvas-host-components.tsx b/apps/web/components/settings/canvas-host-components.tsx index 01a7e1bab0..b6bb0c2a63 100644 --- a/apps/web/components/settings/canvas-host-components.tsx +++ b/apps/web/components/settings/canvas-host-components.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from "react"; import { - IconDots, IconEdit, IconExternalLink, IconLayoutGrid, @@ -13,11 +12,19 @@ import { import { useTranslation } from "react-i18next"; import { controlSizingClassName } from "@kandev/ui/control-sizing"; import { Button } from "@kandev/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet"; +import { PanelHeaderBarSplit } from "@/components/task/panel-primitives"; import { CanvasPage } from "@/components/plugins/canvas-page"; import { canvasHref, type Canvas } from "@/lib/api/domains/canvas-api"; -import { CanvasPromotionDialog, CanvasReleaseDialog } from "./canvas-lifecycle-dialogs"; +import { CanvasMobileActionsButton } from "./canvas-host-actions"; +import { canvasLockHelp, canvasPromotionHelp } from "./canvas-host-desktop-actions"; +export { CanvasHostDialogs, CanvasMobileActionsButton } from "./canvas-host-actions"; +export { + CanvasDesktopActions, + CanvasDesktopOverflowActions, + CanvasDesktopOverflowMenuItems, + CanvasDesktopPrimaryAction, +} from "./canvas-host-desktop-actions"; export type CanvasHostState = | "loading_metadata" @@ -57,84 +64,6 @@ const STATE_COPY: Record void; - releasesOpen: boolean; - onReleasesOpenChange: (open: boolean) => void; - onPromotionCompleted: () => void; - onChanged: () => void; -}) { - return ( - <> - - - - ); -} - -function canvasLockHelp(canvas: Canvas, t: (key: string) => string): string { - return canvas.status === "archived" - ? t("canvases:archivedCanvasActionHelp") - : t("canvases:disabledCanvasActionHelp"); -} - -function canvasPromotionHelp(canvas: Canvas, t: (key: string) => string): string { - if (canvas.status === "archived" || canvas.status === "disabled") { - return canvasLockHelp(canvas, t); - } - if (canvas.scope_kind === "task" && canvas.active_release_status === "valid") { - return t("canvases:promoteCanvasHelp"); - } - return t("canvases:promoteCanvasUnavailable"); -} - -function CanvasDesktopActionTooltip({ - description, - disabled, - testId, - children, -}: { - description: string; - disabled: boolean; - testId: string; - children: ReactNode; -}) { - return ( - - - - {children} - - - {description} - - ); -} - function MobileCanvasAction({ description, children, @@ -158,11 +87,8 @@ export function CanvasHostBody({ canvasId, title, state, - isMobile, - menuOpen, runtimeUrl, error, - onOpenActions, onRuntimeReady, onRuntimeError, onRetry, @@ -170,29 +96,30 @@ export function CanvasHostBody({ canvasId: string; title: string; state: CanvasHostState; - isMobile: boolean; - menuOpen: boolean; runtimeUrl: string | null; error: string | null; - onOpenActions: () => void; onRuntimeReady: () => void; onRuntimeError: () => void; onRetry: () => void; }) { + const { t } = useTranslation(); return (
- -
- {(state === "loading_runtime" || state === "ready") && runtimeUrl ? ( + {(state === "loading_runtime" || state === "ready") && runtimeUrl ? ( + <> + {state === "ready" && ( + + {t(STATE_COPY.ready.title)} + + )} - ) : ( - - )} -
-
- ); -} - -export function CanvasDesktopActions({ - canvas, - editing, - onEdit, - onPromote, - onReleases, - onShare, -}: { - canvas: Canvas; - editing: boolean; - onEdit: () => void; - onPromote: () => void; - onReleases: () => void; - onShare: () => void; -}) { - const { t } = useTranslation(); - const lifecycleLocked = canvas.status === "archived" || canvas.status === "disabled"; - const promoteAvailable = canvas.scope_kind === "task" && canvas.active_release_status === "valid"; - const promoteDisabled = lifecycleLocked || !promoteAvailable; - const promoteDescription = canvasPromotionHelp(canvas, t); - return ( -
- {canvas.scope_kind === "workspace" && ( - - - - )} - - - - - - - {canvas.scope_kind === "task" && ( - - - + + ) : ( + )}
); @@ -291,47 +137,34 @@ export function CanvasDesktopActions({ export function CanvasHostHeader({ title, - state, isMobile, menuOpen, onOpenActions, + actions, + overflowActions, }: { title: string; - state: CanvasHostState; isMobile: boolean; menuOpen: boolean; onOpenActions: () => void; + actions?: ReactNode; + overflowActions?: ReactNode; }) { - const { t } = useTranslation(); return ( -
-
-

{title}

-

- {t(STATE_COPY[state].title)} -

-
- {isMobile && ( - - )} -
+ left={{title}} + right={ + <> + {actions} + {isMobile && ( + + )} + + } + overflow={!isMobile ? overflowActions : undefined} + overflowAt={520} + /> ); } @@ -347,9 +180,14 @@ export function CanvasHostStatePanel({ const { t } = useTranslation(); const copy = STATE_COPY[state]; return ( -
+
-

{t(copy.title)}

+

+ {t(copy.title)} +

{error || t(copy.description)}

{state !== "loading_metadata" && state !== "loading_runtime" && ( + + )} + + + + + + + {canvas.scope_kind === "task" && ( + + + + )} +
+ ); +} + +type CanvasDesktopOverflowActionsProps = { + canvas: Canvas; + editing: boolean; + onEdit: () => void; + onPromote: () => void; + onReleases: () => void; + onShare: () => void; + omitPrimaryAction?: boolean; +}; + +export function CanvasDesktopPrimaryAction({ + canvas, + editing, + onEdit, + onPromote, +}: Pick) { + const { t } = useTranslation(); + const lifecycleLocked = canvas.status === "archived" || canvas.status === "disabled"; + if (canvas.scope_kind === "workspace") { + const editDisabled = editing || lifecycleLocked; + return ( + + + + ); + } + + const promoteAvailable = canvas.active_release_status === "valid"; + const promoteDisabled = lifecycleLocked || !promoteAvailable; + const promoteDescription = canvasPromotionHelp(canvas, t); + return ( + + + + ); +} + +function CanvasDesktopOverflowMenuItemsContent({ + canvas, + editing, + onEdit, + onPromote, + onReleases, + onShare, + omitPrimaryAction, + t, +}: CanvasDesktopOverflowActionsProps & { t: (key: string) => string }) { + const lifecycleLocked = canvas.status === "archived" || canvas.status === "disabled"; + const promoteAvailable = canvas.scope_kind === "task" && canvas.active_release_status === "valid"; + const promoteDisabled = lifecycleLocked || !promoteAvailable; + const promoteDescription = canvasPromotionHelp(canvas, t); + const editDescription = lifecycleLocked + ? canvasLockHelp(canvas, t) + : t("canvases:editCanvasHelp"); + const editDisabled = editing || lifecycleLocked; + const editHelpId = `canvas-edit-overflow-help-${canvas.id}`; + const promoteHelpId = `canvas-promote-overflow-help-${canvas.id}`; + + return ( + <> + {canvas.scope_kind === "workspace" && !omitPrimaryAction && ( + + + {t("canvases:editCanvas")} + {editDisabled && ( + + {editDescription} + + )} + + )} + + + {t("canvases:releasesAndPermissions")} + + + + {t("canvases:shareCanvas")} + + {canvas.scope_kind === "task" && !omitPrimaryAction && ( + + + {t("canvases:promoteCanvas")} + {promoteDisabled && ( + + {promoteDescription} + + )} + + )} + + ); +} + +export function CanvasDesktopOverflowMenuItems(props: CanvasDesktopOverflowActionsProps) { + const { t } = useTranslation(); + return ; +} + +export function CanvasDesktopOverflowActions(props: CanvasDesktopOverflowActionsProps) { + const { t } = useTranslation(); + return ( + + + + ); +} diff --git a/apps/web/components/settings/canvas-host-frame.tsx b/apps/web/components/settings/canvas-host-frame.tsx new file mode 100644 index 0000000000..8e89eb2314 --- /dev/null +++ b/apps/web/components/settings/canvas-host-frame.tsx @@ -0,0 +1,75 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { PageShell } from "@/components/page-shell"; +import { PanelRoot } from "@/components/task/panel-primitives"; +import { CanvasHostHeader } from "./canvas-host-components"; + +export function CanvasHostFrame({ + embedded, + isMobile, + title, + menuOpen, + setMenuOpen, + desktopActions, + desktopOverflowActions, + desktopOverflowMenuItems, + desktopOverflowPrimaryAction, + mobileActionsButton, + canvasBody, + mobileActions, + dialogs, +}: { + embedded: boolean; + isMobile: boolean; + title: string; + menuOpen: boolean; + setMenuOpen: (open: boolean) => void; + desktopActions: ReactNode; + desktopOverflowActions: ReactNode; + desktopOverflowMenuItems: ReactNode; + desktopOverflowPrimaryAction: ReactNode; + mobileActionsButton: ReactNode; + canvasBody: ReactNode; + mobileActions: ReactNode; + dialogs: ReactNode; +}) { + const { t } = useTranslation(); + if (embedded) { + return ( + + setMenuOpen(true)} + actions={isMobile ? null : desktopActions} + overflowActions={isMobile ? null : desktopOverflowActions} + /> + {canvasBody} + {mobileActions} + {dialogs} + + ); + } + + return ( + + {canvasBody} + {mobileActions} + {dialogs} + + ); +} diff --git a/apps/web/components/settings/canvas-host-route-view.tsx b/apps/web/components/settings/canvas-host-route-view.tsx new file mode 100644 index 0000000000..df53946e9c --- /dev/null +++ b/apps/web/components/settings/canvas-host-route-view.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { CanvasShareDialog } from "./canvas-share-dialog"; +import { CanvasHostFrame } from "./canvas-host-frame"; +import { useTranslation } from "react-i18next"; +import { + CanvasDesktopActions, + CanvasDesktopOverflowActions, + CanvasDesktopOverflowMenuItems, + CanvasDesktopPrimaryAction, + CanvasHostBody, + CanvasHostDialogs, + CanvasMobileActionsButton, + MobileCanvasActions, + type CanvasHostState, +} from "./canvas-host-components"; +import { type Canvas } from "@/lib/api/domains/canvas-api"; + +type CanvasHostRouteViewProps = { + canvasId: string; + embedded: boolean; + isMobile: boolean; + canvas: Canvas | null; + hostCanvases: Canvas[]; + runtimeUrl: string | null; + state: CanvasHostState; + error: string | null; + menuOpen: boolean; + promotionOpen: boolean; + releasesOpen: boolean; + shareOpen: boolean; + editing: boolean; + setMenuOpen: (open: boolean) => void; + setPromotionOpen: (open: boolean) => void; + setReleasesOpen: (open: boolean) => void; + setShareOpen: (open: boolean) => void; + onEdit: () => void; + onPromote: () => void; + onReleases: () => void; + onShare: () => void; + onSelectCanvas: (canvas: Canvas) => void; + onRuntimeReady: () => void; + onRuntimeError: () => void; + onRetry: () => void; + onPromotionCompleted: () => void; + onChanged: () => void; +}; + +function CanvasHostRouteDialogs({ + canvas, + promotionOpen, + setPromotionOpen, + releasesOpen, + setReleasesOpen, + shareOpen, + setShareOpen, + onPromotionCompleted, + onChanged, +}: Pick< + CanvasHostRouteViewProps, + | "canvas" + | "promotionOpen" + | "setPromotionOpen" + | "releasesOpen" + | "setReleasesOpen" + | "shareOpen" + | "setShareOpen" + | "onPromotionCompleted" + | "onChanged" +>) { + return ( + <> + + + + ); +} + +function CanvasHostRouteBody({ + canvasId, + title, + state, + runtimeUrl, + error, + onRuntimeReady, + onRuntimeError, + onRetry, +}: Pick< + CanvasHostRouteViewProps, + "canvasId" | "runtimeUrl" | "state" | "error" | "onRuntimeReady" | "onRuntimeError" | "onRetry" +> & { title: string }) { + return ( + + ); +} + +function CanvasHostRouteMobileActions({ + canvas, + menuOpen, + setMenuOpen, + onEdit, + onPromote, + onReleases, + onShare, + editing, + hostCanvases, + onSelectCanvas, +}: Pick< + CanvasHostRouteViewProps, + | "canvas" + | "menuOpen" + | "setMenuOpen" + | "onEdit" + | "onPromote" + | "onReleases" + | "onShare" + | "editing" + | "onSelectCanvas" +> & { hostCanvases: Canvas[] }) { + return ( + + ); +} + +function createCanvasDesktopActions(canvas: Canvas | null, props: CanvasHostRouteViewProps) { + if (!canvas) { + return { + actions: null, + overflowActions: null, + overflowMenuItems: null, + overflowPrimaryAction: null, + }; + } + + const actionProps = { + canvas, + editing: props.editing, + onEdit: props.onEdit, + onPromote: props.onPromote, + onReleases: props.onReleases, + onShare: props.onShare, + }; + return { + actions: , + overflowActions: , + overflowMenuItems: , + overflowPrimaryAction: , + }; +} + +export function CanvasHostRouteView(props: CanvasHostRouteViewProps) { + const { canvas, isMobile, menuOpen, setMenuOpen } = props; + const { t } = useTranslation(); + const title = canvas?.title || t("canvases:canvas"); + const desktopActions = createCanvasDesktopActions(canvas, props); + const mobileActionsButton = isMobile ? ( + setMenuOpen(true)} /> + ) : null; + const canvasBody = ( + + ); + const mobileActions = ( + + ); + const dialogs = ( + + ); + + return ( + + ); +} diff --git a/apps/web/components/settings/canvas-host-route.test.tsx b/apps/web/components/settings/canvas-host-route.test.tsx index 5b513a072a..0c95aa668c 100644 --- a/apps/web/components/settings/canvas-host-route.test.tsx +++ b/apps/web/components/settings/canvas-host-route.test.tsx @@ -1,6 +1,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DropdownMenu, DropdownMenuContent } from "@kandev/ui/dropdown-menu"; import type { Canvas } from "@/lib/api/domains/canvas-api"; import { registerCanvasesHandlers } from "@/lib/ws/handlers/canvases"; @@ -11,6 +12,7 @@ const { mockListWorkspaceCanvases, mockPush, mockIsMobile, + mockRenderPageShellOverflow, } = vi.hoisted(() => ({ mockGetCanvas: vi.fn(), mockGetCanvasRuntime: vi.fn(), @@ -18,6 +20,7 @@ const { mockListWorkspaceCanvases: vi.fn(), mockPush: vi.fn(), mockIsMobile: { value: false }, + mockRenderPageShellOverflow: { value: false }, })); const FRAME_TEST_ID = "canvas-frame"; @@ -33,7 +36,29 @@ vi.mock("@/lib/api/domains/canvas-api", () => ({ })); vi.mock("@/components/page-shell", () => ({ - PageShell: ({ children }: { children: ReactNode }) =>
{children}
, + PageShell: ({ + actions, + children, + overflowMenuItems, + topbarTestId, + }: { + actions?: ReactNode; + children: ReactNode; + overflowMenuItems?: ReactNode; + topbarTestId?: string; + }) => ( +
+
{mockIsMobile.value ? actions : null}
+ {overflowMenuItems && mockRenderPageShellOverflow.value ? ( + + + {overflowMenuItems} + + + ) : null} + {children} +
+ ), })); vi.mock("@/components/plugins/canvas-page", () => ({ @@ -106,6 +131,7 @@ beforeEach(() => { mockListTaskCanvases.mockReset().mockResolvedValue({ canvases: [canvas] }); mockListWorkspaceCanvases.mockReset().mockResolvedValue({ canvases: [] }); mockIsMobile.value = false; + mockRenderPageShellOverflow.value = false; mockPush.mockReset(); }); @@ -167,6 +193,16 @@ describe("CanvasHostRoute runtime recovery", () => { }); }); + it("passes raw desktop overflow items to the standalone page shell", async () => { + mockRenderPageShellOverflow.value = true; + render(); + + await waitFor(() => expect(screen.getByTestId(FRAME_TEST_ID)).toBeTruthy()); + + expect(screen.getByRole("menuitem", { name: "Releases and permissions" })).toBeTruthy(); + expect(screen.queryByTestId("panel-header-overflow")).toBeNull(); + }); + it("refreshes the visible host when a canvas lifecycle event arrives", async () => { const updated = { ...canvas, active_release_id: "release-2" }; mockGetCanvas.mockReset().mockResolvedValueOnce(canvas).mockResolvedValueOnce(updated); diff --git a/apps/web/components/settings/canvas-host-route.tsx b/apps/web/components/settings/canvas-host-route.tsx index 12f8c4a05e..e1b64c5004 100644 --- a/apps/web/components/settings/canvas-host-route.tsx +++ b/apps/web/components/settings/canvas-host-route.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { PageShell } from "@/components/page-shell"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useRouter } from "@/lib/routing/client-router"; import { @@ -16,14 +15,8 @@ import { import { canvasErrorMessage } from "@/lib/api/domains/canvas-error-copy"; import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle"; import { useCanvasHostCanvases } from "./canvas-host-picker"; -import { - CanvasDesktopActions, - CanvasHostBody, - CanvasHostDialogs, - MobileCanvasActions, - type CanvasHostState, -} from "./canvas-host-components"; -import { CanvasShareDialog } from "./canvas-share-dialog"; +import { type CanvasHostState } from "./canvas-host-components"; +import { CanvasHostRouteView } from "./canvas-host-route-view"; function stateForCanvas(canvas: Canvas): CanvasHostState { if (canvas.status === "archived") return "archived"; @@ -392,7 +385,13 @@ async function editCanvasFromHost(options: CanvasHostEditOptions): Promise } } -export function CanvasHostRoute({ canvasId }: { canvasId: string }) { +export function CanvasHostRoute({ + canvasId, + embedded = false, +}: { + canvasId: string; + embedded?: boolean; +}) { const { t } = useTranslation(); const router = useRouter(); const { isMobile } = useResponsiveBreakpoint(); @@ -430,63 +429,35 @@ export function CanvasHostRoute({ canvasId }: { canvasId: string }) { [canvasId, router], ); - const title = canvas?.title || t("canvases:canvas"); - const desktopActions = canvas ? ( - void edit()} onPromote={() => setPromotionOpen(true)} onReleases={() => setReleasesOpen(true)} onShare={() => setShareOpen(true)} + onSelectCanvas={selectCanvas} + onRuntimeReady={markRuntimeReady} + onRuntimeError={markRuntimeUnavailable} + onRetry={load} + onPromotionCompleted={() => router.push(canvas ? canvasHref(canvas.id) : "/")} + onChanged={load} /> - ) : null; - - return ( - - setMenuOpen(true)} - onRuntimeReady={markRuntimeReady} - onRuntimeError={markRuntimeUnavailable} - onRetry={load} - /> - void edit()} - onPromote={() => setPromotionOpen(true)} - onReleases={() => setReleasesOpen(true)} - onShare={() => setShareOpen(true)} - editing={editing} - canvases={hostCanvases} - onSelectCanvas={selectCanvas} - /> - router.push(canvas ? canvasHref(canvas.id) : "/")} - onChanged={load} - /> - - ); } diff --git a/apps/web/components/task/bottom-terminal-panel.tsx b/apps/web/components/task/bottom-terminal-panel.tsx index 26b1a08a21..70204b6fc9 100644 --- a/apps/web/components/task/bottom-terminal-panel.tsx +++ b/apps/web/components/task/bottom-terminal-panel.tsx @@ -5,6 +5,7 @@ import { IconMinus } from "@tabler/icons-react"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useEnvironmentId } from "@/hooks/use-environment-session-id"; import { PassthroughTerminal } from "./passthrough-terminal"; +import { PanelHeaderBar } from "./panel-primitives"; import { Button } from "@kandev/ui/button"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; @@ -87,13 +88,17 @@ export function BottomTerminalPanel() { className="absolute top-0 left-0 right-0 h-1 cursor-ns-resize hover:bg-primary/20 z-10" onMouseDown={handleMouseDown} /> - {/* Header */} -
+ {t("task:terminal")} - -
+ {/* Terminal content */}
(null); const inspect = useInspectMode(iframeRef); usePreviewConsoleForwarder(iframeRef); @@ -151,51 +156,9 @@ export const BrowserPanel = memo(function BrowserPanel({ params }: BrowserPanelP // can be injected". Toggling Inspect remounts the iframe with a different src. const url = useBrowserPanelUrl((params.url as string) || "", inspect.isInspectMode); const showInspect = url.canProxy; - return ( - - url.setUrlDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - url.handleUrlSubmit(); - } - }} - placeholder={url.detectedUrl || "http://localhost:3000"} - className="h-6 flex-1 min-w-[180px]" - /> - - - {showInspect && ( - - )} - + ); }); + +function BrowserPanelHeader({ + url, + inspect, + showInspect, +}: { + url: ReturnType; + inspect: ReturnType; + showInspect: boolean; +}) { + const { t } = useTranslation(); + const directActions = ( + <> + + + {showInspect && ( + + )} + + ); + const overflowActions = ( + + + + {t("task:openInBrowserTab")} + + url.setRefreshKey((v) => v + 1)} + > + + {t("task:refresh")} + + {showInspect && ( + + + {inspect.isInspectMode ? t("task:exitInspectMode") : t("task:enterInspectMode")} + + )} + + ); + + return ( + url.setUrlDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + url.handleUrlSubmit(); + } + }} + placeholder={url.detectedUrl || "http://localhost:3000"} + className="h-6 min-w-0 flex-1" + /> + } + right={directActions} + overflow={overflowActions} + overflowAt={420} + /> + ); +} diff --git a/apps/web/components/task/changes-panel-header-actions.tsx b/apps/web/components/task/changes-panel-header-actions.tsx new file mode 100644 index 0000000000..2391b39c1a --- /dev/null +++ b/apps/web/components/task/changes-panel-header-actions.tsx @@ -0,0 +1,559 @@ +"use client"; + +import { useState } from "react"; +import { + IconCloudDownload, + IconEye, + IconChevronDown, + IconGitBranch, + IconGitMerge, + IconArrowRight, + IconLoader2, + IconEdit, + IconRoute, +} from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@kandev/ui/drawer"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { PanelHeaderOverflowMenu } from "./panel-primitives"; +import { BaseBranchPicker } from "./base-branch-picker"; +import type { GitCredentialDisplay } from "./changes-git-credential-display"; +import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; +import { useTranslation } from "react-i18next"; +import { PerRepoPullMenu } from "./changes-panel-per-repo-menu"; +import { ComparisonTargetDisplay } from "./changes-panel-comparison-target"; +import type { BranchRow, PerRepoStatus } from "./changes-panel-branch-rows"; + +const ACTION_MENU_ITEM_CLASS = "cursor-pointer gap-2"; +const PANEL_ACTION_BUTTON_CLASS = + "h-6 min-h-6 text-[11px] px-1.5 gap-1 cursor-pointer max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"; +const WALKTHROUGH_LABEL_KEY = "task:walkMeThroughTheseChanges"; +const REVIEW_LABEL_KEY = "task:filterStateReview"; + +export type RenameBranchResult = { + success: boolean; + error?: string; +}; + +function RenameBranchButton({ + branch, + repositoryName, + onRenameBranch, + isRenaming, +}: { + branch: string; + repositoryName: string; + onRenameBranch?: (newName: string, repo: string) => Promise; + isRenaming: boolean; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [newBranchName, setNewBranchName] = useState(branch); + const [error, setError] = useState(null); + const trimmedBranchName = newBranchName.trim(); + const canRename = !!onRenameBranch && trimmedBranchName !== "" && trimmedBranchName !== branch; + const submitRename = async () => { + if (!onRenameBranch || !canRename) return; + setError(null); + try { + const result = await onRenameBranch(trimmedBranchName, repositoryName); + if (!result.success) { + setError(result.error || t("task:failedToRenameBranch")); + return; + } + setOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : t("task:failedToRenameBranch")); + } + }; + const openDialog = () => { + setNewBranchName(branch); + setError(null); + setOpen(true); + }; + return ( + <> + + + + + {t("task:editBranch2")} + +
+ + setNewBranchName(event.target.value)} + autoFocus + /> + {error &&

{error}

} +
+ + + + +
+
+ + ); +} + +function BranchRowView({ + repoLabel, + branch, + baseBranch, + repositoryName, + taskId, + onRenameBranch, + isRenaming, +}: BranchRow & { + taskId: string | null; + onRenameBranch?: (newName: string, repo: string) => Promise; + isRenaming: boolean; +}) { + return ( +
+ {repoLabel && ( + + {repoLabel} + + )} + + + {branch} + + +
+
+ +
+ +
+ ); +} + +export function BranchHoverCard({ + displayBranch, + baseBranchDisplay, + rows, + taskId, + onRenameBranch, + isRenaming, + credentialDisplay, + comparisonTargets, +}: { + displayBranch: string; + baseBranchDisplay: string; + rows?: BranchRow[]; + taskId: string | null; + onRenameBranch?: (newName: string, repo: string) => Promise; + isRenaming: boolean; + credentialDisplay: GitCredentialDisplay | null; + comparisonTargets: string[]; +}) { + const { t } = useTranslation(); + const usesTouchDrawer = useTouchDrawer(); + const [open, setOpen] = useState(false); + const trigger = ( + + ); + const content = ( +
+
+ + {rows && rows.length > 0 ? t("task:yourBranchesLabel") : t("task:yourCodeLivesInLabel")} + + {t("task:comparingAgainstLabel")} +
+ {rows && rows.length > 0 ? ( +
+ {rows!.map((row) => ( + + ))} +
+ ) : ( + + )} + {credentialDisplay && ( +
+

{credentialDisplay.source}

+

{credentialDisplay.detail}

+

{credentialDisplay.transport}

+
+ )} + +
+ ); + if (usesTouchDrawer) { + return ( + + {trigger} + + + {t("task:branchDetails")} + {t("task:branchComparisonAndTaskGitCredentials")} + +
{content}
+
+
+ ); + } + return ( + + {trigger} + + {content} + + + ); +} + +function PullTriggerContent({ + behindCount, + isPulling, + isRebasing, +}: { + behindCount: number; + isPulling: boolean; + isRebasing: boolean; +}) { + let label: string; + if (isPulling) label = "Pulling…"; + else if (isRebasing) label = "Rebasing…"; + else label = "Pull"; + return ( + <> + {isPulling || isRebasing ? ( + + ) : ( + + )} + {label} + {behindCount > 0 && !(isPulling || isRebasing) && ( + {behindCount} + )} + {!(isPulling || isRebasing) && ( + + )} + + ); +} + +export function PullDropdown({ + behindCount, + pullDisabled, + isLoading, + loadingOperation, + repoNames, + perRepoStatus, + onRepoPull, + onRepoRebase, + onRepoMerge, + repoDisplayName, + pullDisabledReason, +}: { + behindCount: number; + pullDisabled?: boolean; + pullDisabledReason?: string; + isLoading: boolean; + loadingOperation: string | null; + /** Always non-empty (single-repo includes the empty-name entry). */ + repoNames: string[]; + perRepoStatus: PerRepoStatus[]; + onRepoPull: (repo: string) => void; + onRepoRebase: (repo: string) => void; + onRepoMerge: (repo: string) => void; + /** Maps a repository_name to its display label. */ + repoDisplayName?: (repositoryName: string) => string | undefined; +}) { + const { t } = useTranslation(); + const triggerBehind = + perRepoStatus.length > 0 + ? Math.max(behindCount, ...perRepoStatus.map((s) => s.pullBehind ?? 0)) + : behindCount; + const pullButton = ( + + ); + if (pullDisabled) { + return ( + + + + {pullButton} + + + + {pullDisabledReason ?? t("task:divergedActionsUnavailable")} + + + ); + } + return ( + + {pullButton} + + + + + ); +} + +export function ChangesPanelHeaderLeft({ + showDiffReview, + onOpenDiffAll, + onOpenReview, + onRequestWalkthrough, + requestWalkthroughDisabled, + primaryOnly = false, +}: { + showDiffReview: boolean; + onOpenDiffAll?: () => void; + onOpenReview?: () => void; + onRequestWalkthrough?: () => void; + requestWalkthroughDisabled?: boolean; + /** Keep the primary Review action available beside the overflow menu. */ + primaryOnly?: boolean; +}) { + const { t } = useTranslation(); + if (!showDiffReview) return null; + if (primaryOnly) { + return ( + <> + + + {onRequestWalkthrough ? ( + + ) : null} + + ); + } + return ( + <> + + + {onRequestWalkthrough ? ( + + ) : null} + + ); +} + +function ChangesPanelWalkthroughButton({ + onRequestWalkthrough, + requestWalkthroughDisabled, +}: { + onRequestWalkthrough: () => void; + requestWalkthroughDisabled?: boolean; +}) { + const { t } = useTranslation(); + const tooltip = requestWalkthroughDisabled + ? t("task:loadingChangedFiles") + : t(WALKTHROUGH_LABEL_KEY); + return ( + + + + + + + {tooltip} + + ); +} + +export type ChangesPanelHeaderActionProps = { + showDiffReview?: boolean; + onOpenDiffAll?: () => void; + onOpenReview?: () => void; + onRequestWalkthrough?: () => void; + requestWalkthroughDisabled?: boolean; +}; + +export function ChangesPanelHeaderOverflowActions({ + showDiffReview = true, + onOpenDiffAll, + onOpenReview, + onRequestWalkthrough, + requestWalkthroughDisabled, +}: ChangesPanelHeaderActionProps) { + const { t } = useTranslation(); + const walkthroughReason = requestWalkthroughDisabled + ? t("task:loadingChangedFiles") + : t(WALKTHROUGH_LABEL_KEY); + return ( + + {showDiffReview && ( + <> + onOpenDiffAll?.()} + > + + {t("task:diff")} + + onOpenReview?.()} + > + + {t(REVIEW_LABEL_KEY)} + + + )} + {onRequestWalkthrough && ( + + + {t(WALKTHROUGH_LABEL_KEY)} + + )} + + ); +} diff --git a/apps/web/components/task/changes-panel-header.test.tsx b/apps/web/components/task/changes-panel-header.test.tsx index 3120ea6030..d65b9b2ced 100644 --- a/apps/web/components/task/changes-panel-header.test.tsx +++ b/apps/web/components/task/changes-panel-header.test.tsx @@ -1,6 +1,8 @@ import { cleanup, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { PullDropdown } from "./changes-panel-header"; +import { ChangesPanelHeaderOverflowActions } from "./changes-panel-header-actions"; vi.mock("@kandev/ui/button", () => ({ Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( @@ -12,6 +14,11 @@ vi.mock("@kandev/ui/dropdown-menu", () => ({ DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuItem: ({ children, ...props }: { children: React.ReactNode }) => ( +
+ {children} +
+ ), })); vi.mock("@kandev/ui/tooltip", () => ({ @@ -20,6 +27,25 @@ vi.mock("@kandev/ui/tooltip", () => ({ TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, })); +vi.mock("./panel-primitives", () => ({ + PanelHeaderBarSplit: ({ + left, + right, + overflow, + }: { + left?: ReactNode; + right?: ReactNode; + overflow?: ReactNode; + }) => ( +
+ {left} + {right} + {overflow} +
+ ), + PanelHeaderOverflowMenu: ({ children }: { children: ReactNode }) =>
{children}
, +})); + vi.mock("./changes-panel-per-repo-menu", () => ({ PerRepoPullMenu: () => null, })); @@ -54,3 +80,31 @@ describe("PullDropdown remote safety", () => { ).toBeNull(); }); }); + +describe("ChangesPanelHeaderOverflowActions", () => { + it("does not expose Diff or Review when the Changes panel has no reviewable content", () => { + render( + , + ); + + expect(screen.queryByText("Diff")).toBeNull(); + expect(screen.queryByText("Review")).toBeNull(); + }); + + it("keeps Diff and Review available when reviewable content exists", () => { + render( + , + ); + + expect(screen.getByText("Diff")).toBeTruthy(); + expect(screen.getByText("Review")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/changes-panel-header.tsx b/apps/web/components/task/changes-panel-header.tsx index 77488da0cb..71a8d49088 100644 --- a/apps/web/components/task/changes-panel-header.tsx +++ b/apps/web/components/task/changes-panel-header.tsx @@ -1,38 +1,7 @@ "use client"; -import { useState } from "react"; -import { - IconCloudDownload, - IconEye, - IconChevronDown, - IconGitBranch, - IconGitMerge, - IconArrowRight, - IconLoader2, - IconEdit, - IconRoute, -} from "@tabler/icons-react"; -import { Button } from "@kandev/ui/button"; -import { Input } from "@kandev/ui/input"; -import { Label } from "@kandev/ui/label"; -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; -import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@kandev/ui/drawer"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu"; import { PanelHeaderBarSplit } from "./panel-primitives"; -import { BaseBranchPicker } from "./base-branch-picker"; import type { GitCredentialDisplay } from "./changes-git-credential-display"; -import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; -import { useTranslation } from "react-i18next"; -import { PerRepoPullMenu } from "./changes-panel-per-repo-menu"; import type { RemoteContributionRelation } from "@/hooks/domains/session/remote-contribution-relation"; import type { ContributionHistoryExplanationTarget } from "@/hooks/domains/session/use-contribution-history-explanation"; import { @@ -40,426 +9,25 @@ import { type useRemoteContributionResolution, } from "./use-remote-contribution-resolution"; import { RemoteContributionHeaderActions } from "./remote-contribution-header-actions"; -import { ComparisonTargetDisplay } from "./changes-panel-comparison-target"; -import { buildBranchRows, type BranchRow, type PerRepoStatus } from "./changes-panel-branch-rows"; +import { buildBranchRows, type PerRepoStatus } from "./changes-panel-branch-rows"; +import { + BranchHoverCard, + ChangesPanelHeaderLeft, + ChangesPanelHeaderOverflowActions, + PullDropdown, +} from "./changes-panel-header-actions"; +import type { RenameBranchResult } from "./changes-panel-header-actions"; + +export { PullDropdown } from "./changes-panel-header-actions"; export type { PerRepoStatus } from "./changes-panel-branch-rows"; -type RenameBranchResult = { - success: boolean; - error?: string; -}; - -function RenameBranchButton({ - branch, - repositoryName, - onRenameBranch, - isRenaming, -}: { - branch: string; - repositoryName: string; - onRenameBranch?: (newName: string, repo: string) => Promise; - isRenaming: boolean; -}) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const [newBranchName, setNewBranchName] = useState(branch); - const [error, setError] = useState(null); - const trimmedBranchName = newBranchName.trim(); - const canRename = !!onRenameBranch && trimmedBranchName !== "" && trimmedBranchName !== branch; - const submitRename = async () => { - if (!onRenameBranch || !canRename) return; - setError(null); - try { - const result = await onRenameBranch(trimmedBranchName, repositoryName); - if (!result.success) { - setError(result.error || t("task:failedToRenameBranch")); - return; - } - setOpen(false); - } catch (err) { - setError(err instanceof Error ? err.message : t("task:failedToRenameBranch")); - } - }; - const openDialog = () => { - setNewBranchName(branch); - setError(null); - setOpen(true); - }; - return ( - <> - - - - - {t("task:editBranch2")} - -
- - setNewBranchName(event.target.value)} - autoFocus - /> - {error &&

{error}

} -
- - - - -
-
- - ); -} - -function BranchRowView({ - repoLabel, - branch, - baseBranch, - repositoryName, - taskId, - onRenameBranch, - isRenaming, -}: BranchRow & { - taskId: string | null; - onRenameBranch?: (newName: string, repo: string) => Promise; - isRenaming: boolean; -}) { - return ( -
- {repoLabel && ( - - {repoLabel} - - )} - - - {branch} - - -
-
- -
- -
- ); -} - -function BranchHoverCard({ - displayBranch, - baseBranchDisplay, - rows, - taskId, - onRenameBranch, - isRenaming, - credentialDisplay, - comparisonTargets, -}: { - displayBranch: string; - baseBranchDisplay: string; - rows?: BranchRow[]; - taskId: string | null; - onRenameBranch?: (newName: string, repo: string) => Promise; - isRenaming: boolean; - credentialDisplay: GitCredentialDisplay | null; - comparisonTargets: string[]; -}) { - const { t } = useTranslation(); - const usesTouchDrawer = useTouchDrawer(); - const [open, setOpen] = useState(false); - const trigger = ( - - ); - const content = ( -
-
- - {rows && rows.length > 0 ? t("task:yourBranchesLabel") : t("task:yourCodeLivesInLabel")} - - {t("task:comparingAgainstLabel")} -
- {rows && rows.length > 0 ? ( -
- {rows!.map((row) => ( - - ))} -
- ) : ( - - )} - {credentialDisplay && ( -
-

{credentialDisplay.source}

-

{credentialDisplay.detail}

-

{credentialDisplay.transport}

-
- )} - -
- ); - if (usesTouchDrawer) { - return ( - - {trigger} - - - {t("task:branchDetails")} - {t("task:branchComparisonAndTaskGitCredentials")} - -
{content}
-
-
- ); - } - return ( - - {trigger} - - {content} - - - ); -} - -function PullTriggerContent({ - behindCount, - isPulling, - isRebasing, -}: { - behindCount: number; - isPulling: boolean; - isRebasing: boolean; -}) { - let label: string; - if (isPulling) label = "Pulling…"; - else if (isRebasing) label = "Rebasing…"; - else label = "Pull"; - return ( - <> - {isPulling || isRebasing ? ( - - ) : ( - - )} - {label} - {behindCount > 0 && !(isPulling || isRebasing) && ( - {behindCount} - )} - {!(isPulling || isRebasing) && ( - - )} - - ); -} - -export function PullDropdown({ - behindCount, - pullDisabled, - isLoading, - loadingOperation, - repoNames, - perRepoStatus, - onRepoPull, - onRepoRebase, - onRepoMerge, - repoDisplayName, - pullDisabledReason, -}: { - behindCount: number; - pullDisabled?: boolean; - pullDisabledReason?: string; - isLoading: boolean; - loadingOperation: string | null; - /** Always non-empty (single-repo includes the empty-name entry). */ - repoNames: string[]; - perRepoStatus: PerRepoStatus[]; - onRepoPull: (repo: string) => void; - onRepoRebase: (repo: string) => void; - onRepoMerge: (repo: string) => void; - /** Maps a repository_name to its display label. */ - repoDisplayName?: (repositoryName: string) => string | undefined; -}) { - const { t } = useTranslation(); - const triggerBehind = - perRepoStatus.length > 0 - ? Math.max(behindCount, ...perRepoStatus.map((s) => s.pullBehind ?? 0)) - : behindCount; - const pullButton = ( - - ); - if (pullDisabled) { - return ( - - - - {pullButton} - - - - {pullDisabledReason ?? t("task:divergedActionsUnavailable")} - - - ); - } - return ( - - {pullButton} - - - - - ); -} - -function ChangesPanelHeaderLeft({ - showDiffReview, - onOpenDiffAll, - onOpenReview, - onRequestWalkthrough, - requestWalkthroughDisabled, -}: { - showDiffReview: boolean; - onOpenDiffAll?: () => void; - onOpenReview?: () => void; - onRequestWalkthrough?: () => void; - requestWalkthroughDisabled?: boolean; -}) { - const { t } = useTranslation(); - if (!showDiffReview) return null; - return ( - <> - - - {onRequestWalkthrough ? ( - - ) : null} - - ); -} - -function ChangesPanelWalkthroughButton({ - onRequestWalkthrough, - requestWalkthroughDisabled, -}: { - onRequestWalkthrough: () => void; - requestWalkthroughDisabled?: boolean; -}) { - const { t } = useTranslation(); - const tooltip = requestWalkthroughDisabled - ? t("task:loadingChangedFiles") - : t("task:walkMeThroughTheseChanges"); - return ( - - - - - - - {tooltip} - +function buildHeaderBranchRows(props: ChangesPanelHeaderProps) { + return buildBranchRows( + props.perRepoStatus, + props.baseBranchByRepo, + props.baseBranchDisplay, + props.repoDisplayName, ); } @@ -503,31 +71,19 @@ type ChangesPanelHeaderProps = { remoteContributionNumber?: number; }; -export function ChangesPanelHeader(props: ChangesPanelHeaderProps) { +function ChangesPanelHeaderRight({ + props, + branchRows, +}: { + props: ChangesPanelHeaderProps; + branchRows: ReturnType; +}) { const { - hasChanges, - hasCommits, - hasPRFiles, displayBranch, baseBranchDisplay, - baseBranchByRepo, - behindCount, - pullDisabled, - pullDisabledReason, - isLoading, - loadingOperation, - onOpenDiffAll, - onOpenReview, - onRequestWalkthrough, - requestWalkthroughDisabled, - repoNames, - perRepoStatus, - onRepoPull, - onRepoRebase, - onRepoMerge, - repoDisplayName, taskId, onRenameBranch, + loadingOperation, credentialDisplay, comparisonTargets, relation, @@ -536,61 +92,102 @@ export function ChangesPanelHeader(props: ChangesPanelHeaderProps) { resolutionTarget, remoteContributionUrl, remoteContributionNumber, - } = props; - const branchRows = buildBranchRows( + behindCount, + pullDisabled, + pullDisabledReason, + isLoading, + repoNames, perRepoStatus, - baseBranchByRepo, - baseBranchDisplay, + onRepoPull, + onRepoRebase, + onRepoMerge, repoDisplayName, + } = props; + return ( + <> + {(displayBranch || branchRows.length > 0) && ( + + )} + + + ); +} + +export function ChangesPanelHeader(props: ChangesPanelHeaderProps) { + const { + hasChanges, + hasCommits, + hasPRFiles, + onOpenDiffAll, + onOpenReview, + onRequestWalkthrough, + requestWalkthroughDisabled, + } = props; + const branchRows = buildHeaderBranchRows(props); + const showDiffReview = hasChanges || hasCommits || !!hasPRFiles; return ( } - right={ - <> - {(displayBranch || branchRows.length > 0) && ( - - )} - - - + leftWhenOverflow={ + } + right={} + overflow={ + + } + overflowAt={520} + hideLeftWhenOverflow + hideRightWhenOverflow={false} /> ); } diff --git a/apps/web/components/task/changes-top-bar.tsx b/apps/web/components/task/changes-top-bar.tsx index e465017da0..73ed978a49 100644 --- a/apps/web/components/task/changes-top-bar.tsx +++ b/apps/web/components/task/changes-top-bar.tsx @@ -20,7 +20,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { ReviewPRSelector } from "@/components/review/review-pr-selector"; import type { TaskPR } from "@/lib/types/github"; -import { PanelHeaderBar, PanelHeaderBarSplit } from "./panel-primitives"; +import { PanelHeaderBarSplit, PanelHeaderOverflowMenu } from "./panel-primitives"; import { useTranslation } from "react-i18next"; export type ChangesTopBarProps = { @@ -58,7 +58,11 @@ function ChangesTopBarLeft({ <> - @@ -112,7 +116,7 @@ function ReviewWalkthroughRequestButton({ + + {t("task:expandReview")} + + ); +} + function ChangesTopBarRight({ splitView, wordWrap, @@ -154,26 +178,13 @@ function ChangesTopBarRight({ handleRequestWalkthrough={handleRequestWalkthrough} requestWalkthroughDisabled={requestWalkthroughDisabled} /> + - - {t("task:expandReview")} - - - - @@ -188,21 +188,21 @@ function CreateMenu({ {t("task:newFile")} onUploadFiles("files")} > {t("task:uploadFiles")} onUploadFiles("folder")} > @@ -225,7 +225,7 @@ function OpenWorkspaceFolderMenuItem({ const { t } = useTranslation(); return ( @@ -313,7 +313,7 @@ function WorkspaceActionsMenu({ > { openSourcesAfterCloseRef.current = true; restoreMobileFocusAfterDrawerClose(); diff --git a/apps/web/components/task/file-viewer-header.tsx b/apps/web/components/task/file-viewer-header.tsx index db617b3e6a..15871f3d55 100644 --- a/apps/web/components/task/file-viewer-header.tsx +++ b/apps/web/components/task/file-viewer-header.tsx @@ -12,6 +12,7 @@ import { ExternalVcsFileLink, useExternalVcsFileStatus, } from "@/components/editors/external-vcs-file-link"; +import { PanelHeaderBarSplit } from "@/components/task/panel-primitives"; type FileViewerHeaderProps = { path: string; @@ -27,24 +28,16 @@ export function FileViewerHeader({ actions, }: FileViewerHeaderProps) { const label = toRelativePath(path, worktreePath); - if (!actions) { - return ( -
-
+ return ( + {label}
-
- ); - } - return ( -
-
- {label} - -
-
{actions}
-
+ } + right={actions} + /> ); } @@ -99,7 +92,7 @@ export function FileViewerDownloadButton({ onDownload }: FileViewerDownloadButto variant="ghost" onClick={onDownload} aria-label={t("editors:downloadFile")} - className="h-11 w-11 p-0 cursor-pointer sm:h-8 sm:w-8" + className="h-6 w-6 p-0 cursor-pointer max-md:h-11 [@media(pointer:coarse)]:h-11 max-md:w-11 [@media(pointer:coarse)]:w-11" > diff --git a/apps/web/components/task/html-preview-content.tsx b/apps/web/components/task/html-preview-content.tsx index df7228eb84..b99e61077a 100644 --- a/apps/web/components/task/html-preview-content.tsx +++ b/apps/web/components/task/html-preview-content.tsx @@ -88,7 +88,7 @@ function HtmlPreviewContentToolbar({ onClick={onRefresh} disabled={isLoading} aria-label={t("task:refreshHtmlPreview")} - className="h-11 w-11 cursor-pointer p-0 text-foreground sm:h-8 sm:w-8" + className="h-6 w-6 cursor-pointer p-0 text-foreground max-md:h-11 [@media(pointer:coarse)]:h-11 max-md:w-11 [@media(pointer:coarse)]:w-11" > @@ -99,7 +99,7 @@ function HtmlPreviewContentToolbar({ variant="ghost" onClick={onOpenInBrowser} aria-label={t("task:openInBrowserPanel")} - className="h-11 w-11 cursor-pointer p-0 text-foreground sm:h-8 sm:w-8" + className="h-6 w-6 cursor-pointer p-0 text-foreground max-md:h-11 [@media(pointer:coarse)]:h-11 max-md:w-11 [@media(pointer:coarse)]:w-11" > @@ -112,7 +112,7 @@ function HtmlPreviewContentToolbar({ onClick={onTogglePreview} aria-label={t("task:showCode")} title={trustWarning} - className="h-11 w-11 cursor-pointer p-0 text-foreground sm:h-8 sm:w-8" + className="h-6 w-6 cursor-pointer p-0 text-foreground max-md:h-11 [@media(pointer:coarse)]:h-11 max-md:w-11 [@media(pointer:coarse)]:w-11" data-testid="html-preview-code-toggle" > diff --git a/apps/web/components/task/markdown-preview-content.tsx b/apps/web/components/task/markdown-preview-content.tsx index 2968090c01..6ae9d48b97 100644 --- a/apps/web/components/task/markdown-preview-content.tsx +++ b/apps/web/components/task/markdown-preview-content.tsx @@ -114,7 +114,7 @@ function MarkdownPreviewToolbar({ size="sm" variant="ghost" onClick={onTogglePreview} - className="h-11 w-11 p-0 cursor-pointer text-foreground sm:h-8 sm:w-8" + className="h-6 w-6 p-0 cursor-pointer text-foreground max-md:h-11 [@media(pointer:coarse)]:h-11 max-md:w-11 [@media(pointer:coarse)]:w-11" data-testid="markdown-preview-toggle" > diff --git a/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx b/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx index 7b2139416c..4431c7ce4b 100644 --- a/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx +++ b/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx @@ -161,7 +161,7 @@ function MobileFileViewerHeader({ return ( {file.path} @@ -199,7 +199,12 @@ function MobileFileViewerHeader({ )} )} -
diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx index 914941d915..b69e242e89 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.tsx @@ -283,7 +283,7 @@ export function MobilePanelArea({
)} {currentMobilePanel === "files" && ( -
+
{selectedFile ? ( { + it("passes standard div attributes and refs through the shared header shell", () => { + const ref = createRef(); + + render( + + Header + , + ); + + const header = screen.getByTestId("panel-header"); + expect(header.getAttribute("aria-label")).toBe("Panel actions"); + expect(header.textContent).toContain("Header"); + expect(ref.current).toBe(header); + expect(header.className).toContain("h-[1.875rem]"); + expect(header.className).toContain("[@media(max-width:47.999rem)]:h-12"); + expect(header.className).toContain("[@media(pointer:coarse)]:h-12"); + expect(header.className).toContain("[@media(max-width:47.999rem)]:[&_button]:min-h-11"); + expect(header.className).toContain("[@media(pointer:coarse)]:[&_a]:min-w-11"); + }); + + it.each([ + [239, true], + [480, true], + [520, false], + ])("resolves width-aware overflow at %ipx", (width, expected) => { + expect(shouldUsePanelHeaderOverflow(width, 520)).toBe(expected); + }); + + it("keeps split actions in the shared header and leaves footer geometry separate", () => { + render( + <> + {SPLIT_TITLE}} + right={} + rightWhenOverflow={} + overflow={} + overflowAt={520} + /> + Footer + , + ); + + expect(screen.getByTestId(SPLIT_HEADER_TEST_ID).textContent).toContain(SPLIT_TITLE); + expect(screen.getByTestId(SPLIT_HEADER_TEST_ID).textContent).toContain(SPLIT_ACTION); + expect(screen.getByTestId(SPLIT_HEADER_TEST_ID).textContent).not.toContain("Primary action"); + expect(screen.getByTestId(PANEL_FOOTER_TEST_ID).className).not.toContain("h-[30px]"); + }); + + it("exposes a keyboard-addressable overflow trigger for secondary actions", () => { + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "More panel actions" }); + expect(trigger.getAttribute("data-testid")).toBe("panel-header-overflow"); + expect(trigger.className).toContain("max-md:size-11"); + }); +}); + +describe("panel header overflow state", () => { + it("clears a stale overflow state when overflow detection is disabled", async () => { + class ResizeObserverMock { + constructor(private readonly callback: ResizeObserverCallback) {} + + observe(target: Element) { + Object.defineProperty(target, "getBoundingClientRect", { + configurable: true, + value: () => ({ width: 240 }), + }); + this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver); + } + + disconnect() {} + } + + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + const view = render( + {RESIZE_TITLE}} + right={} + overflow={} + overflowAt={520} + />, + ); + + await waitFor(() => + expect(screen.getByTestId(SPLIT_HEADER_TEST_ID).getAttribute("data-panel-overflow")).toBe( + "true", + ), + ); + + view.rerender( + {RESIZE_TITLE}} + right={} + />, + ); + + await waitFor(() => + expect( + screen.getByTestId(SPLIT_HEADER_TEST_ID).getAttribute("data-panel-overflow"), + ).toBeNull(), + ); + expect( + screen + .getByRole("button", { name: RESIZE_ACTION }) + .parentElement?.className.split(/\s+/) + .includes("hidden"), + ).toBe(false); + vi.unstubAllGlobals(); + }); +}); diff --git a/apps/web/components/task/panel-primitives.tsx b/apps/web/components/task/panel-primitives.tsx index 1cb9977720..8fb8599047 100644 --- a/apps/web/components/task/panel-primitives.tsx +++ b/apps/web/components/task/panel-primitives.tsx @@ -1,4 +1,18 @@ -import { forwardRef, type ReactNode, type HTMLAttributes } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useRef, + useState, + type ForwardedRef, + type RefObject, + type ReactNode, + type HTMLAttributes, +} from "react"; +import { IconDots } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu"; +import { controlSizingClassName } from "@kandev/ui/control-sizing"; import { cn } from "@kandev/ui/lib/utils"; /** @@ -11,10 +25,50 @@ import { cn } from "@kandev/ui/lib/utils"; const PANEL_ROOT_CLASS = "h-full min-h-0 flex flex-col bg-card text-card-foreground"; const PANEL_BAR_CLASS = - "flex items-center gap-1.5 h-[30px] px-2.5 shrink-0 border-border/80 bg-card/95 text-xs text-foreground"; + "box-border flex min-w-0 items-center gap-1.5 px-2.5 shrink-0 border-border/80 bg-card/95 text-xs text-foreground"; +const PANEL_HEADER_BAR_CLASS = + "h-[1.875rem] min-h-[1.875rem] [@media(max-width:47.999rem)]:h-12 [@media(max-width:47.999rem)]:min-h-12 [@media(pointer:coarse)]:h-12 [@media(pointer:coarse)]:min-h-12 [@media(max-width:47.999rem)]:[&_button]:min-h-11 [@media(max-width:47.999rem)]:[&_button]:min-w-11 [@media(max-width:47.999rem)]:[&_a]:min-h-11 [@media(max-width:47.999rem)]:[&_a]:min-w-11 [@media(max-width:47.999rem)]:[&_[role=button]]:min-h-11 [@media(max-width:47.999rem)]:[&_[role=button]]:min-w-11 [@media(max-width:47.999rem)]:[&_input]:min-h-11 [@media(max-width:47.999rem)]:[&_select]:min-h-11 [@media(max-width:47.999rem)]:[&_textarea]:min-h-11 [@media(pointer:coarse)]:[&_button]:min-h-11 [@media(pointer:coarse)]:[&_button]:min-w-11 [@media(pointer:coarse)]:[&_a]:min-h-11 [@media(pointer:coarse)]:[&_a]:min-w-11 [@media(pointer:coarse)]:[&_[role=button]]:min-h-11 [@media(pointer:coarse)]:[&_[role=button]]:min-w-11 [@media(pointer:coarse)]:[&_input]:min-h-11 [@media(pointer:coarse)]:[&_select]:min-h-11 [@media(pointer:coarse)]:[&_textarea]:min-h-11"; const PANEL_ACTION_CURSOR_CLASS = "[&_button:not(:disabled)]:cursor-pointer [&_[role=button]:not([aria-disabled=true])]:cursor-pointer"; +export function shouldUsePanelHeaderOverflow(panelWidth: number, overflowAt: number): boolean { + return panelWidth > 0 && panelWidth < overflowAt; +} + +function usePanelHeaderOverflow( + ref: RefObject, + overflowAt: number | undefined, +): boolean { + const [overflowed, setOverflowed] = useState(false); + + useEffect(() => { + if (!overflowAt || typeof ResizeObserver === "undefined") { + setOverflowed(false); + return; + } + const element = ref.current; + if (!element) return; + const measure = () => { + const next = shouldUsePanelHeaderOverflow(element.getBoundingClientRect().width, overflowAt); + setOverflowed((current) => (current === next ? current : next)); + }; + const observer = new ResizeObserver(measure); + observer.observe(element); + measure(); + return () => observer.disconnect(); + }, [overflowAt, ref]); + + return overflowed; +} + +function assignRef(ref: ForwardedRef, value: T | null) { + if (typeof ref === "function") { + ref(value); + } else if (ref) { + ref.current = value; + } +} + type PanelRootProps = HTMLAttributes & { children: ReactNode; className?: string; @@ -62,66 +116,223 @@ export const PanelBody = forwardRef(function Pan ); }); -type PanelToolbarProps = { - children: ReactNode; - className?: string; -}; +type PanelToolbarProps = HTMLAttributes & { children: ReactNode }; -type PanelBarProps = { - children?: ReactNode; - className?: string; +type PanelBarProps = HTMLAttributes & { borderClassName: "border-b" | "border-t"; }; -function PanelBar({ children, className, borderClassName }: PanelBarProps) { +const PanelBar = forwardRef(function PanelBar( + { children, className, borderClassName, ...rest }, + ref, +) { return ( -
+
{children}
); +}); + +/** Fixed header toolbar strip. Doesn't scroll with content. */ +export const PanelToolbar = forwardRef(function PanelToolbar( + { children, className, ...rest }, + ref, +) { + return ( + + {children} + + ); +}); + +type PanelHeaderBarProps = HTMLAttributes & { children?: ReactNode }; + +/** Fixed-height panel header bar. Renders children directly. */ +export const PanelHeaderBar = forwardRef( + function PanelHeaderBar({ children, className, ...rest }, ref) { + return ( + + {children} + + ); + }, +); + +type PanelHeaderBarSplitProps = HTMLAttributes & { + left?: ReactNode; + right?: ReactNode; + /** Actions shown when the panel is too narrow for the full action cluster. */ + overflow?: ReactNode; + /** Panel width in CSS pixels at which `overflow` replaces `right`. */ + overflowAt?: number; + /** Hide the left slot when the overflow action menu is active. */ + hideLeftWhenOverflow?: boolean; + /** Keep the right slot visible alongside the overflow menu. */ + hideRightWhenOverflow?: boolean; + /** Primary action kept visible alongside the overflow menu. */ + rightWhenOverflow?: ReactNode; + /** Left content replacement that keeps the primary left action visible when overflowed. */ + leftWhenOverflow?: ReactNode; + leftClassName?: string; + rightClassName?: string; +}; + +function shouldRenderPanelHeaderSlot( + isOverflowed: boolean, + hideWhenOverflow: boolean, + replacement: ReactNode, +): boolean { + if (!isOverflowed) return true; + return !hideWhenOverflow || replacement !== undefined; +} + +function PanelHeaderSplitSlot({ + visible, + isOverflowed, + replacement, + children, + className, + shrinkWhenReplaced = false, +}: { + visible: boolean; + isOverflowed: boolean; + replacement?: ReactNode; + children?: ReactNode; + className?: string; + shrinkWhenReplaced?: boolean; +}) { + if (!visible) return null; + const content = isOverflowed && replacement !== undefined ? replacement : children; + return ( +
+ {content} +
+ ); } -/** Fixed header toolbar strip. Doesn't scroll with content. */ -export function PanelToolbar({ children, className }: PanelToolbarProps) { - return {children}; -} - -type PanelHeaderBarProps = { - children?: ReactNode; - className?: string; -}; - -/** Fixed-height panel header bar. Renders children directly. */ -export function PanelHeaderBar({ children, className }: PanelHeaderBarProps) { - return ( - - {children} - - ); -} - -type PanelHeaderBarSplitProps = { - left?: ReactNode; - right?: ReactNode; - className?: string; -}; - /** Panel header bar with left/right slots separated by a spacer. */ -export function PanelHeaderBarSplit({ left, right, className }: PanelHeaderBarSplitProps) { +export const PanelHeaderBarSplit = forwardRef( + function PanelHeaderBarSplit( + { + left, + right, + overflow, + overflowAt, + hideLeftWhenOverflow = false, + hideRightWhenOverflow = true, + rightWhenOverflow, + leftWhenOverflow, + leftClassName, + rightClassName, + className, + ...rest + }, + ref, + ) { + const headerRef = useRef(null); + const isOverflowed = usePanelHeaderOverflow(headerRef, overflow ? overflowAt : undefined); + const setHeaderRef = useCallback( + (node: HTMLDivElement | null) => { + headerRef.current = node; + assignRef(ref, node); + }, + [ref], + ); + const showLeft = shouldRenderPanelHeaderSlot( + isOverflowed, + hideLeftWhenOverflow, + leftWhenOverflow, + ); + const showRight = shouldRenderPanelHeaderSlot(isOverflowed, hideRightWhenOverflow, undefined); + return ( + + + {left} + +
+ + {right} + + {isOverflowed && rightWhenOverflow ? ( +
{rightWhenOverflow}
+ ) : null} + {isOverflowed && overflow} + + ); + }, +); + +export function PanelHeaderOverflowMenu({ + label, + children, + testId = "panel-header-overflow", +}: { + label: string; + children: ReactNode; + testId?: string; +}) { return ( - -
{left}
-
-
{right}
- + + + + + + {children} + + ); } /** Fixed-height panel footer bar with border-t. Mirrors PanelHeaderBar but anchors to the bottom. */ -export function PanelFooterBar({ children, className }: PanelHeaderBarProps) { - return ( - - {children} - - ); -} +export const PanelFooterBar = forwardRef( + function PanelFooterBar({ children, className, ...rest }, ref) { + return ( + + {children} + + ); + }, +); diff --git a/apps/web/components/task/task-chat-panel.scroll-target.test.tsx b/apps/web/components/task/task-chat-panel.scroll-target.test.tsx index 249486d774..971b89b8c8 100644 --- a/apps/web/components/task/task-chat-panel.scroll-target.test.tsx +++ b/apps/web/components/task/task-chat-panel.scroll-target.test.tsx @@ -59,6 +59,7 @@ vi.mock("@/hooks/domains/session/load-message-window", () => ({ import { isMessageRowRendered, usePendingMessageScroll, + usePendingScrollToStart, useScrollTargetConsumption, type PendingMessageScrollTarget, } from "./task-chat-panel"; @@ -82,6 +83,68 @@ describe("isMessageRowRendered", () => { }); }); +const FIRST_PROMPT_MESSAGE_ID = "first-prompt"; + +describe("usePendingScrollToStart", () => { + it("retries after the first prompt row is not mounted yet", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const scrollToMessage = vi.fn().mockReturnValueOnce(false).mockReturnValue(true); + const messageListRef = { current: { scrollToMessage } }; + const onComplete = vi.fn(); + + renderHook(() => + usePendingScrollToStart({ + messageListRef, + firstMessageId: FIRST_PROMPT_MESSAGE_ID, + hasMore: false, + pending: true, + requestKey: 1, + onComplete, + }), + ); + + await flushFrames(); + expect(scrollToMessage).toHaveBeenCalledTimes(1); + expect(onComplete).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + expect(scrollToMessage).toHaveBeenCalledTimes(2); + expect(scrollToMessage).toHaveBeenLastCalledWith(FIRST_PROMPT_MESSAGE_ID, { + align: "start", + }); + expect(onComplete).toHaveBeenCalledWith(true); + }); + + it("waits for pagination to finish before attempting the target", async () => { + const messageListRef = { current: scrollHandle(true) }; + const onComplete = vi.fn(); + const { rerender } = renderHook( + ({ hasMore }: { hasMore: boolean }) => + usePendingScrollToStart({ + messageListRef, + firstMessageId: FIRST_PROMPT_MESSAGE_ID, + hasMore, + pending: true, + requestKey: 1, + onComplete, + }), + { initialProps: { hasMore: true } }, + ); + + await flushFrames(); + expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled(); + + rerender({ hasMore: false }); + await flushFrames(); + expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledWith(FIRST_PROMPT_MESSAGE_ID, { + align: "start", + }); + expect(onComplete).toHaveBeenCalledWith(true); + }); +}); + let pendingFrames: Array<(() => void) | undefined> = []; /** Runs two rounds of pending requestAnimationFrame callbacks inside act. */ diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 37bb06ae79..ae9eb7c268 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -447,6 +447,60 @@ export function usePendingMessageScroll({ return { isLoading }; } +const SCROLL_TO_START_RETRY_DELAY_MS = 50; +const MAX_SCROLL_TO_START_ATTEMPTS = 120; + +type PendingScrollToStartOptions = { + messageListRef: RefObject; + firstMessageId: string | null; + hasMore: boolean; + pending: boolean; + requestKey: number; + onComplete: (didScroll: boolean) => void; +}; + +/** Keeps a scroll-to-start request alive until the final page's row is mounted. */ +export function usePendingScrollToStart({ + messageListRef, + firstMessageId, + hasMore, + pending, + requestKey, + onComplete, +}: PendingScrollToStartOptions) { + useEffect(() => { + if (!pending || hasMore) return; + if (!firstMessageId) { + onComplete(false); + return; + } + + let cancelled = false; + let frameId: number | null = null; + let timeoutId: number | null = null; + let attempts = 0; + const attempt = () => { + if (cancelled) return; + attempts += 1; + const didScroll = Boolean( + messageListRef.current?.scrollToMessage(firstMessageId, { align: "start" }), + ); + if (didScroll || attempts >= MAX_SCROLL_TO_START_ATTEMPTS) { + onComplete(didScroll); + return; + } + timeoutId = window.setTimeout(attempt, SCROLL_TO_START_RETRY_DELAY_MS); + }; + + frameId = requestAnimationFrame(attempt); + return () => { + cancelled = true; + if (frameId !== null) cancelAnimationFrame(frameId); + if (timeoutId !== null) window.clearTimeout(timeoutId); + }; + }, [firstMessageId, hasMore, messageListRef, onComplete, pending, requestKey]); +} + /** Computes the render-item key the unread "New" divider should appear * immediately before: tracks the latest rendered message id for session read * tracking, then maps the resulting divider anchor onto the grouped items. */ @@ -1139,23 +1193,24 @@ export const TaskChatPanel = memo(function TaskChatPanel({ // which grows as pages prepend) has settled on the true first prompt by the // time the scroll fires. const [pendingScrollToStart, setPendingScrollToStart] = useState(false); + const [scrollToStartRequest, setScrollToStartRequest] = useState(0); useDrainOlderMessages(resolvedSessionId, pendingScrollToStart && hasMore); - useEffect(() => { - if (!pendingScrollToStart || hasMore) return; + const completeScrollToStart = useCallback((didScroll: boolean) => { setPendingScrollToStart(false); - if (firstMessageId) { - messageListRef.current?.scrollToMessage(firstMessageId, { align: "start" }); - } - }, [pendingScrollToStart, hasMore, firstMessageId]); + if (didScroll) setIsFirstMessageHidden(false); + }, []); + usePendingScrollToStart({ + messageListRef, + firstMessageId, + hasMore, + pending: pendingScrollToStart, + requestKey: scrollToStartRequest, + onComplete: completeScrollToStart, + }); const scrollToStart = useCallback(() => { - if (hasMore) { - setPendingScrollToStart(true); - return; - } - if (firstMessageId) { - messageListRef.current?.scrollToMessage(firstMessageId, { align: "start" }); - } - }, [hasMore, firstMessageId]); + setScrollToStartRequest((request) => request + 1); + setPendingScrollToStart(true); + }, []); // Search can target backend rows before the visible transcript boundary. const navigateSearchHit = useCallback( (id: string) => { diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 06677581aa..6f7983e09e 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -1075,6 +1075,24 @@ export class SessionPage { await tab.click(options); } + /** Open the Changes Diff action in its direct or width-aware overflow presentation. */ + async openChangesDiff(): Promise { + const direct = this.changes.getByRole("button", { name: "Diff", exact: true }); + const overflow = this.changes.getByTestId("panel-header-overflow").first(); + await expect + .poll(async () => (await direct.isVisible()) || (await overflow.isVisible()), { + timeout: 15_000, + message: "Waiting for the Changes Diff action", + }) + .toBe(true); + if (await direct.isVisible()) { + await direct.click(); + return; + } + await overflow.click(); + await this.page.getByRole("menuitem", { name: "Diff", exact: true }).click(); + } + /** * Click the session/chat tab regardless of its current title. * Session tabs are renamed from "Agent" to "#N AgentName" by useChatSessionTitle, diff --git a/apps/web/e2e/tests/canvas/canvas-authenticated-proxy-fixture.ts b/apps/web/e2e/tests/canvas/canvas-authenticated-proxy-fixture.ts new file mode 100644 index 0000000000..03f27069c0 --- /dev/null +++ b/apps/web/e2e/tests/canvas/canvas-authenticated-proxy-fixture.ts @@ -0,0 +1,284 @@ +import { execFileSync } from "node:child_process"; +import { request as httpRequest, type ClientRequest, type IncomingMessage } from "node:http"; +import type { Socket } from "node:net"; +import { createServer as createTLSServer, type Server as TLSServer } from "node:https"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { BrowserContext } from "@playwright/test"; + +export const CANVAS_PROXY_COOKIE_NAME = "kandev_e2e_access"; + +type RuntimeRequestKind = + | "runtime-entry" + | "runtime-host" + | "runtime-asset" + | "context" + | "data" + | "write" + | "events"; + +export type CanvasProxyObservation = { + authenticated: boolean; + host: string; + kind: RuntimeRequestKind; + method: string; +}; + +export type CanvasAuthenticatedProxy = { + origin: string; + host: string; + cookieName: string; + setAuthentication: (context: BrowserContext) => Promise; + clearAuthentication: (context: BrowserContext) => Promise; + count: (kind: RuntimeRequestKind, authenticated?: boolean) => number; + observations: () => CanvasProxyObservation[]; + activeUpstreamRequests: () => number; + close: () => Promise; +}; + +const runtimePrefix = "/api/v1/plugins/web-apps/runtime/"; + +export async function startCanvasAuthenticatedProxy( + backendURL: string, +): Promise { + const certificate = createCertificate(); + const backend = new URL(backendURL); + const observations: CanvasProxyObservation[] = []; + const upstreamRequests = new Set(); + const upstreamResponses = new Set(); + const server = createTLSServer( + { + key: fs.readFileSync(certificate.keyPath), + cert: fs.readFileSync(certificate.certPath), + }, + (incoming, outgoing) => { + const requestURL = new URL(incoming.url ?? "/", "https://127.0.0.1"); + const kind = runtimeRequestKind(requestURL.pathname, incoming.method ?? "GET"); + const host = incoming.headers.host ?? ""; + const authenticated = hasAuthenticationCookie(incoming.headers.cookie); + if (kind) { + observations.push({ + authenticated, + host, + kind, + method: incoming.method ?? "GET", + }); + } + + if (requestURL.pathname.startsWith(runtimePrefix) && !authenticated) { + outgoing.writeHead(401, { + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + }); + outgoing.end(JSON.stringify({ error: "proxy_auth_required" })); + return; + } + + const publicHost = host || `${certificate.hostname}:${certificate.port}`; + const headers = { + ...incoming.headers, + host: publicHost, + "x-forwarded-host": publicHost, + "x-forwarded-proto": "https", + }; + let upstreamResponse: IncomingMessage | undefined; + const upstream = httpRequest( + { + hostname: backend.hostname, + port: Number(backend.port), + path: incoming.url, + method: incoming.method, + headers, + }, + (response) => { + upstreamResponse = response; + upstreamResponses.add(response); + response.once("close", () => upstreamResponses.delete(response)); + outgoing.writeHead(response.statusCode ?? 502, response.headers); + outgoing.flushHeaders(); + response.once("error", (error) => { + if (!outgoing.destroyed) outgoing.destroy(error); + destroyUpstream(error); + }); + response.once("aborted", () => destroyUpstream()); + response.pipe(outgoing); + }, + ); + upstreamRequests.add(upstream); + const destroyUpstream = (error?: Error) => { + if (!upstream.destroyed) upstream.destroy(error); + if (upstreamResponse && !upstreamResponse.destroyed) upstreamResponse.destroy(error); + }; + const destroyIncompleteUpstream = () => { + if (!incoming.complete) destroyUpstream(); + }; + const removeUpstream = () => { + upstreamRequests.delete(upstream); + incoming.off("aborted", destroyUpstream); + incoming.off("error", destroyUpstream); + incoming.off("close", destroyIncompleteUpstream); + outgoing.off("close", destroyUpstream); + outgoing.off("error", destroyUpstream); + }; + incoming.once("aborted", destroyUpstream); + incoming.once("error", destroyUpstream); + incoming.once("close", destroyIncompleteUpstream); + outgoing.once("close", destroyUpstream); + outgoing.once("error", destroyUpstream); + upstream.once("close", removeUpstream); + upstream.on("error", (error) => { + if (outgoing.destroyed) return; + if (!outgoing.headersSent) { + outgoing.writeHead(502); + outgoing.end(); + } else { + outgoing.destroy(error); + } + }); + incoming.pipe(upstream); + }, + ); + const port = await listen(server); + certificate.port = port; + const sockets = new Set(); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + + const origin = `https://${certificate.hostname}:${port}`; + const host = `${certificate.hostname}:${port}`; + return { + origin, + host, + cookieName: CANVAS_PROXY_COOKIE_NAME, + setAuthentication: async (context) => { + await context.addCookies([ + { + name: CANVAS_PROXY_COOKIE_NAME, + value: "valid", + url: origin, + httpOnly: true, + secure: true, + sameSite: "Lax", + }, + ]); + }, + clearAuthentication: async (context) => { + await context.clearCookies({ name: CANVAS_PROXY_COOKIE_NAME }); + }, + count: (kind, authenticated) => + observations.filter( + (observation) => + observation.kind === kind && + (authenticated === undefined || observation.authenticated === authenticated), + ).length, + observations: () => observations.map((observation) => ({ ...observation })), + activeUpstreamRequests: () => upstreamRequests.size, + close: async () => { + await closeServer(server, sockets, upstreamRequests, upstreamResponses); + fs.rmSync(certificate.directory, { recursive: true, force: true }); + }, + }; +} + +function runtimeRequestKind(pathname: string, method: string): RuntimeRequestKind | null { + if (!pathname.startsWith(runtimePrefix)) return null; + const tokenAndPath = pathname.slice(runtimePrefix.length); + const separator = tokenAndPath.indexOf("/"); + const requestPath = separator < 0 ? "" : tokenAndPath.slice(separator + 1); + if (requestPath === "") return "runtime-entry"; + if (requestPath === "_kandev/host-runtime.js") return "runtime-host"; + if (requestPath === "_kandev/v1/context") return "context"; + if (requestPath === "_kandev/v1/events") return "events"; + if (requestPath.startsWith("_kandev/v1/data/") || requestPath.startsWith("_kandev/v1/state")) { + return method === "GET" ? "data" : "write"; + } + return "runtime-asset"; +} + +function hasAuthenticationCookie(cookieHeader: string | undefined): boolean { + return new RegExp(`(?:^|;\\s*)${CANVAS_PROXY_COOKIE_NAME}=valid(?:;|$)`).test(cookieHeader ?? ""); +} + +function createCertificate(): { + directory: string; + keyPath: string; + certPath: string; + hostname: string; + port: number; +} { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-canvas-auth-proxy-cert-")); + const keyPath = path.join(directory, "key.pem"); + const certPath = path.join(directory, "cert.pem"); + const configPath = path.join(directory, "openssl.cnf"); + fs.writeFileSync( + configPath, + [ + "[req]", + "distinguished_name = req_distinguished_name", + "x509_extensions = v3_req", + "prompt = no", + "[req_distinguished_name]", + "CN = 127.0.0.1", + "[v3_req]", + "subjectAltName = @alt_names", + "[alt_names]", + "IP.1 = 127.0.0.1", + "DNS.1 = localhost", + "", + ].join("\n"), + ); + execFileSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + keyPath, + "-out", + certPath, + "-config", + configPath, + "-extensions", + "v3_req", + "-days", + "1", + ], + { stdio: "ignore" }, + ); + return { directory, keyPath, certPath, hostname: "127.0.0.1", port: 0 }; +} + +function listen(server: TLSServer): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("canvas authenticated proxy did not receive a port")); + return; + } + resolve(address.port); + }); + }); +} + +function closeServer( + server: TLSServer, + sockets: Set, + upstreamRequests: Set, + upstreamResponses: Set, +): Promise { + return new Promise((resolve, reject) => { + for (const request of upstreamRequests) request.destroy(); + for (const response of upstreamResponses) response.destroy(); + for (const socket of sockets) socket.destroy(); + server.close((error) => (error ? reject(error) : resolve())); + }); +} diff --git a/apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts b/apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts new file mode 100644 index 0000000000..6fc129f160 --- /dev/null +++ b/apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts @@ -0,0 +1,263 @@ +import { expect, test } from "../../fixtures/test-base"; +import type { Browser, BrowserContext, Page } from "@playwright/test"; +import { + canvasHref, + enableCanvasFeature, + expectCanvasFrameFillsHost, + getCanvas, + removeCanvas, + seedTaskCanvas, +} from "./canvas-fixture"; +import { + startCanvasAuthenticatedProxy, + type CanvasAuthenticatedProxy, +} from "./canvas-authenticated-proxy-fixture"; + +async function openAuthenticatedCanvas( + browser: Browser, + proxy: CanvasAuthenticatedProxy, + canvasId: string, + mobile = false, +): Promise<{ context: BrowserContext; page: Page }> { + const context = await browser.newContext({ + baseURL: proxy.origin, + ignoreHTTPSErrors: true, + ...(mobile ? { viewport: { width: 393, height: 852 }, isMobile: true, hasTouch: true } : {}), + }); + await context.addInitScript(() => { + localStorage.setItem("kandev.onboarding.completed", "true"); + delete (window as unknown as { __KANDEV_API_PORT?: string }).__KANDEV_API_PORT; + }); + await proxy.setAuthentication(context); + const page = await context.newPage(); + await page.goto(canvasHref(canvasId)); + return { context, page }; +} + +test.describe("authenticated same-origin canvas runtime", () => { + test("loads a retained canvas through cookie authentication", async ({ + browser, + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(180_000); + const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); + let canvasId: string | undefined; + let proxy: CanvasAuthenticatedProxy | undefined; + let context: BrowserContext | undefined; + try { + const seeded = await seedTaskCanvas(testPage, apiClient, seedData); + canvasId = seeded.canvas.id; + proxy = await startCanvasAuthenticatedProxy(backend.baseUrl); + const opened = await openAuthenticatedCanvas(browser, proxy, canvasId); + context = opened.context; + const page = opened.page; + + await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", { + timeout: 30_000, + }); + await expectCanvasFrameFillsHost(page); + const frame = page.frameLocator('iframe[title="E2E Plugin Canvas"]'); + await expect(frame.getByTestId("canvas-fixture-script")).toHaveText("inline-ready"); + await expect(frame.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId); + await expect(frame.getByTestId("canvas-fixture-task-count")).toHaveText("1"); + await expect(frame.getByTestId("canvas-fixture-sse-status")).toHaveText("connected", { + timeout: 20_000, + }); + await expect.poll(() => proxy?.count("runtime-entry", true) ?? 0).toBeGreaterThan(0); + await expect.poll(() => proxy?.count("runtime-asset", true) ?? 0).toBeGreaterThan(0); + await expect.poll(() => proxy?.count("context", true) ?? 0).toBeGreaterThan(0); + await expect.poll(() => proxy?.count("data", true) ?? 0).toBeGreaterThan(0); + await expect.poll(() => proxy?.count("events", true) ?? 0).toBeGreaterThan(0); + expect(proxy?.observations().every((observation) => observation.host === proxy?.host)).toBe( + true, + ); + + await frame.getByTestId("canvas-fixture-continue").click(); + await expect(frame.getByTestId("canvas-fixture-message-status")).toHaveText("accepted"); + await expect + .poll(async () => + Number(await frame.getByTestId("canvas-fixture-sse-events").textContent()), + ) + .toBeGreaterThan(0); + await expect.poll(() => proxy?.count("write", true) ?? 0).toBeGreaterThan(0); + } finally { + await context?.close(); + await proxy?.close(); + if (canvasId) await removeCanvas(apiClient, canvasId); + await releaseFeature(); + } + }); + + test("releases a disconnected event stream before reconnecting", async ({ + browser, + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(180_000); + const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); + let canvasId: string | undefined; + let proxy: CanvasAuthenticatedProxy | undefined; + let context: BrowserContext | undefined; + try { + const seeded = await seedTaskCanvas(testPage, apiClient, seedData); + canvasId = seeded.canvas.id; + proxy = await startCanvasAuthenticatedProxy(backend.baseUrl); + const opened = await openAuthenticatedCanvas(browser, proxy, canvasId); + context = opened.context; + await expect(opened.page.getByTestId("web-app-frame")).toHaveAttribute( + "data-frame-state", + "ready", + { timeout: 30_000 }, + ); + const frame = opened.page.frameLocator('iframe[title="E2E Plugin Canvas"]'); + await expect(frame.getByTestId("canvas-fixture-sse-status")).toHaveText("connected", { + timeout: 20_000, + }); + await expect.poll(() => proxy?.activeUpstreamRequests() ?? 0).toBeGreaterThan(0); + + await opened.page.close(); + await expect + .poll(() => proxy?.activeUpstreamRequests() ?? 0, { + timeout: 10_000, + message: "The proxy kept the disconnected canvas event stream open", + }) + .toBe(0); + + const reconnectedPage = await context.newPage(); + await reconnectedPage.goto(canvasHref(canvasId)); + await expect(reconnectedPage.getByTestId("web-app-frame")).toHaveAttribute( + "data-frame-state", + "ready", + { timeout: 30_000 }, + ); + const reconnectedFrame = reconnectedPage.frameLocator('iframe[title="E2E Plugin Canvas"]'); + await expect(reconnectedFrame.getByTestId("canvas-fixture-sse-status")).toHaveText( + "connected", + { timeout: 20_000 }, + ); + await expect.poll(() => proxy?.count("events", true) ?? 0).toBeGreaterThan(1); + } finally { + await context?.close(); + await proxy?.close(); + if (canvasId) await removeCanvas(apiClient, canvasId); + await releaseFeature(); + } + }); + + test("recovers after proxy authentication expires", async ({ + browser, + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(180_000); + const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); + let canvasId: string | undefined; + let proxy: CanvasAuthenticatedProxy | undefined; + let context: BrowserContext | undefined; + try { + const seeded = await seedTaskCanvas(testPage, apiClient, seedData); + canvasId = seeded.canvas.id; + const releaseBefore = (await getCanvas(apiClient, canvasId))?.active_release_id; + proxy = await startCanvasAuthenticatedProxy(backend.baseUrl); + const opened = await openAuthenticatedCanvas(browser, proxy, canvasId); + context = opened.context; + const page = opened.page; + await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", { + timeout: 30_000, + }); + + await proxy.clearAuthentication(context); + await page.reload(); + await expect(page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", { + timeout: 30_000, + }); + await expect(page.getByTestId("web-app-frame")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Try again", exact: true })).toBeVisible(); + + await proxy.setAuthentication(context); + await page.getByRole("button", { name: "Try again", exact: true }).click(); + await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", { + timeout: 30_000, + }); + await expectCanvasFrameFillsHost(page); + expect((await getCanvas(apiClient, canvasId))?.active_release_id).toBe(releaseBefore); + await expect.poll(() => proxy?.count("runtime-entry", false) ?? 0).toBeGreaterThan(0); + await expect.poll(() => proxy?.count("runtime-entry", true) ?? 0).toBeGreaterThan(1); + } finally { + await context?.close(); + await proxy?.close(); + if (canvasId) await removeCanvas(apiClient, canvasId); + await releaseFeature(); + } + }); + + test("preserves capability checks with cookies", async ({ + browser, + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(180_000); + const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); + let canvasId: string | undefined; + let proxy: CanvasAuthenticatedProxy | undefined; + let context: BrowserContext | undefined; + try { + const seeded = await seedTaskCanvas(testPage, apiClient, seedData); + canvasId = seeded.canvas.id; + proxy = await startCanvasAuthenticatedProxy(backend.baseUrl); + const opened = await openAuthenticatedCanvas(browser, proxy, canvasId); + context = opened.context; + const page = opened.page; + await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", { + timeout: 30_000, + }); + + const checks = await page.evaluate(async () => { + const frame = document.querySelector( + 'iframe[title="E2E Plugin Canvas"]', + ); + if (!frame?.src) throw new Error("canvas runtime URL was not mounted"); + const runtimeURL = new URL(frame.src); + const marker = "/api/v1/plugins/web-apps/runtime/"; + const tokenStart = runtimeURL.pathname.indexOf(marker) + marker.length; + const tokenEnd = runtimeURL.pathname.indexOf("/", tokenStart); + if (tokenStart < marker.length || tokenEnd < 0) { + throw new Error("canvas runtime URL did not contain a capability segment"); + } + runtimeURL.pathname = `${runtimeURL.pathname.slice(0, tokenStart)}invalid-capability/${runtimeURL.pathname.slice(tokenEnd + 1)}`; + const invalidToken = await fetch(runtimeURL, { credentials: "same-origin" }); + const deniedWrite = await fetch(`${frame.src}_kandev/v1/actions/not-declared`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + return { + deniedBody: await deniedWrite.text(), + deniedStatus: deniedWrite.status, + invalidBody: await invalidToken.text(), + invalidStatus: invalidToken.status, + }; + }); + + expect(checks.invalidStatus).toBe(404); + expect(checks.invalidBody).toContain("runtime_token_invalid"); + expect(checks.deniedStatus).toBe(403); + expect(checks.deniedBody).toContain("plugin_permission_denied"); + } finally { + await context?.close(); + await proxy?.close(); + if (canvasId) await removeCanvas(apiClient, canvasId); + await releaseFeature(); + } + }); +}); diff --git a/apps/web/e2e/tests/canvas/canvas-fixture.ts b/apps/web/e2e/tests/canvas/canvas-fixture.ts index 84220d9209..4aee211ff9 100644 --- a/apps/web/e2e/tests/canvas/canvas-fixture.ts +++ b/apps/web/e2e/tests/canvas/canvas-fixture.ts @@ -59,8 +59,11 @@ export async function expectCanvasFrameFillsHost(page: Page): Promise { const hostRect = host.getBoundingClientRect(); const headerRect = header.getBoundingClientRect(); const frameRect = frame.getBoundingClientRect(); + const headerIsInsideHost = host.contains(header); const heightMatches = - Math.abs(frameRect.height - (hostRect.height - headerRect.height)) <= 2; + Math.abs( + frameRect.height - (hostRect.height - (headerIsInsideHost ? headerRect.height : 0)), + ) <= 2; const widthMatches = Math.abs(frameRect.width - hostRect.width) <= 2; const documentFits = document.documentElement.scrollWidth <= window.innerWidth; return heightMatches && widthMatches && documentFits; @@ -255,6 +258,7 @@ function canvasFixtureScript(canvas: CanvasRecord): string { let lastEventId = ""; let streamController; let streamReader; + let streamCompletion = Promise.resolve(); const renderAppearance = () => { const root = document.documentElement; @@ -316,34 +320,45 @@ function canvasFixtureScript(canvas: CanvasRecord): string { const connectEvents = async (cursor = "") => { if (streamController) streamController.abort(); + await streamCompletion; const controller = new AbortController(); streamController = controller; text("canvas-fixture-sse-status", "connecting"); - try { - const response = await fetch("./_kandev/v1/events", { - headers: cursor ? { "Last-Event-ID": cursor } : {}, - signal: controller.signal, - }); - if (!response.ok || !response.body) throw new Error("event stream unavailable"); - text("canvas-fixture-sse-status", "connected"); - streamReader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - for (;;) { - const result = await streamReader.read(); - if (result.done) break; - buffer += decoder.decode(result.value, { stream: true }); - let boundary = buffer.indexOf("\n\n"); - while (boundary >= 0) { - const block = buffer.slice(0, boundary); - buffer = buffer.slice(boundary + 2); - if (block.trim()) parseEvent(block); - boundary = buffer.indexOf("\n\n"); + const connection = (async () => { + try { + let response; + for (let attempt = 0; ; attempt += 1) { + response = await fetch("./_kandev/v1/events", { + headers: cursor ? { "Last-Event-ID": cursor } : {}, + signal: controller.signal, + }); + if (response.status !== 429 || attempt >= 31) break; + await response.text(); + await new Promise((resolve) => setTimeout(resolve, Math.min(250, 50 * (attempt + 1)))); } + if (!response.ok || !response.body) throw new Error("event stream unavailable"); + text("canvas-fixture-sse-status", "connected"); + streamReader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const result = await streamReader.read(); + if (result.done) break; + buffer += decoder.decode(result.value, { stream: true }); + let boundary = buffer.indexOf("\n\n"); + while (boundary >= 0) { + const block = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + if (block.trim()) parseEvent(block); + boundary = buffer.indexOf("\n\n"); + } + } + } catch (error) { + if (!controller.signal.aborted) text("canvas-fixture-sse-status", "disconnected"); } - } catch (error) { - if (!controller.signal.aborted) text("canvas-fixture-sse-status", "disconnected"); - } + })(); + streamCompletion = connection; + await connection; }; document.querySelector('[data-testid="canvas-fixture-continue"]')?.addEventListener("click", async () => { diff --git a/apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts b/apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts new file mode 100644 index 0000000000..3461cabf0d --- /dev/null +++ b/apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts @@ -0,0 +1,121 @@ +import { expect, test } from "../../fixtures/test-base"; +import { devices, type Browser, type BrowserContext, type Page } from "@playwright/test"; +import { + canvasHref, + enableCanvasFeature, + expectCanvasFrameFillsHost, + getCanvas, + removeCanvas, + seedTaskCanvas, +} from "./canvas-fixture"; +import { + startCanvasAuthenticatedProxy, + type CanvasAuthenticatedProxy, +} from "./canvas-authenticated-proxy-fixture"; + +async function openMobileCanvas( + browser: Browser, + proxy: CanvasAuthenticatedProxy, + canvasId: string, +): Promise<{ context: BrowserContext; page: Page }> { + const context = await browser.newContext({ + ...devices["Pixel 5"], + baseURL: proxy.origin, + ignoreHTTPSErrors: true, + }); + await context.addInitScript(() => { + localStorage.setItem("kandev.onboarding.completed", "true"); + delete (window as unknown as { __KANDEV_API_PORT?: string }).__KANDEV_API_PORT; + }); + await proxy.setAuthentication(context); + const page = await context.newPage(); + await page.goto(canvasHref(canvasId)); + return { context, page }; +} + +test.describe("authenticated same-origin canvas runtime on mobile", () => { + test("loads retained canvas data through cookie authentication on phone", async ({ + browser, + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(180_000); + const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); + let canvasId: string | undefined; + let proxy: CanvasAuthenticatedProxy | undefined; + let context: BrowserContext | undefined; + try { + const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true); + canvasId = seeded.canvas.id; + proxy = await startCanvasAuthenticatedProxy(backend.baseUrl); + const opened = await openMobileCanvas(browser, proxy, canvasId); + context = opened.context; + const page = opened.page; + + await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", { + timeout: 30_000, + }); + await expectCanvasFrameFillsHost(page); + const frame = page.frameLocator('iframe[title="E2E Plugin Canvas"]'); + await expect(frame.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId); + await expect(frame.getByTestId("canvas-fixture-task-count")).toHaveText("1"); + await expect(frame.getByTestId("canvas-fixture-sse-status")).toHaveText("connected"); + await expect.poll(() => proxy?.count("context", true) ?? 0).toBeGreaterThan(0); + await expect.poll(() => proxy?.count("data", true) ?? 0).toBeGreaterThan(0); + await expect.poll(() => proxy?.count("events", true) ?? 0).toBeGreaterThan(0); + } finally { + await context?.close(); + await proxy?.close(); + if (canvasId) await removeCanvas(apiClient, canvasId); + await releaseFeature(); + } + }); + + test("recovers after proxy authentication expires on phone", async ({ + browser, + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(180_000); + const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); + let canvasId: string | undefined; + let proxy: CanvasAuthenticatedProxy | undefined; + let context: BrowserContext | undefined; + try { + const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true); + canvasId = seeded.canvas.id; + const releaseBefore = (await getCanvas(apiClient, canvasId))?.active_release_id; + proxy = await startCanvasAuthenticatedProxy(backend.baseUrl); + const opened = await openMobileCanvas(browser, proxy, canvasId); + context = opened.context; + const page = opened.page; + await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", { + timeout: 30_000, + }); + + await proxy.clearAuthentication(context); + await page.reload(); + await expect(page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", { + timeout: 30_000, + }); + await expect(page.getByTestId("web-app-frame")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Try again", exact: true })).toBeVisible(); + + await proxy.setAuthentication(context); + await page.getByRole("button", { name: "Try again", exact: true }).tap(); + await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", { + timeout: 30_000, + }); + expect((await getCanvas(apiClient, canvasId))?.active_release_id).toBe(releaseBefore); + } finally { + await context?.close(); + await proxy?.close(); + if (canvasId) await removeCanvas(apiClient, canvasId); + await releaseFeature(); + } + }); +}); diff --git a/apps/web/e2e/tests/canvas/plugin-canvas.spec.ts b/apps/web/e2e/tests/canvas/plugin-canvas.spec.ts index 2caf70180c..316220d563 100644 --- a/apps/web/e2e/tests/canvas/plugin-canvas.spec.ts +++ b/apps/web/e2e/tests/canvas/plugin-canvas.spec.ts @@ -27,8 +27,10 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => { const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); try { await testPage.goto(`/?workspaceId=${encodeURIComponent(seedData.workspaceId)}`); - await expect(testPage.getByTestId("kanban-board")).toBeVisible(); - await expect(testPage.getByTestId("sidebar-canvases-settings")).toBeVisible(); + await expect(testPage.getByTestId("kanban-board")).toBeVisible({ timeout: 20_000 }); + await expect(testPage.getByTestId("sidebar-canvases-settings")).toBeVisible({ + timeout: 20_000, + }); const sectionHeader = testPage.getByRole("button", { name: /canvases/i }).first(); await sectionHeader.click(); @@ -259,6 +261,15 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => { ) .toBe(true); await expectCanvasFrameFillsHost(testPage); + const canvasOverflow = testPage + .getByTestId("canvas-host-header") + .getByTestId("panel-header-overflow"); + await expect(canvasOverflow).toBeVisible(); + await canvasOverflow.click(); + await expect( + testPage.getByRole("menuitem", { name: "Releases and permissions", exact: true }), + ).toBeVisible(); + await testPage.keyboard.press("Escape"); const fixture = testPage.frameLocator('iframe[title="E2E Plugin Canvas"]'); await expect(fixture.getByTestId("canvas-fixture-script")).toHaveText("inline-ready"); await expect(fixture.getByTestId("canvas-fixture-appearance-mode")).toHaveText("light"); @@ -293,7 +304,9 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => { await fixture.getByTestId("canvas-fixture-reconnect").dispatchEvent("click"); await expect(fixture.getByTestId("canvas-fixture-sse-status")).toHaveText("connected"); await fixture.getByTestId("canvas-fixture-resync").dispatchEvent("click"); - await expect(fixture.getByTestId("canvas-fixture-sse-resync")).toHaveText("received"); + await expect(fixture.getByTestId("canvas-fixture-sse-resync")).toHaveText("received", { + timeout: 15_000, + }); await expect .poll( diff --git a/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts b/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts index 4b64d7f2d0..4ddc8ba830 100644 --- a/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts @@ -326,6 +326,7 @@ test("mobile Send Now resumes Auto-run in targeted order without overflow", asyn }) => { test.setTimeout(120_000); + const gateway = watchWs(testPage); const { session, taskId, sessionId } = await seedBusyQueueTask(testPage, apiClient, seedData); const chat = session.activeChat(); const markerA = "mobile targeted A response"; @@ -365,7 +366,12 @@ test("mobile Send Now resumes Auto-run in targeted order without overflow", asyn await assertNoDocumentHorizontalOverflow(testPage); + const sendNowResponse = gateway.waitForResponse("message.queue.send_now"); await rowSendNow.tap(); + await sendNowResponse; + await expect + .poll(() => apiClient.getQueueStatus(queueIdentity).then((status) => status.count)) + .toBe(2); await expect(panel.getByTestId("queue-entry-text")).toHaveCount(2, { timeout: 10_000 }); await expect(panel.getByTestId("queue-entry-text").nth(0)).toContainText(markerA); await expect(panel.getByTestId("queue-entry-text").nth(1)).toContainText(markerC); diff --git a/apps/web/e2e/tests/git/git-changes-panel.spec.ts b/apps/web/e2e/tests/git/git-changes-panel.spec.ts index 817cf1f78b..e20e2fb0cf 100644 --- a/apps/web/e2e/tests/git/git-changes-panel.spec.ts +++ b/apps/web/e2e/tests/git/git-changes-panel.spec.ts @@ -1896,7 +1896,7 @@ test.describe("Git Changes Panel", () => { expect(await diffViewerOpen(), "no cumulative diff panel before clicking Diff").toBe(false); // Click the "Diff" button in the header to open the cumulative diff view - await session.changes.getByRole("button", { name: "Diff", exact: true }).click(); + await session.openChangesDiff(); // Assert what the click actually opens. Without this the checks below are // vacuous: the two commit texts were already visible before the click and diff --git a/apps/web/e2e/tests/mobile-panel-toolbars.spec.ts b/apps/web/e2e/tests/mobile-panel-toolbars.spec.ts new file mode 100644 index 0000000000..197a563b80 --- /dev/null +++ b/apps/web/e2e/tests/mobile-panel-toolbars.spec.ts @@ -0,0 +1,177 @@ +import { expect, test } from "../fixtures/test-base"; +import type { SeedData } from "../fixtures/test-base"; +import type { Page } from "@playwright/test"; +import type { ApiClient } from "../helpers/api-client"; +import { SessionPage } from "../pages/session-page"; + +async function createToolbarTask( + page: Page, + apiClient: ApiClient, + seedData: SeedData, + title: string, +): Promise { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await page.goto(`/t/${task.id}`); + const session = new SessionPage(page); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 45_000 }); + return session; +} + +async function expectTouchHeaders(page: Page, surface: string) { + await expect + .poll( + () => + page.locator("[data-panel-header]:visible").evaluateAll((headers) => + headers.map((header) => { + const rect = header.getBoundingClientRect(); + return { + height: rect.height, + bottom: rect.bottom, + nextTop: header.nextElementSibling?.getBoundingClientRect().top ?? null, + wrap: window.getComputedStyle(header).flexWrap, + }; + }), + ), + { timeout: 15_000, message: `Waiting for a touch header in ${surface}` }, + ) + .not.toEqual([]); + + const headers = await page.locator("[data-panel-header]:visible").evaluateAll((items) => + items.map((header) => { + const rect = header.getBoundingClientRect(); + return { + height: rect.height, + bottom: rect.bottom, + nextTop: header.nextElementSibling?.getBoundingClientRect().top ?? null, + wrap: window.getComputedStyle(header).flexWrap, + }; + }), + ); + expect(headers.length, `expected touch header in ${surface}`).toBeGreaterThan(0); + for (const header of headers) { + expect(Math.abs(header.height - 48), `${surface} header height`).toBeLessThanOrEqual(1); + expect(header.wrap).toBe("nowrap"); + if (header.nextTop !== null) { + expect( + Math.abs(header.nextTop - header.bottom), + `${surface} content edge`, + ).toBeLessThanOrEqual(1); + } + } +} + +async function expectNoOverflow(page: Page, surface: string) { + await expect + .poll( + () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), + { timeout: 5_000, message: `${surface} has horizontal overflow` }, + ) + .toBe(true); +} + +async function expectTouchControls(page: Page, surface: string) { + const controls = await page.locator("[data-panel-header]:visible").evaluateAll((headers) => { + const measurements: Array<{ + label: string; + tagName: string; + height: number; + width: number; + }> = []; + for (const header of headers) { + const candidates = header.querySelectorAll( + "button, a, input, select, textarea, [role='button']", + ); + for (const control of candidates) { + const style = window.getComputedStyle(control); + if (style.display === "none" || style.visibility === "hidden") continue; + const rect = control.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) continue; + measurements.push({ + label: control.getAttribute("aria-label") ?? control.textContent ?? control.tagName, + tagName: control.tagName, + height: rect.height, + width: rect.width, + }); + } + } + return measurements; + }); + + expect(controls, `${surface} should render touch controls`).not.toEqual([]); + for (const control of controls) { + expect(control.height, `${surface} ${control.label} height`).toBeGreaterThanOrEqual(44); + if (!new Set(["INPUT", "SELECT", "TEXTAREA"]).has(control.tagName)) { + expect(control.width, `${surface} ${control.label} width`).toBeGreaterThanOrEqual(44); + } + } +} + +test.describe("touch panel toolbars", () => { + test("keeps the 390px phone panels contained and preserves task navigation", async ({ + testPage, + apiClient, + seedData, + }) => { + await testPage.setViewportSize({ width: 390, height: 844 }); + expect(await testPage.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(true); + + const session = await createToolbarTask(testPage, apiClient, seedData, "Panel toolbar phone"); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); + await expect(testPage.getByTestId("file-tree-scroll")).toBeVisible(); + await expectTouchHeaders(testPage, "390px Files"); + await expectTouchControls(testPage, "390px Files"); + + const searchButton = testPage.getByRole("button", { name: "Search files", exact: true }); + await expect(searchButton).toBeVisible(); + expect((await searchButton.boundingBox())?.height).toBeGreaterThanOrEqual(44); + await expectNoOverflow(testPage, "390px Files"); + + await testPage.getByRole("button", { name: /Changes/ }).tap(); + await expect(testPage.getByTestId("mobile-changes-panel")).toBeVisible(); + await expectTouchHeaders(testPage, "390px Changes"); + await expectTouchControls(testPage, "390px Changes"); + await expectNoOverflow(testPage, "390px Changes"); + + await testPage.getByRole("button", { name: "Chat", exact: true }).tap(); + await expect(session.activeChat()).toBeVisible(); + }); + + test("keeps 767px and 900px coarse headers at touch geometry", async ({ + testPage, + tabletTestPage, + apiClient, + seedData, + }) => { + await testPage.setViewportSize({ width: 767, height: 900 }); + await createToolbarTask(testPage, apiClient, seedData, "Panel toolbar coarse boundaries"); + await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); + await expect(testPage.getByTestId("file-tree-scroll")).toBeVisible(); + await expectTouchHeaders(testPage, "767px Files"); + await expectTouchControls(testPage, "767px Files"); + await expectNoOverflow(testPage, "767px Files"); + + const taskHref = await testPage.url(); + await tabletTestPage.goto(taskHref); + const tabletSession = new SessionPage(tabletTestPage); + await tabletSession.waitForLoad(); + await tabletSession.waitForChatIdle({ timeout: 45_000 }); + expect(await tabletTestPage.evaluate(() => window.innerWidth)).toBe(900); + expect(await tabletTestPage.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(true); + await expect(tabletTestPage.getByTestId("file-tree-scroll")).toBeVisible(); + await expectTouchHeaders(tabletTestPage, "900px Files"); + await expectTouchControls(tabletTestPage, "900px Files"); + await expectNoOverflow(tabletTestPage, "900px Files"); + }); +}); diff --git a/apps/web/e2e/tests/panel-toolbars.spec.ts b/apps/web/e2e/tests/panel-toolbars.spec.ts new file mode 100644 index 0000000000..5bc2ec0e04 --- /dev/null +++ b/apps/web/e2e/tests/panel-toolbars.spec.ts @@ -0,0 +1,437 @@ +import { expect, test } from "../fixtures/test-base"; +import type { SeedData } from "../fixtures/test-base"; +import type { Locator, Page } from "@playwright/test"; +import type { ApiClient } from "../helpers/api-client"; +import { SessionPage } from "../pages/session-page"; +import { GitHelper, makeGitEnv } from "../helpers/git-helper"; +import { enableCanvasFeature, removeCanvas, seedTaskCanvas } from "./canvas/canvas-fixture"; + +type HeaderGeometry = { + height: number; + top: number; + bottom: number; + clientHeight: number; + scrollHeight: number; + flexWrap: string; + nextTop: number | null; +}; + +async function createToolbarTask( + page: Page, + apiClient: ApiClient, + seedData: SeedData, + title: string, +): Promise { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await page.goto(`/t/${task.id}`); + const session = new SessionPage(page); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 45_000 }); + return session; +} + +async function readVisibleHeaderGeometry(panel: Locator): Promise { + return panel.locator("[data-panel-header]:visible").evaluateAll((headers) => + headers.map((header) => { + const rect = header.getBoundingClientRect(); + const next = header.nextElementSibling; + const nextRect = next?.getBoundingClientRect(); + const style = window.getComputedStyle(header); + return { + height: rect.height, + top: rect.top, + bottom: rect.bottom, + clientHeight: header.clientHeight, + scrollHeight: header.scrollHeight, + flexWrap: style.flexWrap, + nextTop: nextRect && nextRect.width > 0 && nextRect.height > 0 ? nextRect.top : null, + }; + }), + ); +} + +async function expectHeadersAtHeight(panel: Locator, expectedHeight: number, surface: string) { + await expect + .poll(() => readVisibleHeaderGeometry(panel), { + timeout: 15_000, + message: `Waiting for a visible shared header in ${surface}`, + }) + .not.toEqual([]); + + const headers = await readVisibleHeaderGeometry(panel); + expect(headers.length, `expected visible shared header in ${surface}`).toBeGreaterThan(0); + for (const header of headers) { + expect( + Math.abs(header.height - expectedHeight), + `${surface} header height`, + ).toBeLessThanOrEqual(1); + expect(header.scrollHeight).toBeLessThanOrEqual(header.clientHeight + 1); + expect(header.flexWrap).toBe("nowrap"); + if (header.nextTop !== null) { + expect( + Math.abs(header.nextTop - header.bottom), + `${surface} content edge`, + ).toBeLessThanOrEqual(1); + } + } +} + +async function expectNoDocumentOverflow(page: Page, surface: string) { + await expect + .poll( + () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), + { timeout: 5_000, message: `${surface} has document horizontal overflow` }, + ) + .toBe(true); +} + +async function expectHeaderControlsContained(panel: Locator, surface: string) { + await expect + .poll( + () => + panel.locator("[data-panel-header]:visible").evaluateAll((headers) => { + const failures: string[] = []; + for (const header of headers) { + const headerRect = header.getBoundingClientRect(); + const controls = header.querySelectorAll( + "button, a, input, select, textarea, [role='button']", + ); + for (const control of controls) { + const style = window.getComputedStyle(control); + if (style.display === "none" || style.visibility === "hidden") continue; + const rect = control.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) continue; + if ( + rect.left < headerRect.left - 1 || + rect.right > headerRect.right + 1 || + rect.top < headerRect.top - 1 || + rect.bottom > headerRect.bottom + 1 + ) { + failures.push( + control.getAttribute("aria-label") ?? control.textContent ?? "control", + ); + } + } + } + return failures; + }), + { timeout: 5_000, message: `${surface} has controls outside its header row` }, + ) + .toEqual([]); +} + +async function constrainDockviewPanel(panel: import("@playwright/test").Locator, width: number) { + await panel.evaluate((element, panelWidth) => { + const target = + element.closest(".dv-panel-view, .dv-panel") ?? + (element.hasAttribute("data-portal-panel") ? element.parentElement : element) ?? + element; + target.style.flex = `0 0 ${panelWidth}px`; + target.style.minWidth = `${panelWidth}px`; + target.style.width = `${panelWidth}px`; + }, width); +} + +test.describe("shared panel toolbars", () => { + test("keeps migrated panel families at one compact row on desktop", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await createToolbarTask(testPage, apiClient, seedData, "Panel toolbar desktop"); + + await session.clickTab("Files"); + await expect(session.files).toBeVisible(); + await expectHeadersAtHeight(session.files, 30, "Files"); + await expectNoDocumentOverflow(testPage, "Files"); + + await session.clickTab("Changes"); + await expect(session.changes).toBeVisible(); + await expectHeadersAtHeight(session.changes, 30, "Changes"); + await expectNoDocumentOverflow(testPage, "Changes"); + + await session.addBrowserPanel(); + await expect(session.browserPanel).toBeVisible(); + await session.browserAddressInput.fill( + "https://example.test/a-very-long-preview-path-that-must-remain-in-the-toolbar-input", + ); + await expectHeadersAtHeight(session.browserPanel, 30, "Browser"); + await expectNoDocumentOverflow(testPage, "Browser"); + }); + + test("keeps the fine-pointer compact geometry at the 768px boundary", async ({ + testPage, + apiClient, + seedData, + }) => { + await testPage.setViewportSize({ width: 768, height: 900 }); + expect(await testPage.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(false); + + const session = await createToolbarTask( + testPage, + apiClient, + seedData, + "Panel toolbar boundary", + ); + await session.clickTab("Files"); + await expect(session.files).toBeVisible(); + await expectHeadersAtHeight(session.files, 30, "768px Files"); + await expectNoDocumentOverflow(testPage, "768px Files"); + }); + + test("folds narrow Changes and Browser actions into reachable menus", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + const session = await createToolbarTask(testPage, apiClient, seedData, "Narrow panel actions"); + const git = new GitHelper(seedData.repositoryPath, makeGitEnv(backend.tmpDir)); + git.createFile("narrow-toolbar-actions.ts", "export const narrowToolbarActions = true;\n"); + + await session.clickTab("Changes"); + await expect(session.changes).toBeVisible(); + await expect(session.changesFileRow("narrow-toolbar-actions.ts")).toBeVisible({ + timeout: 15_000, + }); + await constrainDockviewPanel(session.changes, 240); + await expect(session.changes.getByTestId("panel-header-overflow").first()).toBeVisible(); + await session.changes.getByTestId("panel-header-overflow").first().click(); + await expect(testPage.getByRole("menuitem", { name: "Diff", exact: true })).toBeVisible(); + await expect(testPage.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible(); + await testPage.keyboard.press("Escape"); + await expectHeadersAtHeight(session.changes, 30, "narrow Changes"); + + await session.addBrowserPanel(); + await expect(session.browserPanel).toBeVisible(); + await constrainDockviewPanel(session.browserPanel, 240); + await expect(session.browserPanel.getByTestId("panel-header-overflow")).toBeVisible(); + await expectHeadersAtHeight(session.browserPanel, 30, "narrow Browser"); + await expectNoDocumentOverflow(testPage, "narrow panel actions"); + }); + + test("keeps the multiple-PR selector and review actions reachable in a narrow panel", async ({ + testPage, + apiClient, + seedData, + }) => { + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("reviewer"); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Multiple PR toolbar actions", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repositories: [{ repository_id: seedData.repositoryId, checkout_branch: "main" }], + }, + ); + const taskBranch = "main"; + await apiClient.mockGitHubAddPRs([ + { + number: 401, + title: "Toolbar review one", + state: "open", + head_branch: taskBranch, + base_branch: "main", + author_login: "reviewer", + repo_owner: "testorg", + repo_name: "testrepo", + }, + { + number: 402, + title: "Toolbar review two", + state: "open", + head_branch: taskBranch, + base_branch: "main", + author_login: "reviewer", + repo_owner: "testorg", + repo_name: "testrepo", + }, + ]); + await apiClient.mockGitHubAddPRFiles("testorg", "testrepo", 401, [ + { filename: "toolbar-one.ts", status: "added", additions: 1, deletions: 0 }, + ]); + await apiClient.mockGitHubAddPRFiles("testorg", "testrepo", 402, [ + { filename: "toolbar-two.ts", status: "added", additions: 1, deletions: 0 }, + ]); + await apiClient.mockGitHubAssociateTaskPR({ + workspace_id: seedData.workspaceId, + task_id: task.id, + owner: "testorg", + repo: "testrepo", + pr_number: 401, + pr_url: "https://github.com/testorg/testrepo/pull/401", + pr_title: "Toolbar review one", + head_branch: taskBranch, + base_branch: "main", + author_login: "reviewer", + }); + await apiClient.mockGitHubAssociateTaskPR({ + workspace_id: seedData.workspaceId, + task_id: task.id, + owner: "testorg", + repo: "testrepo", + pr_number: 402, + pr_url: "https://github.com/testorg/testrepo/pull/402", + pr_title: "Toolbar review two", + head_branch: taskBranch, + base_branch: "main", + author_login: "reviewer", + }); + + await expect.poll(() => apiClient.listTaskPRs(task.id), { timeout: 10_000 }).toHaveLength(2); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 45_000 }); + await session.clickTab("Changes"); + await expect(session.changes).toBeVisible(); + const changesOverflow = session.changes.getByTestId("panel-header-overflow").first(); + await expect(changesOverflow).toBeVisible({ timeout: 20_000 }); + await changesOverflow.click(); + await testPage.getByRole("menuitem", { name: "Diff", exact: true }).click(); + + const selector = testPage.getByTestId("changes-review-pr-selector-trigger"); + await expect(selector).toBeVisible({ timeout: 20_000 }); + const diffHeader = selector.locator("xpath=ancestor::*[@data-panel-header]"); + await constrainDockviewPanel(diffHeader, 240); + await expect(selector).toBeVisible(); + await expectHeaderControlsContained( + diffHeader.locator("xpath=.."), + "narrow multiple-PR Changes", + ); + + const overflow = diffHeader.getByTestId("panel-header-overflow"); + await expect(overflow).toBeVisible(); + await overflow.click(); + await expect( + testPage.getByRole("menuitem", { name: "Expand review", exact: true }), + ).toBeVisible(); + await testPage.keyboard.press("Escape"); + await expectNoDocumentOverflow(testPage, "narrow multiple-PR Changes"); + }); + + test("keeps narrow embedded canvas actions reachable beside the workbench", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(180_000); + const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); + let canvasId: string | undefined; + try { + const seeded = await seedTaskCanvas(testPage, apiClient, seedData); + canvasId = seeded.canvas.id; + const canvasPanel = testPage.getByTestId("canvas-host-panel"); + + await expect(canvasPanel).toBeVisible({ timeout: 20_000 }); + await expect(testPage.getByTestId("canvas-host-state")).toHaveText("Ready", { + timeout: 20_000, + }); + await constrainDockviewPanel(canvasPanel, 240); + + const overflow = canvasPanel.getByTestId("panel-header-overflow"); + await expect(overflow).toBeVisible(); + await overflow.click(); + await expect( + testPage.getByRole("menuitem", { name: "Releases and permissions", exact: true }), + ).toBeVisible(); + await expect( + testPage.getByRole("menuitem", { name: "Share canvas", exact: true }), + ).toBeVisible(); + await expect( + testPage.getByRole("menuitem", { name: "Promote canvas", exact: true }), + ).toBeVisible(); + await testPage.keyboard.press("Escape"); + + await expectHeaderControlsContained(canvasPanel, "narrow embedded canvas"); + await expectNoDocumentOverflow(testPage, "narrow embedded canvas"); + } finally { + if (canvasId) await removeCanvas(apiClient, canvasId); + await releaseFeature(); + } + }); + + test("keeps a dirty editor save action and secondary actions reachable when narrow", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await createToolbarTask(testPage, apiClient, seedData, "Narrow editor actions"); + + await session.clickTab("Files"); + await expect(session.files).toBeVisible(); + await session.fileTreeNode("walkthrough_base.txt").click(); + + const editor = testPage.locator(".monaco-editor:visible").first(); + await expect(editor).toBeVisible({ timeout: 20_000 }); + await editor.click(); + await expect(editor.locator(".native-edit-context")).toBeFocused({ timeout: 5_000 }); + await testPage.keyboard.press("End"); + await testPage.keyboard.insertText("\n// toolbar coverage"); + + const editorPanel = testPage.locator('[data-portal-panel="preview:file-editor"]'); + await expect(editorPanel.locator("[data-panel-header]:visible")).toHaveCount(1, { + timeout: 10_000, + }); + await constrainDockviewPanel(editorPanel, 240); + const editorHeader = editorPanel.locator("[data-panel-header]:visible").first(); + await expect(editorHeader.getByTestId("panel-header-overflow")).toBeVisible(); + await expect(editorHeader.getByRole("button", { name: /Save/ })).toBeEnabled({ + timeout: 10_000, + }); + await expectHeaderControlsContained(editorPanel, "narrow dirty editor"); + + await editorHeader.getByTestId("panel-header-overflow").click(); + await expect(testPage.getByRole("menuitem", { name: /word wrap/i })).toBeVisible(); + await testPage.keyboard.press("Escape"); + await expectNoDocumentOverflow(testPage, "narrow dirty editor"); + }); + + test("uses touch geometry and contains controls at fine-pointer phone widths", async ({ + testPage, + apiClient, + seedData, + }) => { + await testPage.setViewportSize({ width: 390, height: 844 }); + expect(await testPage.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(false); + await createToolbarTask(testPage, apiClient, seedData, "Fine pointer phone toolbar"); + const mobileFiles = testPage.getByTestId("files-panel"); + const mobileChanges = testPage.getByTestId("mobile-changes-panel"); + + for (const width of [390, 767]) { + await testPage.setViewportSize({ width, height: 844 }); + await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible(); + await testPage.getByRole("button", { name: "Files", exact: true }).click(); + await expect(testPage.getByTestId("file-tree-scroll")).toBeVisible(); + await expectHeadersAtHeight(mobileFiles, 48, `${width}px fine-pointer Files`); + await expectHeaderControlsContained(mobileFiles, `${width}px fine-pointer Files`); + await expect(testPage.getByRole("button", { name: "Search files", exact: true })).toHaveCSS( + "height", + "44px", + ); + await expectNoDocumentOverflow(testPage, `${width}px fine-pointer Files`); + + await testPage.getByRole("button", { name: /Changes/ }).click(); + await expect(testPage.getByTestId("mobile-changes-panel")).toBeVisible(); + await expectHeadersAtHeight(mobileChanges, 48, `${width}px fine-pointer Changes`); + await expectHeaderControlsContained(mobileChanges, `${width}px fine-pointer Changes`); + await expectNoDocumentOverflow(testPage, `${width}px fine-pointer Changes`); + } + }); +}); diff --git a/apps/web/e2e/tests/review/code-review-on-demand.spec.ts b/apps/web/e2e/tests/review/code-review-on-demand.spec.ts index 91df9ee2ad..af1036aa69 100644 --- a/apps/web/e2e/tests/review/code-review-on-demand.spec.ts +++ b/apps/web/e2e/tests/review/code-review-on-demand.spec.ts @@ -73,10 +73,7 @@ test.describe("Native code review — on demand", () => { timeout: 30_000, }); - await testPage - .getByTestId("changes-panel") - .getByRole("button", { name: "Diff", exact: true }) - .click(); + await session.openChangesDiff(); await testPage.getByRole("button", { name: "Expand review" }).click(); const dialog = testPage.getByRole("dialog", { name: "Review Changes" }); await expect(dialog).toBeVisible(); @@ -145,10 +142,7 @@ test.describe("Native code review — on demand", () => { const changesTabAfterReload = testPage.getByTestId("dockview-tab-changes"); await expect(changesTabAfterReload).toBeVisible({ timeout: 30_000 }); await changesTabAfterReload.click(); - await testPage - .getByTestId("changes-panel") - .getByRole("button", { name: "Diff", exact: true }) - .click(); + await session.openChangesDiff(); await testPage.getByRole("button", { name: "Expand review" }).click(); const reopened = testPage.getByRole("dialog", { name: "Review Changes" }); await expect(reopened).toBeVisible(); @@ -208,10 +202,7 @@ test.describe("Native code review — on demand", () => { ).toBeVisible({ timeout: 30_000, }); - await testPage - .getByTestId("changes-panel") - .getByRole("button", { name: "Diff", exact: true }) - .click(); + await session.openChangesDiff(); await testPage.getByRole("button", { name: "Expand review" }).click(); const dialog = testPage.getByRole("dialog", { name: "Review Changes" }); await expect(dialog).toBeVisible(); diff --git a/apps/web/e2e/tests/review/review-file-status.spec.ts b/apps/web/e2e/tests/review/review-file-status.spec.ts index 3c482eb93e..c35e4095a7 100644 --- a/apps/web/e2e/tests/review/review-file-status.spec.ts +++ b/apps/web/e2e/tests/review/review-file-status.spec.ts @@ -112,10 +112,7 @@ test.describe("Review file status", () => { await expect(testPage.getByTestId(rowTestId)).toBeVisible({ timeout: 20_000 }); } - await testPage - .getByTestId("changes-panel") - .getByRole("button", { name: "Diff", exact: true }) - .click(); + await session.openChangesDiff(); await testPage.getByRole("button", { name: "Expand review" }).click(); const dialog = testPage.getByRole("dialog", { name: "Review Changes" }); await expect(dialog).toBeVisible(); diff --git a/apps/web/e2e/tests/review/review-markdown-preview.spec.ts b/apps/web/e2e/tests/review/review-markdown-preview.spec.ts index 7a2e935a02..5602610948 100644 --- a/apps/web/e2e/tests/review/review-markdown-preview.spec.ts +++ b/apps/web/e2e/tests/review/review-markdown-preview.spec.ts @@ -42,7 +42,7 @@ test.describe("Review Markdown preview", () => { await expect(session.changes.getByTestId(`file-row-${MARKDOWN_FILE}`)).toBeVisible({ timeout: 20_000, }); - await session.changes.getByRole("button", { name: "Diff", exact: true }).click(); + await session.openChangesDiff(); await testPage.getByRole("button", { name: "Expand review" }).click(); const dialog = testPage.getByRole("dialog", { name: "Review Changes" }); diff --git a/apps/web/e2e/tests/settings/mobile-workspace-repository-sets.spec.ts b/apps/web/e2e/tests/settings/mobile-workspace-repository-sets.spec.ts index c3120924d2..c810f93055 100644 --- a/apps/web/e2e/tests/settings/mobile-workspace-repository-sets.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-workspace-repository-sets.spec.ts @@ -138,7 +138,9 @@ test.describe("Mobile workspace repository sets", () => { await expect(dropdown).toBeVisible(); await expect(dropdown.getByPlaceholder("Search branches...")).toBeVisible(); await expect(dropdown.getByText("origin/main")).toBeVisible(); - await expect(dropdown.getByText("origin", { exact: true })).toBeVisible(); + const remoteMainOption = dropdown.getByRole("option", { name: /^origin\/main origin/ }); + await expect(remoteMainOption).toBeVisible(); + await expect(remoteMainOption.getByText("origin", { exact: true })).toBeVisible(); const search = dropdown.getByPlaceholder("Search branches..."); await search.fill("origin"); diff --git a/apps/web/e2e/tests/settings/workspace-repository-sets.spec.ts b/apps/web/e2e/tests/settings/workspace-repository-sets.spec.ts index cf82a7b182..f8a560fa4d 100644 --- a/apps/web/e2e/tests/settings/workspace-repository-sets.spec.ts +++ b/apps/web/e2e/tests/settings/workspace-repository-sets.spec.ts @@ -87,8 +87,9 @@ test.describe("Workspace repository sets settings", () => { await expect(dropdown.getByPlaceholder("Search branches...")).toBeVisible(); await expect(dropdown.getByText("Branches")).toBeVisible(); await expect(dropdown.getByRole("option", { name: /^main local/ })).toBeVisible(); - await expect(dropdown.getByRole("option", { name: /^origin\/main origin/ })).toBeVisible(); - await expect(dropdown.getByText("origin", { exact: true })).toBeVisible(); + const remoteMainOption = dropdown.getByRole("option", { name: /^origin\/main origin/ }); + await expect(remoteMainOption).toBeVisible(); + await expect(remoteMainOption.getByText("origin", { exact: true })).toBeVisible(); const branchList = dropdown.getByRole("listbox"); await branchList.hover(); diff --git a/apps/web/e2e/tests/task/mobile-plan-toolbar-implement.spec.ts b/apps/web/e2e/tests/task/mobile-plan-toolbar-implement.spec.ts index 2fc1ad28ad..a3d26cf6b5 100644 --- a/apps/web/e2e/tests/task/mobile-plan-toolbar-implement.spec.ts +++ b/apps/web/e2e/tests/task/mobile-plan-toolbar-implement.spec.ts @@ -86,10 +86,17 @@ test.describe("mobile: Plan toolbar implement", () => { controlHeight: controlRect.height, }; }); - expect(toolbarSpacing?.toolbarHeight).toBe(30); - expect(toolbarSpacing?.controlHeight).toBe(22); - expect(toolbarSpacing?.top).toBeGreaterThanOrEqual(1); - expect(toolbarSpacing?.bottom).toBeGreaterThanOrEqual(1); + const expectedToolbarSpacing = await testPage.evaluate(() => { + const usesTouchGeometry = + matchMedia("(max-width: 47.999rem)").matches || matchMedia("(pointer: coarse)").matches; + return usesTouchGeometry + ? { toolbarHeight: 48, controlHeight: 46 } + : { toolbarHeight: 30, controlHeight: 22 }; + }); + expect(toolbarSpacing?.toolbarHeight).toBe(expectedToolbarSpacing.toolbarHeight); + expect(toolbarSpacing?.controlHeight).toBe(expectedToolbarSpacing.controlHeight); + expect(toolbarSpacing?.top).toBeGreaterThanOrEqual(0); + expect(toolbarSpacing?.bottom).toBeGreaterThanOrEqual(0); const overflow = await testPage.evaluate(() => { const root = document.scrollingElement ?? document.documentElement; diff --git a/docs/decisions/2026-08-26-plugin-backed-web-app-canvases.md b/docs/decisions/2026-08-26-plugin-backed-web-app-canvases.md index 99c4994b4d..baaf7a2714 100644 --- a/docs/decisions/2026-08-26-plugin-backed-web-app-canvases.md +++ b/docs/decisions/2026-08-26-plugin-backed-web-app-canvases.md @@ -11,6 +11,10 @@ The bounded startup acknowledgement is specified in the [runtime design](../specs/plugins/system-design/isolated-web-app-contributions.md#runtime-startup-protocol); it adds no privileged host API. +The browser isolation and credential rules below are amended by +[trusted same-origin canvases](2026-09-19-trusted-same-origin-canvases.md). +The original rationale remains historical; the new decision owns that boundary. + ## Context The first canvas design used server-owned Markdown and structured blocks. That diff --git a/docs/decisions/2026-09-19-trusted-same-origin-canvases.md b/docs/decisions/2026-09-19-trusted-same-origin-canvases.md new file mode 100644 index 0000000000..9006bc8d2d --- /dev/null +++ b/docs/decisions/2026-09-19-trusted-same-origin-canvases.md @@ -0,0 +1,68 @@ +# ADR-2026-09-19-trusted-same-origin-canvases: Trust same-origin canvas code + +**Status:** accepted +**Date:** 2026-09-19 +**Area:** frontend, backend, protocol, security + +## Context + +Cloudflare Access protects a Kandev installation with a browser cookie. +The existing canvas document has an opaque origin, and its startup request +explicitly omits credentials. Cloudflare redirects that request to login. +The canvas CSP blocks the redirect, and the host reports an unavailable canvas. + +The user selected same-origin canvases after reviewing the isolation tradeoffs. +They prefer a smaller initial implementation over a parent-page request bridge. +This choice changes the browser trust boundary, including retained and imported +packages. It is not only a transport correction. + +## Decision + +Treat packages executed by the shared web-application runtime as trusted code +with the viewing user's same-origin browser authority. Add `allow-same-origin` +to both the iframe sandbox and the runtime response CSP sandbox. Use +`credentials: "same-origin"` for the host-owned startup request. + +Keep ordinary relative browser requests and the existing capability protocol. +Do not add a bridge, runtime mode, manifest switch, or credential-fetch shim. +Do not rewrite stored releases or override explicit authored credential omission. + +The backend remains authoritative for authorization, domain validation, and +capability-protocol grants. A browser cookie does not replace a runtime token. +However, same-origin code can call ordinary APIs as the viewing user. Canvas +grants do not constrain those calls or protect the parent DOM and browser storage. + +Keep the remaining declared CSP and sandbox restrictions, exact frame ancestors, +and existing backend checks. Do not grant credentialed CORS to arbitrary origins. +These policies do not contain trusted code that can access the parent document. + +This decision amends the browser-isolation and credential portions of +[the original canvas decision](2026-08-26-plugin-backed-web-app-canvases.md). +Package validation, immutable releases, scope, grants, and distribution remain. +The [implementation plan](../plans/canvas-same-origin-auth/plan.md) is pending. + +## Consequences + +- Same-origin startup and default API requests can carry reverse-proxy cookies. +- Backend validation still rejects unauthorized or invalid requests. +- Canvas code can modify host DOM, access shared storage, and use user-session APIs. +- Capability revocation does not undo parent changes or stop all code already installed there. +- Imported and retained canvases must be trusted before they execute. +- Browser storage is available under normal browser rules, but is not a supported shared-state contract. +- Existing code that explicitly omits credentials needs an author change when a proxy requires cookies. +- Proxy analytics injection remains a separate deployment concern. Exclude incompatible injection on runtime responses. +- Distinct-origin desktop hosts keep their existing capability transport; this change does not forward cookies across origins. +- A later isolation design needs an explicit migration for packages that start depending on host access. + +## Alternatives Considered + +- **Narrow Cloudflare Access bypass:** Smallest deployment fix, but requires a special operator rule. +- **Credentialed opaque-origin requests:** Requires cookie and CORS changes, with browser-dependent cookie restrictions. +- **Parent-page request bridge:** Preserves host isolation but needs a new transport, asset loading, and compatibility design. +- **Separate canvas origin:** Preserves separation from the SPA, but adds deployment and authentication complexity. +- **Same-origin only in the iframe attribute:** Insufficient. The response CSP would retain the opaque sandbox, and startup would still omit credentials. + +## Related contracts + +- [Runtime requirements](../specs/plugins/requirements/isolated-web-app-contributions.md) +- [Runtime design](../specs/plugins/system-design/isolated-web-app-contributions.md#same-origin-transport-and-trust) diff --git a/docs/plans/canvas-runtime-permission-fixes/plan.md b/docs/plans/canvas-runtime-permission-fixes/plan.md index 0e42614716..2a2792a1e3 100644 --- a/docs/plans/canvas-runtime-permission-fixes/plan.md +++ b/docs/plans/canvas-runtime-permission-fixes/plan.md @@ -310,3 +310,10 @@ Tasks 03 and 04 implementation verification completed on 2026-09-11: - Review source labels need current authorization and graceful deletion fallback. - Concurrent branches may touch the shared canvas E2E fixture or spec pair; reconcile current base before implementation and final delivery. + +## Same-origin follow-up + +The [trusted same-origin package](../canvas-same-origin-auth/plan.md) supersedes +this package's opaque-origin and cookie-isolation assumptions. Existing results +record the earlier implementation. They do not verify the new runtime behavior. +The follow-up work orders own the replacement tests and documentation. diff --git a/docs/plans/canvas-same-origin-auth/plan.md b/docs/plans/canvas-same-origin-auth/plan.md new file mode 100644 index 0000000000..e234bb4152 --- /dev/null +++ b/docs/plans/canvas-same-origin-auth/plan.md @@ -0,0 +1,207 @@ +--- +created: 2026-09-19 +status: done +requirements: + - REQ-PLUGINS-ISOLATED-WEB-APPS-001 + - REQ-PLUGINS-ISOLATED-WEB-APPS-003 + - REQ-PLUGINS-ISOLATED-WEB-APPS-007 + - REQ-PLUGINS-ISOLATED-WEB-APPS-012 + - REQ-PLUGINS-ISOLATED-WEB-APPS-013 + - REQ-UI-PANEL-TOOLBARS-001 + - REQ-CANVASES-AGENT-WEB-APPS-006 + - REQ-CANVASES-AGENT-WEB-APPS-007 +system_design: + - ../../specs/plugins/system-design/isolated-web-app-contributions.md + - ../../specs/ui/system-design/panel-toolbars.md + - ../../specs/canvases/system-design/canvas-host-chrome.md +legacy_specs: [] +--- + +# Implementation Plan: Canvas authentication and consistent panel toolbars + +## Overview + +Allow a trusted canvas to use the viewing browser's reverse-proxy login cookie. +Change both sandbox declarations and startup credentials in one vertical slice. +Then prove browser behavior through a cookie-gated HTTPS proxy and update public guidance. +Finally consolidate canvas chrome and migrate inconsistent primary panel toolbars. + +The user selected this trust model. Backend validation remains authoritative, +but canvas grants do not constrain direct user-session API calls or parent DOM access. +See the [decision](../../decisions/2026-09-19-trusted-same-origin-canvases.md). + +## Scope + +### In scope + +- Shared `ui.web_apps` runtime, including task, workspace, imported, and installed-plugin canvases. +- Same-origin iframe and response CSP, plus cookie-capable startup. +- Existing capability, grant, input-validation, and exact-origin checks. +- Retained release compatibility and unchanged persisted artifact bytes. +- Desktop and phone tests through a cookie-authenticated proxy. +- Authoring and operator documentation for the changed trust boundary. +- Shared fixed-height panel toolbars, caller audit, and a single canvas host header. + +### Out of scope + +- A parent-page bridge, separate hostname, new runtime mode, or feature toggle. +- Changes to manifest grants, backend domain validation, or token lifetime. +- Native plugin bundles, arbitrary preview iframes, and unrelated CORS changes. +- Automatic Cloudflare account changes or enabling remote analytics scripts. +- Rewriting explicit `credentials: "omit"` in retained application source. +- A promise that same-origin code remains limited to its canvas grants. + +## Technical approach + +`WebAppFrame` and `BuildContentSecurityPolicy` must both include +`allow-same-origin`. The injected `hostRuntimeBootstrap` must fetch context with +`credentials: "same-origin"`. Ordinary relative fetches, assets, XHR, and +EventSource requests then use same-origin browser cookie behavior. + +`Runtime.Serve`, its binding validator, and the protocol handlers remain the +capability gate. Preserve the restricted opaque-origin CORS fallback for older +or distinct-origin callers. Same-origin requests need no wildcard CORS change. +Do not add `Access-Control-Allow-Credentials: true` for `Origin: null`. + +The proxy test must require a Secure, HttpOnly, SameSite=Lax cookie for runtime +HTML, assets, context, data, writes, and events. Forward the public Host and +browser-supplied cookies. Do not fabricate missing cookies or replace backend +responses with successful stubs. A live browser must observe Ready and data. + +Use the existing HTTPS alias fixture as the pattern. It currently buffers SSE +and substitutes an empty stream; the new coverage needs real event forwarding. +Its current virtual wrapper does not mount `WebAppFrame`. New tests must open +the real canvas host, not only that wrapper, to test both sandbox declarations. + +Cookie expiry must produce a recoverable startup failure. Restoring the cookie +and choosing Retry must reach Ready with a fresh runtime. A valid cookie with +an invalid capability must still fail at Kandev. A denied capability write must +not mutate data. Verify a direct ordinary API denial under Kandev authentication +using the existing auth test patterns; do not claim canvas grants govern it. + +Keep Cloudflare RUM disabled on runtime responses for the final operator smoke +test. An injected remote script can still fail the CSP and startup monitor. +Preserve that failure rather than suppressing it. + +## ASCII UI preview + +UI-01: Final canvas host and neighboring panel. Maps to +`AC-PLUGINS-ISOLATED-WEB-APPS-013.2/.6`, `AC-UI-PANEL-TOOLBARS-001.1-.6`, +and `AC-CANVASES-AGENT-WEB-APPS-006.10/.11`. + +```text +Desktop: adjacent task panels, below unchanged Dockview tabs ++---------------------------------------+--------------------------+ +| Coordinator [Releases] [Share] [...] | [Diff] [Review] [...] | 30px ++---------------------------------------+--------------------------+ +| Canvas content OR | Changes content | +| Canvas unavailable | | +| [Try again] | | ++---------------------------------------+--------------------------+ + +Phone: focused standalone canvas, one page header ++----------------------------------+ +| Back Coordinator [...]| navigation + >=44px targets ++----------------------------------+ +| Canvas content OR | +| Canvas unavailable | +| [Try again] | ++----------------------------------+ +[...] opens existing inset actions/picker drawer. +``` + +The shared panel toolbar is 30px on fine-pointer desktop and 48px in touch +contexts. Standalone page chrome keeps its own geometry. Ready has no visible +status row; application content and a polite accessible announcement convey it. +A narrow panel uses overflow before actions wrap. Blocking status appears once +in the body; nonblocking connection state may appear inline in the header. +The body owns content scrolling. Preserve safe areas and focus return. + +UI owns reusable toolbar geometry; Canvases owns its single-header state +presentation. Extend existing `panel-primitives.tsx`, with no competing toolbar. +The source audit and exclusions are in the +[toolbar design](../../specs/ui/system-design/panel-toolbars.md). +Task 03 must repeat the audit and test geometry, overflow, and retained actions. + +## Tests + +| Criteria | Evidence | +| --- | --- | +| 001.2/.3, 007.1/.7, 013.1 | `web-app-frame.test.tsx`, `policy_test.go`, `runtime_test.go`; assert exact sandbox tokens and startup credentials | +| 003.1/.2/.5, 007.2/.3, 013.3 | Runtime and protocol tests with valid cookies plus invalid capabilities, denied permissions, and invalid write bodies | +| 012.1-.4, 013.5/.6 | Existing startup/component tests, immutable artifact checks, cookie-expiry and Retry E2E | +| 013.4 | Bundled authoring tests and public-doc validation; remove obsolete isolation claims | +| 013.2/.7 | Cookie-gated browser data, permitted write, real event, and phone tests | + +New test names are defined by the work orders. Existing suites remain regression +evidence; do not relabel old opaque-origin results as proof of the new model. + +## E2E tests + +- `tests/panel-toolbars.spec.ts`: adjacent panels, all migrated header families, + narrow panels, long labels, multiple PRs, editor states, and single canvas header. +- `tests/mobile-panel-toolbars.spec.ts`: phone actions and Retry, responsive + boundary styles, coarse-pointer targets, containment, and drawer focus return. + +- `tests/canvas/canvas-authenticated-proxy.spec.ts`: real host startup, CSS/JS, + task data, allowed write, event delivery, cookie expiry, and Retry. +- `tests/canvas/mobile-canvas-authenticated-proxy.spec.ts`: phone startup, data, + and Retry through the same cookie gate. +- `tests/canvas/canvas-host-origins.spec.ts`: exact aliases and foreign ancestors. +- `tests/canvas/plugin-canvas.spec.ts`: existing direct/task-host lifecycle and Retry. + +The fixture must expose scoped cookie-gate counters or request observations +without recording cookie values or capability URLs. No Cloudflare account is +needed for CI. Record a separate real-installation retest when credentials and +operator configuration are available; local proxy results do not prove it. + +## Related implementation records + +This package supersedes opaque-origin assertions in the completed +[initial runtime work](../plugin-backed-canvases/task-03-isolated-browser-runtime.md) +and the corresponding authoring/documentation work. The +[startup work](../canvas-runtime-permission-fixes/task-03-runtime-startup.md) +keeps its handshake and failure behavior; only the trust and credential +assumptions change. Historical verification results remain historical. + +## Work orders + +- [x] [Task 01: Enable trusted same-origin runtime requests](task-01-runtime.md) +- [x] [Task 02: Verify authenticated proxy behavior and document trust](task-02-proxy-verification.md) + +- [x] [Task 03: Unify panel toolbars and canvas chrome](task-03-panel-toolbars.md) + +Run 01, 02, then 03 sequentially. No subagents are authorized. + +## Verification results + +Design-package checks passed: specification catalog validation, specification +lint, all 36 specification-linter tests, and `git diff --check`. +Implementation checks passed: focused backend package tests, broad frontend +Vitest coverage, frontend typecheck and lint, the internationalization gate, +production web and backend builds, public-document validation, and the final +specification catalog and lint checks. +Browser checks passed: desktop authenticated proxy, exact-origin, canvas +lifecycle, and panel-toolbar coverage with 11 passed tests; mobile authenticated +proxy, canvas lifecycle, and panel-toolbar coverage with 7 passed tests. +The review-remediation rerun added width-aware canvas, Changes, Browser, and +editor action coverage. It passed 14 desktop browser cases and 7 mobile cases, +including the deterministic proxy disconnect/reconnect check, multiple-PR +review overflow, fine-pointer phone containment, and dirty-editor overflow. +The live Cloudflare Access configuration and operator browser retest remain +external deployment work. + +## Risks + +- Same-origin code can act as the viewing user and change host UI or storage. +- Previously published and imported packages gain that authority when opened. +- Auth-disabled installations retain their existing broad backend authority. +- Capability revocation cannot retract parent callbacks or storage mutations. +- A proxy that rewrites public Host can cause Origin checks to reject writes. +- Cloudflare HTML injection and expired login sessions remain separate failure causes. +- Explicit credential omission in authored packages still needs republishing. +- Later restoration of opaque isolation can break code relying on host access. + +Toolbar risks: narrow action groups can overflow, touch targets can be clipped, +and removing the status row can break old selectors or live announcements. +Task 03 covers these regressions without changing runtime authority. diff --git a/docs/plans/canvas-same-origin-auth/task-01-runtime.md b/docs/plans/canvas-same-origin-auth/task-01-runtime.md new file mode 100644 index 0000000000..e3cdeca1b4 --- /dev/null +++ b/docs/plans/canvas-same-origin-auth/task-01-runtime.md @@ -0,0 +1,122 @@ +--- +id: "01-runtime" +title: "Enable trusted same-origin runtime requests" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-PLUGINS-ISOLATED-WEB-APPS-001 + - REQ-PLUGINS-ISOLATED-WEB-APPS-003 + - REQ-PLUGINS-ISOLATED-WEB-APPS-007 + - REQ-PLUGINS-ISOLATED-WEB-APPS-012 + - REQ-PLUGINS-ISOLATED-WEB-APPS-013 +acceptance_criteria: + - AC-PLUGINS-ISOLATED-WEB-APPS-001.2 + - AC-PLUGINS-ISOLATED-WEB-APPS-001.3 + - AC-PLUGINS-ISOLATED-WEB-APPS-003.1 + - AC-PLUGINS-ISOLATED-WEB-APPS-003.2 + - AC-PLUGINS-ISOLATED-WEB-APPS-003.5 + - AC-PLUGINS-ISOLATED-WEB-APPS-007.1 + - AC-PLUGINS-ISOLATED-WEB-APPS-007.2 + - AC-PLUGINS-ISOLATED-WEB-APPS-007.3 + - AC-PLUGINS-ISOLATED-WEB-APPS-007.7 + - AC-PLUGINS-ISOLATED-WEB-APPS-012.1 + - AC-PLUGINS-ISOLATED-WEB-APPS-012.2 + - AC-PLUGINS-ISOLATED-WEB-APPS-012.3 + - AC-PLUGINS-ISOLATED-WEB-APPS-012.4 + - AC-PLUGINS-ISOLATED-WEB-APPS-013.1 + - AC-PLUGINS-ISOLATED-WEB-APPS-013.3 + - AC-PLUGINS-ISOLATED-WEB-APPS-013.5 +system_design: + - ../../specs/plugins/system-design/isolated-web-app-contributions.md +--- + +# Task 01: Enable trusted same-origin runtime requests + +## Summary + +Update both browser sandbox declarations and the host-owned startup request. +Keep runtime capability validation and all ordinary backend checks effective. + +## In scope + +- Use TDD for exact iframe/CSP sandbox token sets and startup credential behavior. +- Add `allow-same-origin` to `WebAppFrame` and `BuildContentSecurityPolicy` together. +- Use `credentials: "same-origin"` in `hostRuntimeBootstrap.checkContext`. +- Replace comments and assertions that describe an opaque browser boundary. +- Test cookies alongside invalid, expired, and stale tokens and denied writes. +- Preserve immutable artifacts, startup messages, CSP sources, and exact ancestors. + +## Out of scope + +- Broad CORS changes, cross-origin cookie forwarding, fetch interception, and a bridge. +- Editing retained canvas packages or adding a mode/feature toggle. +- New UI composition and Cloudflare account changes. + +## Acceptance + +1. Framed and top-level runtime documents retain their served origin; both policy layers agree. +2. Startup uses same-origin cookies; a cookie cannot replace a token or bypass capability grant/input validation. +3. Existing startup, retry, source validation, and artifact immutability tests pass with the revised trust assertions. + +## Verification + +Run focused RED tests before changing production code. Do not weaken unrelated assertions. +Run each command from the repository root: + +```bash +(cd apps/backend && go test ./internal/plugins/webapp ./internal/plugins ./internal/backendapp) +(cd apps/web && pnpm exec vitest run components/plugins/web-app-frame.test.tsx components/plugins/web-app-startup.test.ts components/plugins/canvas-page.test.tsx components/settings/canvas-host-route.test.tsx components/settings/canvas-host-components.test.tsx) +(cd apps/web && pnpm run typecheck) +git diff --check +``` + +Add `TestRuntimeCookieDoesNotReplaceCapability` and a protocol test named +`TestWebAppProtocolCookieDoesNotExpandPermissions`. Extend the existing policy +test to compare the parsed sandbox token set, rather than a substring that +would also pass with unintended permissions. + +## Files likely touched + +- `apps/web/components/plugins/web-app-frame.tsx` and its component test. +- `apps/backend/internal/plugins/webapp/policy.go` and `policy_test.go`. +- `apps/backend/internal/plugins/webapp/runtime_bootstrap.go` and `runtime_test.go`. +- `apps/backend/internal/plugins/webapp_protocol_test.go`. +- `apps/backend/internal/backendapp/middleware_test.go`, if same-origin request coverage is missing. + +## Dependencies + +None. Read scoped backend and web `AGENTS.md` before implementation. + +## Risks + +Same-origin code gains ordinary user-session authority. Capability tests must +not falsely assert that the runtime limits every possible action of that code. + +## Parallelism + +`sequential` + +## Inputs + +- [Runtime design](../../specs/plugins/system-design/isolated-web-app-contributions.md#same-origin-transport-and-trust). +- [Decision](../../decisions/2026-09-19-trusted-same-origin-canvases.md). +- Existing `Runtime.Serve`, `corsMiddleware`, and `handleWebAppProtocol` tests. + +## Results + +Implemented trusted same-origin runtime transport. The iframe and runtime +response CSP both declare `allow-same-origin`, and the host startup bootstrap +uses `credentials: "same-origin"` for its relative context request. Runtime +token validation, scope checks, protocol grants, and input validation remain +unchanged. Added regressions for the exact CSP sandbox token set, startup +credentials, cookie-plus-capability behavior, and cookie-plus-protocol +permissions. + +Checks passed: + +- `go test ./internal/plugins/webapp ./internal/plugins ./internal/backendapp` +- `pnpm exec vitest run components/plugins/web-app-frame.test.tsx components/plugins/web-app-startup.test.ts components/plugins/canvas-page.test.tsx components/settings/canvas-host-route.test.tsx components/settings/canvas-host-components.test.tsx` +- `pnpm run typecheck` +- `git diff --check` diff --git a/docs/plans/canvas-same-origin-auth/task-02-proxy-verification.md b/docs/plans/canvas-same-origin-auth/task-02-proxy-verification.md new file mode 100644 index 0000000000..10d5be3384 --- /dev/null +++ b/docs/plans/canvas-same-origin-auth/task-02-proxy-verification.md @@ -0,0 +1,164 @@ +--- +id: "02-proxy-verification" +title: "Verify authenticated proxy behavior and document trust" +status: done +wave: 2 +depends_on: + - "01-runtime" +plan: "plan.md" +requirements: + - REQ-PLUGINS-ISOLATED-WEB-APPS-013 +acceptance_criteria: + - AC-PLUGINS-ISOLATED-WEB-APPS-013.2 + - AC-PLUGINS-ISOLATED-WEB-APPS-013.3 + - AC-PLUGINS-ISOLATED-WEB-APPS-013.4 + - AC-PLUGINS-ISOLATED-WEB-APPS-013.5 + - AC-PLUGINS-ISOLATED-WEB-APPS-013.6 + - AC-PLUGINS-ISOLATED-WEB-APPS-013.7 +system_design: + - ../../specs/plugins/system-design/isolated-web-app-contributions.md +--- + +# Task 02: Verify authenticated proxy behavior and document trust + +## Summary + +Prove that the real canvas host works through a cookie-authenticated HTTPS proxy. +Update authoring and operator guidance to describe the accepted trust boundary. + +## In scope + +- Reuse the TLS alias fixture pattern with a cookie gate and public Host forwarding. +- Mount the actual canvas host with the real backend; test retained default-fetch source. +- Observe cookie delivery for assets, bootstrap context, data, permitted writes, and SSE. +- Cover missing/expired cookie failure, restoration and Retry, denied writes, and invalid tokens. +- Add phone coverage using the shared proxy fixture and existing focused canvas route. +- Update embedded authoring guidance, public canvas/security/authoring docs, and relevant comments. +- Explain that Cloudflare RUM or other incompatible HTML injection must be excluded on runtime paths. + +## Out of scope + +- Changing user Cloudflare policies or collecting real cookie/token values in logs. +- Suppressing injected-script failures or allowing analytics origins in CSP. +- New dialogs, warning copy, mode controls, or mobile layout changes. + +## Acceptance + +1. Desktop and phone hosts reach Ready and display data through the cookie gate; desktop writes and events retain backend validation. +2. Cookie expiry fails recoverably; after restoring authentication, Retry succeeds without republishing or changing stored release bytes. +3. Authoring and public docs accurately describe same-origin trust, capability limits, proxy requirements, and retained-source compatibility. + +## ASCII UI preview + +UI-01, excerpt from [the plan](plan.md#ascii-ui-preview): + +```text +Desktop: existing canvas tab Phone: existing focused route +[Title] [Releases] [Share] [Back] [Title] [...] +[Ready] [Ready] +[Canvas content] [Canvas content] + +Expired authentication, either viewport: +[Canvas unavailable] [Try again] +``` + +Use the existing scroll owner, safe-area behavior, actions, and translations. +Maps to criteria 013.2 and 013.6. No new rendered composition is required. + +## Verification + +Create a regression test that fails against the previous opaque runtime. +Use a temporary isolated checkout/build if needed; do not revert shared files. +Run desktop and mobile sequentially. The managed runner builds fresh artifacts. + +```bash +(cd apps/web && pnpm e2e:run --project chromium tests/canvas/canvas-authenticated-proxy.spec.ts tests/canvas/canvas-host-origins.spec.ts tests/canvas/plugin-canvas.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/canvas/mobile-canvas-authenticated-proxy.spec.ts) +(cd apps/backend && go test ./internal/mcp/canvasskill ./internal/backendapp) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Name the new browser scenarios `loads a retained canvas through cookie authentication`, +`recovers after proxy authentication expires`, and `preserves capability checks with cookies`. +Do not stub event success: forward the SSE stream and observe a real authorized event. +Verify a rejected ordinary API operation using existing authentication fixtures if +backend validation coverage does not already establish that behavior. + +## Files likely touched + +- `apps/web/e2e/tests/canvas/canvas-origin-fixture.ts` or a narrow shared proxy fixture beside it. +- New `canvas-authenticated-proxy.spec.ts` and `mobile-canvas-authenticated-proxy.spec.ts` in that directory. +- `apps/backend/internal/mcp/canvasskill/files/SKILL.md` and relevant `references/` files. +- `apps/backend/internal/mcp/canvasskill/bundled_test.go`. +- `apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go`. +- `docs/public/canvases.md`, `docs/public/plugins-authoring.md`, and `docs/public/security.md`. +- Existing plan result sections and the two affected system designs when implementation is complete. + +## Dependencies + +Task 01. Public docs must ship with the behavior change, not in a later release. +Search root README and screenshot documentation for the same trust claims; +change them only when they contain an affected claim. + +## Risks + +The current origin fixture drops real SSE coverage. Reusing it unchanged would +produce incomplete evidence. Browser cookie handling must not be bypassed by +Node-side requests that synthesize authentication. + +## Parallelism + +`sequential` + +## Inputs + +- [Runtime design](../../specs/plugins/system-design/isolated-web-app-contributions.md#same-origin-transport-and-trust). +- Existing `canvas-host-origins.spec.ts`, `plugin-canvas.spec.ts`, and phone canvas fixtures. +- `/e2e`, `/mobile-parity`, and `/docs-maintainer` skills. + +## Results + +Implemented the cookie-authenticated HTTPS proxy fixture and desktop/mobile +coverage against the real canvas host. The fixture forwards runtime HTML, +assets, context, task data, writes, and SSE through a Secure, HttpOnly, +SameSite cookie gate while recording only redacted request observations. +Coverage includes expiry and Retry, permitted writes and events, invalid +capabilities, and the mobile retained-canvas flow. + +Updated bundled authoring and security guidance plus the public canvas, +security, plugin, and authoring documentation for trusted same-origin canvases. +The browser coverage exposed an action route parsing defect, so the protocol +handler now validates the `v1/actions/` path before checking its +capability permission. + +Checks passed: + +- `go test ./internal/plugins ./internal/plugins/webapp ./internal/mcp/canvasskill ./internal/backendapp` +- `node --test scripts/validate-public-docs.test.mjs` +- `node scripts/validate-public-docs.mjs` +- Desktop authenticated proxy, origin, and lifecycle browser coverage. +- Mobile authenticated proxy and lifecycle browser coverage. + +Review remediation now tracks every outstanding upstream request in the +authenticated proxy. Incoming aborts and downstream close/error events destroy +the upstream request, upstream response failures close the downstream stream, +and proxy shutdown closes all remaining upstream requests. The desktop proxy +suite includes a deterministic disconnect and reconnect scenario that observed +the active upstream count return to zero before opening the stream again. + +The refreshed authenticated-proxy checks passed: four desktop cases and two +mobile cases, including the disconnect/reconnect and expiry/Retry scenarios. + +Cloudflare Access exclusions and the live operator retest remain deployment +actions outside this repository. + +## Following toolbar integration + +Task 03 replaces the existing visible status strip with body states and an +accessible Ready announcement. Its final verification reruns the proxy tests. +Use startup and rendered-data outcomes rather than requiring a second toolbar. +See [UI-01](plan.md#ascii-ui-preview) for the final composition. diff --git a/docs/plans/canvas-same-origin-auth/task-03-panel-toolbars.md b/docs/plans/canvas-same-origin-auth/task-03-panel-toolbars.md new file mode 100644 index 0000000000..6f9b5fc3cb --- /dev/null +++ b/docs/plans/canvas-same-origin-auth/task-03-panel-toolbars.md @@ -0,0 +1,208 @@ +--- +id: "03-panel-toolbars" +title: "Unify panel toolbars and canvas chrome" +status: done +wave: 3 +depends_on: + - "02-proxy-verification" +plan: "plan.md" +requirements: + - REQ-UI-PANEL-TOOLBARS-001 + - REQ-CANVASES-AGENT-WEB-APPS-006 + - REQ-CANVASES-AGENT-WEB-APPS-007 +acceptance_criteria: + - AC-UI-PANEL-TOOLBARS-001.1 + - AC-UI-PANEL-TOOLBARS-001.2 + - AC-UI-PANEL-TOOLBARS-001.3 + - AC-UI-PANEL-TOOLBARS-001.4 + - AC-UI-PANEL-TOOLBARS-001.5 + - AC-UI-PANEL-TOOLBARS-001.6 + - AC-CANVASES-AGENT-WEB-APPS-006.10 + - AC-CANVASES-AGENT-WEB-APPS-006.11 + - AC-CANVASES-AGENT-WEB-APPS-007.5 +system_design: + - ../../specs/ui/system-design/panel-toolbars.md + - ../../specs/canvases/system-design/canvas-host-chrome.md +--- + +# Task 03: Unify panel toolbars and canvas chrome + +## Summary + +Extend the existing panel toolbar and migrate inconsistent primary headers. +Render one canvas header and retain state, actions, and recovery in their +appropriate locations. Finish the migration with real browser geometry checks. + +## In scope + +- Shared 30px desktop and 48px touch panel header shell; retain footer geometry. +- Audit every existing header caller and padding-sized primary task-panel header. +- Migrate all primary toolbar rows in the design inventory; document exceptions. +- Reconcile oversized editor controls, review wrapping, narrow browser fields, + terminal header sizing, and overflow in other existing shell consumers. +- Add explicit embedded presentation to CanvasHostRoute and its Dockview caller. +- Remove the separate status toolbar; preserve one standalone page header. +- Move phone actions into that header and retain the existing inset drawer. +- Keep blocking status and Retry in the body; preserve polite announcements. +- Update existing component and browser selectors for the new state presentation. +- Update applicable public canvas guidance only if its descriptions/screenshots + show the removed row; preserve translations across all five locales for new copy. + +## Out of scope + +Global page-header resizing, Dockview tabs, footers, chat composers, nested +per-file diff headers, third-party app chrome, runtime authorization changes, +and new telemetry or density preferences. + +## Acceptance + +1. All primary panel toolbars in the inventory use the shared geometry and retain + accessible actions at narrow widths. Browser evidence covers each migrated family. +2. Embedded and standalone canvas views have one host header, no status-only row, + and functioning actions, loading, Ready announcement, unavailable state and Retry. +3. Phone and coarse-pointer controls fit their rows with at least 44px targets; + resize and status changes preserve runtime/editor state and focus. + +## ASCII UI preview + +UI-01 excerpt from [the plan](plan.md#ascii-ui-preview), covering all listed ACs. + +```text +Desktop below Dockview tabs: +| Coordinator [Releases] [...] | [Diff] [Review] [...] | 30px +| Canvas content or error | Changes content | + +Phone standalone: +| Back Coordinator [...] | one navigation header +| Canvas content or error | +| [Try again] when unavailable | +[...] -> existing inset actions/picker drawer +``` + +Panel toolbars in touch layouts use 48px; page navigation retains its separate +contract. Title truncation and action overflow must not create a second row. +Content owns scrolling; controls remain outside it. Ready has no visible strip. + +## Tests + +- Extend `canvas-host-components.test.tsx` and `canvas-host-route.test.tsx` for + one header, single blocking-state message, action reachability, and announcements. +- Add `components/task/panel-primitives.test.tsx` for prop/ref forwarding and + retained child interaction; do not use class assertions as geometry evidence. +- Extend affected existing editor, plan, files, changes and preview component tests + for action preservation when secondary controls move into overflow. +- Add `e2e/tests/panel-toolbars.spec.ts`: measure border-box heights and adjacent + top/bottom edges within 1px, at standard root font. Exercise every migrated + family, narrow Dockview widths, long labels, multiple PRs, dirty editor state, + disabled/busy actions, keyboard overflow access, and canvas Retry. +- Add `e2e/tests/mobile-panel-toolbars.spec.ts`: 390px, 767px, 768px and 900px + coarse-pointer layouts. Assert computed styles, actual hitboxes, row containment, + no document horizontal overflow, drawer action activation and focus return. + Use a fine-pointer desktop context separately to prove the 768px boundary. +- Run the existing canvas suites alongside new tests. Replace every assertion + targeting the removed visible status row; do not weaken startup assertions. + +## Verification + +Install workspace dependencies if missing. Rebuild the application before E2E. +The broader component directory command covers all migrated caller tests. + +```bash +(cd apps/web && pnpm exec vitest run components/task components/editors components/settings/canvas-host-components.test.tsx components/settings/canvas-host-route.test.tsx) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run lint) +(cd apps/web && pnpm run i18n:check) +make build-web +make build-backend +(cd apps/web && pnpm e2e:run --project chromium tests/panel-toolbars.spec.ts tests/canvas/plugin-canvas.spec.ts tests/canvas/canvas-authenticated-proxy.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/mobile-panel-toolbars.spec.ts tests/canvas/mobile-plugin-canvas.spec.ts tests/canvas/mobile-canvas-authenticated-proxy.spec.ts) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Files likely touched + +- `apps/web/components/task/panel-primitives.tsx` and its new tests. +- All primary toolbar callers listed in the design's migration inventory. +- `apps/web/components/task/dockview-panel-content.tsx`. +- `apps/web/components/settings/canvas-host-route.tsx` and its tests. +- `apps/web/components/settings/canvas-host-frame.tsx`. +- `apps/web/components/settings/canvas-host-components.tsx` and its tests. +- `apps/web/e2e/tests/panel-toolbars.spec.ts` and `mobile-panel-toolbars.spec.ts`. +- Existing canvas E2E files with obsolete status selectors. +- Locale catalogs only when copy or accessible names change. + +## Dependencies + +Task 02 supplies the final authenticated startup behavior and browser fixture. +The shared geometry itself has no runtime-auth dependency; sequential delivery +avoids duplicating final canvas state assertions during integration. + +## Risks + +CSS overrides can defeat fixed sizes. Narrow panels need width-aware overflow; +a phone breakpoint alone is insufficient. Live-region changes must not produce +duplicate announcements. Header updates must not remount the canvas or editor. + +## Parallelism + +`sequential` + +## Inputs + +- [Toolbar design and source audit](../../specs/ui/system-design/panel-toolbars.md). +- [Canvas host design](../../specs/canvases/system-design/canvas-host-chrome.md#single-canvas-host-header). +- Existing `PanelHeaderBarSplit`, `MobileCanvasActions`, and mobile file viewer. + +## Results + +Implemented shared panel header primitives with 30px fine-pointer desktop and +48px coarse-pointer touch geometry, forwarded attributes and refs, width-aware +overflow, and pointer-sized caller controls. Migrated the primary Files, +Changes, Browser, terminal, file viewer, preview, and editor toolbar callers. + +CanvasHostRoute now has explicit embedded and standalone compositions. Each +canvas has one host header, blocking states render in the body, and Ready is +announced through the existing accessible status channel. Mobile actions remain +in the existing inset drawer and the canvas body retains its scroll ownership. + +Added desktop geometry coverage at standard and 768px fine-pointer widths and +mobile coverage at 390px, 767px, and 900px coarse-pointer widths. The tests +measure header height, adjacent content edges, hitboxes, focus/navigation, and +document overflow. + +Checks passed: + +- `pnpm exec vitest run components/task components/editors components/settings/canvas-host-components.test.tsx components/settings/canvas-host-route.test.tsx` (493 files, 4645 tests, 4 skipped) +- `pnpm run typecheck` +- `pnpm run lint` +- `pnpm run i18n:check` +- `make build-web` +- `make build-backend` +- Desktop browser coverage: 11 passed. +- Mobile browser coverage: 7 passed. +- `python3 scripts/list-docs.py validate` +- `python3 scripts/lint-spec-files.py --all` +- `git diff --check` + +Review remediation added phone-width media sizing for fine-pointer layouts, +actual control-containment assertions at 390px and 767px, and width-aware +overflow menus for canvas, Changes, Browser, and both editor toolbars. The +multiple-PR Changes header keeps its selector as the primary left action while +moving secondary actions into the menu. Dirty editor state keeps Save visible, +and the editor overflow test opens the menu and activates a secondary action. +The canvas lifecycle suite also verifies the neighboring narrow Dockview +header and its action menu. + +The refreshed checks passed: + +- Focused frontend tests: 17 files, 62 tests; the Monaco toolbar test now has 6 passing cases. +- Desktop panel-toolbar browser coverage: 5 passed. +- Mobile panel-toolbar browser coverage: 2 passed. +- Desktop canvas and authenticated-proxy browser coverage: 9 passed. +- Mobile canvas and authenticated-proxy browser coverage: 5 passed. +- `make build-web` and `make build-backend` +- `go test ./internal/plugins ./internal/plugins/webapp ./internal/mcp/canvasskill ./internal/backendapp` +- `pnpm run typecheck`, `pnpm run i18n:check`, and targeted ESLint +- Public-document validation, specification catalog validation, specification lint, and `git diff --check` diff --git a/docs/plans/plugin-backed-canvases/plan.md b/docs/plans/plugin-backed-canvases/plan.md index d49f5bee7f..bf026ad22a 100644 --- a/docs/plans/plugin-backed-canvases/plan.md +++ b/docs/plans/plugin-backed-canvases/plan.md @@ -333,3 +333,10 @@ not validation of the repair. - Office skill synchronization can absorb the canvas skill if embeds overlap. - Permission changes can activate broader access before user confirmation. - Arbitrary application CSS can still produce a poor phone layout. + +## Same-origin follow-up + +The [trusted same-origin package](../canvas-same-origin-auth/plan.md) supersedes +this package's opaque-origin and cookie-isolation assumptions. Existing results +record the earlier implementation. They do not verify the new runtime behavior. +The follow-up work orders own the replacement tests and documentation. diff --git a/docs/public/canvases.md b/docs/public/canvases.md index 6d6d36b415..8d979d00d7 100644 --- a/docs/public/canvases.md +++ b/docs/public/canvases.md @@ -59,12 +59,16 @@ The app uses relative requests such as `./_kandev/v1/data/tasks` and `./_kandev/ A task read includes a read-only summary of that task's dependencies: whether it is blocked, and which tasks it depends on or blocks. Events never carry this summary, so a canvas that displays it refetches the task rather than reading the summary out of the event stream: on a `task.updated`, `task.dependencies_resolved`, or `task.dependency_failed` event for that task or one of its edges, or on a `task.state_changed` event for any task in its cached dependency lists (a predecessor or dependent simply advancing state is not one of the first three events). Kandev refuses an oversized read rather than truncating it silently; the authoring reference has the exact fields and limits. -The host shows canvas controls outside the app frame. The app runs in a sandboxed iframe with an opaque browser origin: +The host shows canvas controls outside the app frame. The app runs in a +sandboxed same-origin iframe. Canvas source is trusted with the viewing user's +ordinary user-session authority: - The frame allows packaged scripts and forms. -- The app cannot use the host DOM, cookies, host authentication headers, popups, or top-level navigation. -- `localStorage`, `sessionStorage`, IndexedDB, and service workers are not available. +- The app can use same-origin browser storage and cookies and can access the host DOM. +- The app still cannot open popups or navigate the top-level page. - Use Kandev instance state for small shared values. Keep temporary values in memory. +- Relative Kandev protocol routes still require the capability URL, release binding, + scope checks, and declared grants. A browser cookie does not replace those checks. - External network access uses exact HTTPS origins that a user approved. - Remote scripts are not allowed. Scripts and styles must come from the package. @@ -222,6 +226,6 @@ Direct file and direct-link sharing does not require registry admission. - [Plugin manifest reference](plugins-manifest.md#isolated-web-applications) defines the `ui.web_apps` manifest fields. - [Authoring a plugin](plugins-authoring.md#build-an-isolated-web-application) explains package authoring without an injected JavaScript API. - [Configuration](configuration.md#runtime-feature-toggles) explains the feature flag and restart rule. -- [Security and trust](security.md#isolated-web-applications) explains sandboxing, capabilities, network access, and opaque storage. +- [Security and trust](security.md#isolated-web-applications) explains sandboxing, same-origin trust, capabilities, and network access. - [Operations](operations.md#canvas-artifacts-and-recovery) explains the database and artifact backup boundary. - [Feature status](feature-status.md) records the public support status. diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index 093a927331..79d27dba0c 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -150,9 +150,13 @@ the canvas recoverable after 15 seconds. Keep the entry document and its relative assets valid HTML, and make the app render its own loading and error states after startup. -The frame has an opaque browser origin. Do not use `localStorage`, -`sessionStorage`, IndexedDB, or service workers. Use the state protocol for -small app-specific shared values and JavaScript memory for temporary values. +The frame is same-origin with Kandev and canvas code is trusted with the +viewing user's ordinary user-session authority. It can use `localStorage`, +`sessionStorage`, IndexedDB, same-origin cookies, and the host DOM. Keep +Kandev protocol requests relative and do not copy capability URLs or tokens. +Use the state protocol for small app-specific shared values and JavaScript +memory for temporary values. Capability URLs, release bindings, scope checks, +and declared grants still govern every Kandev protocol operation. Network access uses exact HTTPS origins that a user approves. Wildcards, origin paths, query strings, credentials, and remote scripts are not allowed. @@ -275,10 +279,12 @@ curated React, UI, and app-store surface. Host adapters consume the approval receipt/query surface; they do not derive authority from plugin IDs, package digests, or workspace state. -An isolated web app has a separate browser boundary. Kandev loads it in a -sandboxed iframe with an opaque origin. It cannot use the host DOM, cookies, -host authentication headers, popups, top-level navigation, or a global Kandev -JavaScript API. See [Security and trust](security.md#isolated-web-applications) +An isolated web app has a separate iframe and sandbox boundary. Kandev loads it +same-origin with the host, so its trusted source can use the viewing user's +ordinary browser authority, including cookies, storage, and the host DOM. It +still cannot open popups or navigate the top-level page, and it receives no +global Kandev JavaScript API. Capability URLs and grants remain required for +Kandev protocol operations. See [Security and trust](security.md#isolated-web-applications) for the runtime boundary. ## Storage decision table @@ -288,7 +294,7 @@ for the runtime boundary. | Small JSON object owned by this plugin | Host state: GetState, SetState, DeleteState, ListState | instance, workspace, task, or agent; survives restart/upgrade and is included in Kandev state backups | capabilities.state: true; values are JSON objects, not bare scalars | | Canvas app shared state | Relative `./_kandev/v1/state` protocol | Canvas instance; survives restart while the instance remains | `state` grant, store app-specific shared values, not duplicate task data | | Per-user browser/plugin storage | host.storage: get/set/delete/list/subscribe | instance, workspace, task, session, or repository, scoped per user | capabilities.user_state: true; set/delete accept ifUnmodifiedSince and writerId | -| Temporary canvas app value | JavaScript memory inside the iframe | Current document only | Opaque origin blocks browser storage and service workers | +| Temporary canvas app value | JavaScript memory inside the iframe | Current document only | Use browser storage for user-scoped client data; keep shared app values in canvas state | | Operator configuration | Host.GetConfig and manifest config_schema | Plugin-owned settings; config changes restart an active subprocess | Ungated GetConfig; secret fields arrive cleartext in the subprocess | | Plugin-owned credentials | Host.GetSecret/SetSecret/DeleteSecret, or secret: true config fields | Encrypted Kandev vault, namespaced to this plugin | capabilities.secrets: true; never log values | | Files, caches, or plugin-managed database | KANDEV_PLUGIN_DATA_DIR | Shared across versions, removed on uninstall | Write only below the injected directory; own schema, locking, and migrations | diff --git a/docs/public/plugins.md b/docs/public/plugins.md index baf7e83e29..28f8c90c9d 100644 --- a/docs/public/plugins.md +++ b/docs/public/plugins.md @@ -323,11 +323,12 @@ disk on its first restart after upgrading to this version. a failing bundle or `initialize` is caught and never breaks boot; slot components render behind error boundaries. Hard sandboxing (a worker or realm boundary) is explicit future work: see below. -- **Isolated web apps use a separate browser boundary.** Kandev serves their - packaged files in a sandboxed iframe with an opaque origin. The app receives - only reviewed Kandev capabilities and exact HTTPS network origins. It cannot - use the host DOM, cookies, host authentication headers, or an injected - JavaScript API. See [Security and trust](security.md#isolated-web-applications). +- **Isolated web apps use a sandboxed iframe boundary.** Kandev serves their + packaged files same-origin with the host and trusts the source with the + viewing user's ordinary browser authority, including cookies, storage, and + host DOM access. Kandev protocol routes still expose only the reviewed + capabilities and exact HTTPS network origins declared for the canvas. There + is no injected JavaScript API. See [Security and trust](security.md#isolated-web-applications). - **Package integrity is always checked; signing is optional.** See "Signed vs. unsigned packages" above. - **Curated marketplace, no auto-install.** The [Plugin diff --git a/docs/public/security.md b/docs/public/security.md index 0eaabfdf9f..79b93c7a08 100644 --- a/docs/public/security.md +++ b/docs/public/security.md @@ -192,12 +192,15 @@ same-origin frontend code and belong to the privileged plugin boundary. The isolated web-app boundary has these rules: -- The iframe allows packaged scripts and forms. It does not allow same-origin - access, top-level navigation, or popups. -- The browser gives the app an opaque origin. The app cannot use Kandev cookies, - host authentication headers, the host DOM, or an injected JavaScript API. -- The app uses relative `./_kandev/v1` protocol paths. The app receives only - the Kandev data, events, state, and actions that the host grants. +- The iframe allows packaged scripts and forms, but it does not allow top-level + navigation or popups. +- The iframe is same-origin with Kandev. Canvas source is trusted with the + viewing user's ordinary user-session authority, including same-origin + cookies, browser storage, and host DOM access. +- The app uses relative `./_kandev/v1` protocol paths. For these protocol + requests, the app receives only the Kandev data, events, state, and actions + that the capability binding grants. + Cookies do not replace the capability URL or per-operation permission checks. - Kandev calculates effective access from the package declaration, instance grant, trusted task or workspace scope, and current caller authorization. - A new owner-created task canvas can use a recorded, single-use creation @@ -210,6 +213,10 @@ The isolated web-app boundary has these rules: accepted. - Forms cannot submit to an external origin. The runtime policy sets `form-action 'none'`. +- A reverse proxy must preserve same-origin cookies for runtime requests and + must not redirect runtime HTML or inject remote scripts into it. Cloudflare + Web Analytics and similar HTML injection must be excluded on the runtime + path because the runtime CSP allows only packaged scripts. - Kandev applies a response Content Security Policy to the entry and asset routes. The policy allows the response's exact same origin to frame the runtime. This supports a custom Kandev DNS name, IP address, port, or HTTPS @@ -235,11 +242,11 @@ matching iframe. It loads a replacement only after a fresh metadata and runtime-binding check. Kandev runtime and protocol requests continue to revalidate on every request. -Opaque origin storage is not a durable app store. `localStorage`, -`sessionStorage`, IndexedDB, and service workers are unavailable. Use the -canvas state protocol for small app-specific shared values and memory for -temporary values. Do not copy task or workflow data into app state as a second -source of truth. +Browser storage is available to trusted same-origin canvas code according to +normal browser and site storage policy. Treat it as user-session storage, not +as the durable app store. Use the canvas state protocol for small app-specific +shared values and memory for temporary values. Do not copy task or workflow +data into app state as a second source of truth. The host renders canvas controls outside the iframe. Keep the backend and its HTTP, WebSocket, and MCP routes behind the deployment boundary described at diff --git a/docs/specs/canvases/README.md b/docs/specs/canvases/README.md index bb118ea3a0..58d718312c 100644 --- a/docs/specs/canvases/README.md +++ b/docs/specs/canvases/README.md @@ -13,7 +13,7 @@ Canvases own agent-authored task applications and their promotion to workspace applications. The system owns canvas scope, source lineage, release selection, editing sessions, discovery, and portable canvas distribution. -The Plugins system owns the isolated web-application runtime, permissions, +The Plugins system owns the web-application runtime, permissions, Kandev data access, state, events, and package validation. The task system remains authoritative for task data and permissions. diff --git a/docs/specs/canvases/requirements/agent-authored-web-apps.md b/docs/specs/canvases/requirements/agent-authored-web-apps.md index 5acc3d08aa..505f0e0f8e 100644 --- a/docs/specs/canvases/requirements/agent-authored-web-apps.md +++ b/docs/specs/canvases/requirements/agent-authored-web-apps.md @@ -6,7 +6,7 @@ system: canvases owners: - canvases created: 2026-08-26 -last_updated: 2026-09-10 +last_updated: 2026-09-19 --- # Agent-authored web-app canvases Requirements @@ -18,8 +18,8 @@ user can promote a useful task canvas to its workspace. A workspace canvas appears in workspace navigation and can use workspace-scoped data. The Canvases system owns the canvas scope, source lineage, release selection, -promotion, editing flow, and discovery. The Plugins system owns the isolated -web-application runtime and its data contract. +promotion, editing flow, and discovery. The Plugins system owns the web-application +runtime and its data contract. ## Terminology @@ -209,6 +209,16 @@ canvas through native Kandev navigation. both permissions and its actions without scrolling at 1280 by 720 CSS pixels. Longer reviews shall keep actions reachable at 390 by 844 CSS pixels. +- **AC-CANVASES-AGENT-WEB-APPS-006.10:** An embedded canvas shall have one + host action toolbar aligned with other task panels under the shared + [panel toolbar contract](../../ui/requirements/panel-toolbars.md). + A standalone canvas shall retain one page navigation/action header. +- **AC-CANVASES-AGENT-WEB-APPS-006.11:** Neither presentation shall add a + separate status-only toolbar. Loading and blocking errors shall appear once + in the body, with recovery actions. Ready shall show the application and an + accessible status announcement. Nonblocking offline state may appear inline + in the existing header while retaining the application. + ### REQ-CANVASES-AGENT-WEB-APPS-007: Visible runtime and release state **Intent:** A user can understand whether the canvas is loading, offline, diff --git a/docs/specs/canvases/requirements/marketplace-sharing.md b/docs/specs/canvases/requirements/marketplace-sharing.md index dd3be4fe07..eb27ae6f38 100644 --- a/docs/specs/canvases/requirements/marketplace-sharing.md +++ b/docs/specs/canvases/requirements/marketplace-sharing.md @@ -13,7 +13,7 @@ owners: Users distribute a reusable canvas, discover it in Settings > Plugins, and install a private instance in their workspace. Canvases owns this vertical contract because it owns canvas identity, source lineage, scope, and discovery. -Plugins supplies the existing catalog, manifest, validation, and isolated runtime. +Plugins supplies the existing catalog, manifest, validation, and web-application runtime. This is the first distribution version. Sharing uses downloads and instructions; Kandev does not create repositories or publish external releases for the user. @@ -159,7 +159,7 @@ task, executor, or workspace. ### REQ-CANVASES-MARKETPLACE-006: Accessible delivery and compatibility **Intent:** Desktop and phone users can complete the same sharing and discovery -flows without weakening existing canvas isolation or feature controls. +flows under the current plugin runtime trust contract and feature controls. #### Acceptance criteria diff --git a/docs/specs/canvases/system-design/agent-authored-web-apps.md b/docs/specs/canvases/system-design/agent-authored-web-apps.md index 2fee3b09b0..b40046d3ea 100644 --- a/docs/specs/canvases/system-design/agent-authored-web-apps.md +++ b/docs/specs/canvases/system-design/agent-authored-web-apps.md @@ -6,7 +6,7 @@ system: canvases owners: - canvases created: 2026-08-26 -last_updated: 2026-09-10 +last_updated: 2026-09-19 requirements: - REQ-CANVASES-AGENT-WEB-APPS-001 - REQ-CANVASES-AGENT-WEB-APPS-002 @@ -28,9 +28,9 @@ application into a workspace application. It owns task creation context, promotion, release selection, editing sessions, discovery, and canvas host surfaces. -The Plugins system owns package validation, iframe isolation, data access, +The Plugins system owns package validation, browser trust, data access, state, events, grants, and runtime tokens. This design uses -[the isolated plugin web-application contract](../../plugins/system-design/isolated-web-app-contributions.md). +[the plugin web-application contract](../../plugins/system-design/isolated-web-app-contributions.md). See [declarative canvas](collaborative-canvases.md) and [marketplace sharing](marketplace-sharing.md). @@ -297,8 +297,8 @@ The canvas-authoring skill tells the agent to: - use relative `./_kandev/v1` data, state, action, and event paths - keep Kandev domain data as the source of truth - store only application-specific shared state in instance state -- use memory for temporary values because opaque-origin browser storage is not - available +- use memory for temporary values; use instance state for shared values and + do not depend on host browser-storage keys - publish after local build checks - read validation diagnostics and correct rejected releases - use semantic appearance variables and apply live host appearance messages @@ -711,3 +711,8 @@ tokens. - [Plugin agent tools through Kandev MCP](../../../decisions/2026-08-11-plugin-tools-through-kandev-mcp.md) - [Host utility agentctl for sessionless flows](../../../decisions/0002-host-utility-agentctl-for-sessionless-flows.md) - [Superseded declarative canvases](../../../decisions/2026-08-25-server-owned-declarative-canvases.md) + +## Canvas host chrome + +The [single-header design](canvas-host-chrome.md) defines embedded and standalone +chrome, status presentation, and shared panel toolbar integration. diff --git a/docs/specs/canvases/system-design/canvas-host-chrome.md b/docs/specs/canvases/system-design/canvas-host-chrome.md new file mode 100644 index 0000000000..447180adf4 --- /dev/null +++ b/docs/specs/canvases/system-design/canvas-host-chrome.md @@ -0,0 +1,58 @@ +--- +status: draft +system: canvases +requirements: + - REQ-CANVASES-AGENT-WEB-APPS-006 + - REQ-CANVASES-AGENT-WEB-APPS-007 +--- + +# Canvas host chrome design + +## Purpose and boundaries + +Canvases owns its host presentation and recovery. UI owns reusable toolbar +geometry. The runtime lifecycle and authentication remain unchanged. + +## Requirement mapping + +| Criteria | Design section | +| --- | --- | +| 006.10, 006.11 | Single canvas host header | +| 006.1-.6, 007.1-.3, 007.5-.6 | Responsive composition and state preservation below | + +## Single canvas host header + +For AC-CANVASES-AGENT-WEB-APPS-006.10/.11, give `CanvasHostRoute` an explicit +embedded presentation prop from `CanvasContent` in `dockview-panel-content.tsx`. +The direct caller in `apps/web/src/canvas-route.tsx` retains standalone behavior. +Embedded mode uses the existing shared panel header; standalone mode retains +`PageShell` navigation. Do not shrink PageTopbar globally to fix a panel. + +Remove the separate `CanvasHostHeader` status row from `CanvasHostBody`. Move +its phone action trigger into the one remaining header, keeping the existing +`MobileCanvasActions` drawer and picker reachable. Preserve the title, release, +share, promotion and edit callbacks. Use compact/overflow presentation for narrow +embedded panels, with the shared [toolbar design](../../ui/system-design/panel-toolbars.md). + +Keep loading and blocking-state text in the body exactly once. Ready renders +application content and a visually hidden polite announcement. A nonblocking +connection warning can use an inline header status without adding another row. +Do not remove runtime state transitions, the startup handshake, timeout, Retry, +or release actions. Update tests that locate the removed `canvas-host-state` +row to assert the surviving state presentation and accessible announcement. + +The full-height phone route keeps a single content scroll owner and the inset +actions drawer. Derive remaining viewport space from the actual page header; +remove assumptions that subtract a second header. Preserve safe-area handling +and at least 44px action targets. Header restructuring must not remount the +runtime when actions, status, or viewport width change. + +Delivery and browser coverage are in +[work order 03](../../../plans/canvas-same-origin-auth/task-03-panel-toolbars.md). + +## Verification + +Work order 03 defines component and browser evidence for desktop Dockview, +standalone navigation, phone actions, state announcements, and Retry. Runtime +startup and authentication checks remain in work orders 01 and 02. +No backend, persistence, permission, or observability changes are introduced. diff --git a/docs/specs/canvases/system-design/marketplace-sharing.md b/docs/specs/canvases/system-design/marketplace-sharing.md index fb5939fadb..b1debea73f 100644 --- a/docs/specs/canvases/system-design/marketplace-sharing.md +++ b/docs/specs/canvases/system-design/marketplace-sharing.md @@ -28,7 +28,7 @@ this additive distribution profile fits this design; a separate ADR is not needed. The [existing lifecycle](agent-authored-web-apps.md) still owns authoring, promotion, editing, rollback, and deletion. The [plugin web-app contract](../../plugins/system-design/isolated-web-app-contributions.md) -owns runtime isolation. This document adds portable distribution to both. +owns the runtime trust boundary. This document adds portable distribution to both. ## Requirement mapping @@ -43,7 +43,7 @@ owns runtime isolation. This document adds portable distribution to both. ## Existing implementation and additive changes -- `internal/plugins/manifest.Manifest` already supports isolated `ui.web_apps`. +- `internal/plugins/manifest.Manifest` already supports packaged `ui.web_apps`. `IsStaticWebAppOnly` identifies the static runtime form, but the distribution validator must also reject every non-canvas contribution and managed field. - `internal/plugins/pkgtar.validateInstallManifest` requires a managed binary. @@ -477,7 +477,7 @@ and URL queries. Use content-free diagnostics; add no usage telemetry service. The [implementation plan](../../../plans/canvas-marketplace/plan.md) maps each criterion to proposed unit, integration, registry, and desktop/mobile tests. Tests must prove export-to-import round trips, project-source recovery after -executor cleanup, digest-bound review, concurrent retry receipts, isolation, +executor cleanup, digest-bound review, concurrent retry receipts, runtime policies, registry URL validation, screenshot-free export/import, plugin galleries, gate-off behavior, and native installer compatibility. diff --git a/docs/specs/plugins/README.md b/docs/specs/plugins/README.md index c6a8394f7d..c639f8a0c2 100644 --- a/docs/specs/plugins/README.md +++ b/docs/specs/plugins/README.md @@ -36,9 +36,9 @@ canonical requirement and system-design documents. Use the catalog command to fi ## Related systems - [UI](../ui/README.md): renders plugin contributions. -- [Canvases](../canvases/README.md): binds isolated web applications to task +- [Canvases](../canvases/README.md): binds plugin web applications to task and workspace canvas lifecycles. - [Canvas distribution](../canvases/system-design/marketplace-sharing.md): owns canvas export/import and the canvas-specific marketplace flow; reuses plugin - manifests, static validation, catalog sources, and runtime isolation. + manifests, static validation, catalog sources, and the runtime trust contract. - [Integrations](../integrations/README.md): supplies external connections. diff --git a/docs/specs/plugins/requirements/isolated-web-app-contributions.md b/docs/specs/plugins/requirements/isolated-web-app-contributions.md index 843d1c8eea..3597d2ff2b 100644 --- a/docs/specs/plugins/requirements/isolated-web-app-contributions.md +++ b/docs/specs/plugins/requirements/isolated-web-app-contributions.md @@ -1,25 +1,29 @@ --- id: plugins-isolated-web-app-contributions -title: Isolated plugin web-application contributions +title: Plugin web-application contributions status: draft system: plugins owners: - kandev created: 2026-08-26 -last_updated: 2026-09-10 +last_updated: 2026-09-19 --- -# Isolated plugin web-application contributions Requirements +# Plugin web-application contributions Requirements ## Overview -Plugins need a web-application contribution for untrusted HTML, CSS, and -JavaScript. The contribution must not share the trust boundary of a native -frontend bundle. Canvases use this contribution first, and later plugin -authoring can use the same runtime. +Plugins provide packaged HTML, CSS, and JavaScript in a separate document. +The selected same-origin mode treats this code as trusted by the viewing user. +It does not provide browser isolation from the Kandev host. + +The [same-origin decision](../../../decisions/2026-09-19-trusted-same-origin-canvases.md) +amends the original isolation contract. The runtime and proxy-verification +implementation is complete under the [delivery plan](../../../plans/canvas-same-origin-auth/plan.md). +The existing document path and requirement IDs remain stable. The Plugins system owns package validation, releases, instances, grants, -runtime tokens, data access, state access, event delivery, and isolation. +runtime tokens, data access, state access, event delivery, and browser trust. ## Terminology @@ -29,15 +33,15 @@ runtime tokens, data access, state access, event delivery, and isolation. - **Grant:** A subset of declared permissions approved directly by the user or through [owner-authorized canvas creation](../../canvases/requirements/local-creation-authority.md). - **Runtime token:** A short-lived capability that authorizes one iframe - instance without a Kandev session cookie. + instance independently of ambient Kandev session cookies. - **Native bundle:** A trusted React module that runs inside the Kandev SPA. ## Requirements ### REQ-PLUGINS-ISOLATED-WEB-APPS-001: Isolated web-application contribution -**Intent:** A plugin can provide an arbitrary web interface without executing -its code in the Kandev frontend process. +**Intent:** A plugin can provide an arbitrary web interface in a separate document +without importing its code as a native frontend bundle. #### Acceptance criteria @@ -46,8 +50,10 @@ its code in the Kandev frontend process. supported placements. - **AC-PLUGINS-ISOLATED-WEB-APPS-001.2:** Kandev shall render each web application in a sandboxed iframe and shall not import it as an ES module. -- **AC-PLUGINS-ISOLATED-WEB-APPS-001.3:** The iframe shall not receive the host - React instance, host DOM, Zustand store, native plugin API, or session cookie. +- **AC-PLUGINS-ISOLATED-WEB-APPS-001.3:** The host shall not inject its React + instance, Zustand store, native plugin API, or credential values into the iframe. + Same-origin browser access follows `REQ-PLUGINS-ISOLATED-WEB-APPS-013`; this + criterion does not promise host-DOM or session isolation. - **AC-PLUGINS-ISOLATED-WEB-APPS-001.4:** The contribution shall support packaged HTML, CSS, JavaScript, images, and fonts within documented limits. - **AC-PLUGINS-ISOLATED-WEB-APPS-001.5:** Existing native plugin bundles shall @@ -77,16 +83,17 @@ its code in the Kandev frontend process. ### REQ-PLUGINS-ISOLATED-WEB-APPS-003: Effective capability grants -**Intent:** A plugin instance receives no authority beyond its declaration, -user grant, and current resource access. +**Intent:** Requests through the capability protocol receive only the declared, +granted, and authorized scope. This limit does not constrain direct requests +that trusted same-origin code makes through the ordinary user session. #### Acceptance criteria - **AC-PLUGINS-ISOLATED-WEB-APPS-003.1:** The effective permission set shall be the intersection of package declarations, instance grants, and current Kandev authorization. -- **AC-PLUGINS-ISOLATED-WEB-APPS-003.2:** Each Kandev data, state, event, and - action request shall revalidate the active instance and effective +- **AC-PLUGINS-ISOLATED-WEB-APPS-003.2:** Each capability-protocol data, state, + event, and action request shall revalidate the active instance and effective permission set. A direct browser request to an approved external origin cannot be intercepted by Kandev after it leaves the browser. The host shall therefore tear down the iframe immediately after every authority-changing @@ -143,8 +150,8 @@ state without browser-local ownership. current revision and shall not overwrite the current value. - **AC-PLUGINS-ISOLATED-WEB-APPS-005.4:** Authorized agent tools shall read and change the same plugin-instance state through the backend contract. -- **AC-PLUGINS-ISOLATED-WEB-APPS-005.5:** A plugin instance shall not read or - change another instance's state. +- **AC-PLUGINS-ISOLATED-WEB-APPS-005.5:** The capability state API shall not let + one plugin instance read or change another instance's state. ### REQ-PLUGINS-ISOLATED-WEB-APPS-006: Live events and recovery @@ -170,17 +177,20 @@ or automations change Kandev data. ### REQ-PLUGINS-ISOLATED-WEB-APPS-007: Browser security boundary -**Intent:** Arbitrary application code cannot inherit Kandev authority or -escape its approved network and presentation boundary. +**Intent:** The runtime keeps explicit content and framing policies while +allowing the trusted same-origin behavior in requirement 013. These policies +are not containment against code that can access the host document. #### Acceptance criteria -- **AC-PLUGINS-ISOLATED-WEB-APPS-007.1:** The iframe shall use an opaque origin - and shall not receive same-origin, top-navigation, popup, or host-DOM access. +- **AC-PLUGINS-ISOLATED-WEB-APPS-007.1:** The iframe shall retain its served + origin. Its declared sandbox shall permit scripts, forms, and same-origin + behavior, without adding top-navigation or popup permissions. - **AC-PLUGINS-ISOLATED-WEB-APPS-007.2:** Runtime requests shall use a short-lived user-bound, instance-bound, release-bound, and scope-bound token. -- **AC-PLUGINS-ISOLATED-WEB-APPS-007.3:** Kandev shall not use ambient browser - credentials to authorize iframe data requests. +- **AC-PLUGINS-ISOLATED-WEB-APPS-007.3:** The capability API shall require a + valid runtime token even when a request carries browser session cookies. + Cookies shall not expand that token's effective grants. - **AC-PLUGINS-ISOLATED-WEB-APPS-007.4:** The content policy shall allow packaged scripts and styles but deny remote scripts, undeclared network origins, and form submissions. @@ -190,8 +200,8 @@ escape its approved network and presentation boundary. - **AC-PLUGINS-ISOLATED-WEB-APPS-007.6:** The host shall keep navigation, permission, release, archive, edit, and remove controls outside the iframe. - **AC-PLUGINS-ISOLATED-WEB-APPS-007.7:** Each runtime document response shall - enforce an opaque sandbox even when a person opens its capability URL outside - an iframe. + apply the same declared sandbox permissions when a person opens its capability + URL outside an iframe. It shall not claim an opaque-origin boundary. - **AC-PLUGINS-ISOLATED-WEB-APPS-007.8:** Supported web and desktop hosts shall frame runtime documents, and every other ancestor shall be denied. - **AC-PLUGINS-ISOLATED-WEB-APPS-007.9:** The runtime shall resolve ordinary @@ -260,8 +270,8 @@ runtime protocol while Kandev retains their releases. ### REQ-PLUGINS-ISOLATED-WEB-APPS-011: Host appearance context -**Intent:** An isolated web application matches the current Kandev appearance -without receiving host authority. +**Intent:** A web application matches the current Kandev appearance through +a bounded presentation message. #### Acceptance criteria @@ -280,7 +290,7 @@ without receiving host authority. ### REQ-PLUGINS-ISOLATED-WEB-APPS-012: Runtime startup acknowledgement **Intent:** A host can distinguish a started document from a blocked or failed -frame without granting the frame host authority. +frame through the existing bounded startup message. #### Acceptance criteria @@ -297,10 +307,41 @@ frame without granting the frame host authority. gain startup detection without republishing or changing their stored bytes or digest. A startup timeout shall preserve releases, grants, and stored state. +### REQ-PLUGINS-ISOLATED-WEB-APPS-013: Trusted same-origin authentication + +**Intent:** A user can open a trusted canvas behind a cookie-authenticated +reverse proxy without a canvas-specific authentication bypass. + +#### Acceptance criteria + +- **AC-PLUGINS-ISOLATED-WEB-APPS-013.1:** When the host and runtime share an + origin, startup and default relative API requests shall send eligible browser + cookies. The iframe and response sandbox policies shall both preserve that origin. +- **AC-PLUGINS-ISOLATED-WEB-APPS-013.2:** When a proxy requires a valid session + cookie, an authenticated user shall reach Ready and read permitted canvas data. + The same behavior shall apply to task panels, direct routes, and phone hosts. +- **AC-PLUGINS-ISOLATED-WEB-APPS-013.3:** A cookie shall not replace a missing, + expired, revoked, or invalid runtime capability. Capability permission denials + and backend input validation shall remain effective with cookies present. +- **AC-PLUGINS-ISOLATED-WEB-APPS-013.4:** Documentation shall state that canvas + code is trusted with the viewing user's browser authority. It can access the + same-origin host DOM and storage, and use ordinary user-authorized APIs. + Canvas grants shall not be described as a complete security boundary. +- **AC-PLUGINS-ISOLATED-WEB-APPS-013.5:** Retained releases using default relative + fetches shall gain this behavior without republishing or changing stored bytes. + Explicit credential omission shall remain an application choice. +- **AC-PLUGINS-ISOLATED-WEB-APPS-013.6:** When proxy authentication expires, + startup shall fail through the existing recoverable state. After authentication + is restored, Retry shall obtain a fresh runtime and reach Ready. +- **AC-PLUGINS-ISOLATED-WEB-APPS-013.7:** Cookie-authenticated relative writes + and event subscriptions shall work through the same public origin. External + origins shall not gain cookie forwarding, CORS access, or framing permission. + ## Out of scope - A second JavaScript SDK for canvases. -- Direct iframe access to Kandev frontend state or native components. +- A supported API for direct host-DOM or frontend-store manipulation. Same-origin + code can access these browser surfaces, but they are not stable author contracts. - Remote script execution. - An unbounded general proxy to Kandev HTTP endpoints. - Automatic grants without direct or recorded delegated user approval. diff --git a/docs/specs/plugins/system-design/isolated-web-app-contributions.md b/docs/specs/plugins/system-design/isolated-web-app-contributions.md index 1180c2251e..dc5a233339 100644 --- a/docs/specs/plugins/system-design/isolated-web-app-contributions.md +++ b/docs/specs/plugins/system-design/isolated-web-app-contributions.md @@ -1,12 +1,12 @@ --- id: plugins-isolated-web-app-contributions-design -title: Isolated plugin web-application contributions system design +title: Plugin web-application contributions system design status: draft system: plugins owners: - kandev created: 2026-08-26 -last_updated: 2026-09-10 +last_updated: 2026-09-19 requirements: - REQ-PLUGINS-ISOLATED-WEB-APPS-001 - REQ-PLUGINS-ISOLATED-WEB-APPS-002 @@ -20,15 +20,20 @@ requirements: - REQ-PLUGINS-ISOLATED-WEB-APPS-010 - REQ-PLUGINS-ISOLATED-WEB-APPS-011 - REQ-PLUGINS-ISOLATED-WEB-APPS-012 + - REQ-PLUGINS-ISOLATED-WEB-APPS-013 --- -# Isolated plugin web-application contributions system design +# Plugin web-application contributions system design ## Purpose and boundaries -The Plugins system adds an isolated `web_app` contribution beside the trusted -native frontend bundle. A web application uses packaged static files in a -sandboxed iframe. It does not run in the Kandev SPA process. +The Plugins system provides packaged `web_app` documents beside native bundles. +The target runtime uses trusted same-origin iframes, not opaque-origin isolation. +It does not import package code into the SPA module registry. + +The [same-origin decision](../../../decisions/2026-09-19-trusted-same-origin-canvases.md) +amends the browser trust boundary. Implementation is complete under the +[same-origin delivery plan](../../../plans/canvas-same-origin-auth/plan.md). This design owns plugin instances, immutable releases, capability grants, runtime tokens, web data access, shared instance state, and live browser @@ -60,6 +65,7 @@ It extends the data boundary from | `REQ-PLUGINS-ISOLATED-WEB-APPS-010` | Artifact storage, Protocol compatibility | | `REQ-PLUGINS-ISOLATED-WEB-APPS-011` | Host appearance protocol | | `REQ-PLUGINS-ISOLATED-WEB-APPS-012` | Runtime startup protocol | +| `REQ-PLUGINS-ISOLATED-WEB-APPS-013` | Same-origin transport and trust | ## Existing plugin contracts @@ -99,7 +105,8 @@ wildcards, query strings, or fragments. Later specifications can add other host placements. The host owns the displayed navigation label, icon, scope, route, and actions. -The iframe owns only its document rectangle. +The supported canvas UI occupies its document rectangle. Trusted same-origin +code can also access the parent document; geometry is not a security boundary. A package can contain these contribution combinations: @@ -273,21 +280,15 @@ Add one host component for web applications. The component requests a runtime URL for an authorized instance, release, placement, and user. The backend returns a short-lived capability URL. -The component renders an iframe with these rules: +`WebAppFrame` and `BuildContentSecurityPolicy` both declare +`sandbox="allow-scripts allow-forms allow-same-origin"`. Both must change in +one implementation slice. Either opaque policy would otherwise retain the failure. +The runtime keeps its served origin, including top-level capability navigation. +Form submissions remain denied by `form-action 'none'`. -- `sandbox` permits scripts and forms. -- The sandbox omits `allow-same-origin`, top navigation, and popups. -- The iframe does not receive Kandev cookies or authorization headers. -- The host does not inject a JavaScript object or use a privileged message - bridge. It can send the presentation-only appearance envelope below. -- The host chrome contains status, navigation, permissions, releases, and - lifecycle actions. - -The runtime response also applies the sandbox through the CSP `sandbox` -directive. This directive protects a capability URL that a person opens as a -top-level document. The response permits only `allow-scripts` and -`allow-forms`. It does not permit `allow-same-origin`, and `form-action -'none'` denies form submissions to external origins. +The host injects no native API or credential value. Appearance and startup +messages stay bounded. Host chrome remains outside the frame for presentation; +trusted frame code can access that chrome through normal same-origin DOM APIs. The desktop shell adds a narrow `frame-src` rule for the loopback backend. `BuildContentSecurityPolicy` always prepends the literal CSP source `'self'` @@ -301,14 +302,11 @@ origin, including TLS termination at a reverse proxy. Each DNS alias works independently without a configured list. An alias does not authorize framing a different alias. Cross-origin frontend/runtime hosting requires an explicitly trusted origin; this repair adds no new operator configuration for that topology. -Preserve the current exact development ports and Tauri exceptions. CSP `'self'` -does not add `allow-same-origin` to either sandbox or grant ambient credentials. +Preserve the current exact development ports and Tauri exceptions. Framing +permission and browser same-origin identity remain separate decisions. Browser coverage must use real runtime responses through two custom HTTPS hosts and an unrelated-parent negative case, not just assert the header text. -The iframe can use `prefers-color-scheme` and responsive CSS as fallbacks. The -host appearance protocol supplies the exact active Kandev semantic colors. - ## Host appearance protocol The isolated runtime has one host-to-frame presentation message. Version 1 has @@ -347,8 +345,8 @@ iframe's `contentWindow`. Reveal additionally requires the current startup acknowledgement described below and one animation frame for appearance. The host sends another envelope when the resolved theme changes without reload. -The opaque iframe has no stable origin, so the host targets its exact window -with `targetOrigin: "*"`. The application listener accepts only messages whose +The existing appearance sender targets the exact frame window with +`targetOrigin: "*"` to retain support for web and distinct-origin desktop hosts. The application listener accepts only messages whose source is `window.parent` and whose type, version, mode, keys, and value bounds match the contract. This source check prevents a sibling frame from setting appearance values. The wildcard does not grant authority because the payload @@ -375,7 +373,8 @@ Serve the bootstrap from a reserved capability-relative capability and apply normal runtime headers. Package files cannot shadow it. The bootstrap installs capture-phase script/asset error and unhandled-rejection listeners before authored code. It waits for document load, then fetches -`./_kandev/v1/context` using the existing capability path. It reports only safe +`./_kandev/v1/context` using the existing capability path and +`credentials: "same-origin"`. Never use `omit` for this host-owned request. It reports only safe startup result codes. A handled application data error can still render inside a successfully started frame; Ready is not proof of application correctness or business-operation success. @@ -390,9 +389,10 @@ are a closed vocabulary (`document_error`, `context_unavailable`). Arbitrary error text and request URLs are never forwarded. The parent checks exact message shape, version, nonce, and -`event.source === iframe.contentWindow`. An opaque `event.origin` of `null` -is not sufficient authority. Replies from a previous mount, sibling window, -wrong nonce, or unsupported version are ignored. The reply changes presentation +`event.source === iframe.contentWindow`. Origin alone is not sufficient +authority. Same-origin code is trusted and can access host state directly. +Replies from a previous mount, sibling window, wrong nonce, or unsupported +version are ignored. The reply changes presentation only. No domain APIs, grants, navigation, or credentials cross this channel. Top-level capability navigation has no host probe and remains sandboxed. @@ -434,9 +434,11 @@ fresh HTTP metadata and runtime-binding load. This is the revocation boundary for direct external requests. Kandev runtime and protocol requests still run the binding validator on every request. -The runtime route accepts the capability without an ambient session cookie. -The route sets explicit CORS responses for the sandboxed opaque origin. It -does not accept the token from a general authorization header or query field. +The runtime route accepts the capability independently of ambient session cookies. +A reverse proxy can require its own cookie before forwarding the request. +Kandev does not accept a runtime token from a general header or query field. +Legacy opaque-origin CORS responses remain restricted; no credentialed `null` +origin or wildcard CORS permission is added. ## Browser data protocol @@ -551,15 +553,16 @@ add another handler type without changing the browser protocol. The runtime document response uses this minimum header policy: -- `Content-Security-Policy` includes `sandbox allow-scripts allow-forms`, +- `Content-Security-Policy` includes + `sandbox allow-scripts allow-forms allow-same-origin`, `default-src 'none'`, `form-action 'none'`, `base-uri 'none'`, and `object-src 'none'` - `frame-ancestors` contains the host-owned `'self'` keyword and normalized explicit Kandev launcher and Tauri origins - `X-Content-Type-Options` is `nosniff` - `Referrer-Policy` is `no-referrer` -- `Cross-Origin-Resource-Policy` is `cross-origin` because the document has an - opaque origin and Tauri is a separate origin +- `Cross-Origin-Resource-Policy` remains `cross-origin` for distinct-origin + Tauri hosts - capability-bearing HTML and API responses use `Cache-Control: no-store` The response uses a restrictive resource policy: @@ -579,16 +582,51 @@ The response uses a restrictive resource policy: The implementation must build the policy from normalized grants. It must not copy manifest text into a response header. -The runtime strips plugin-supplied framing, cookie, authentication, service -worker, and cross-origin policy headers. Service workers are unavailable under -the opaque origin and runtime path. +Runtime headers remain host-owned. They do not grant broader service-worker +scope. Same-origin code can access origin storage and the parent document; +service-worker unavailability must not be claimed as an isolation guarantee. +Authors use instance state for shared values and memory for temporary values. +They must not modify host internals or depend on shared browser-storage keys. +These are authoring rules, not enforced browser boundaries. -The host must treat all iframe content, titles, errors, downloads, and links as -untrusted. The iframe cannot cover host controls or navigate the top frame. +## Same-origin transport and trust -The authoring guide states that an opaque-origin application cannot use -`localStorage`, `sessionStorage`, IndexedDB, or service workers. The application -uses instance state for durable shared values and memory for temporary values. +This change applies to the shared `ui.web_apps` runtime, including local, +imported, and installed-plugin canvases. It adds no manifest option, profile +switch, database migration, or separate iframe transport. + +The startup bootstrap uses `credentials: "same-origin"`. Standard relative +`fetch`, XHR, asset loads, and EventSource requests use their same-origin +browser behavior. No global fetch shim rewrites authored requests. Retained +packages that explicitly omit credentials need an author edit and republish. +Cross-origin credential delivery, including separate-origin desktop hosts, +is not enabled globally. Capability-only local operation remains supported. + +The proxy must preserve the public Host for writes that carry Origin. +`corsMiddleware` already accepts matching-host origins through `AllowedOrigin`. +Do not trust arbitrary forwarded headers or broaden the general CORS policy. +The HTTPS test proxy must preserve browser cookies and public Host; it must not +supply missing cookies from its Node client or mock successful protocol replies. + +The proxy cookie only passes the outer gate. `Runtime.Serve` still validates +its token and binding before assets, data, state, actions, and events. Cookies +must not change the bound actor, release, scope, or grant generation. Invalid +capabilities and denied writes still fail through existing backend services. +Direct first-party API requests use the ordinary user authorization boundary; +canvas grants do not constrain them. Revoking a runtime token cannot undo DOM +changes, shared storage, or callbacks already installed in the parent page. + +Keep `script-src`, network grants, form policy, and exact frame ancestors. +These govern the served document, not trusted code acting through its parent. +Proxy HTML injection remains incompatible with the startup monitor and CSP. +Operators must exclude runtime responses from Cloudflare RUM and other +injected scripts that violate policy. Do not allow remote analytics scripts +or suppress document errors to conceal this problem. + +The UI layout, translated states, Retry action, and phone navigation stay the +same. Test Ready, data, a permitted write, and an event through a real local +cookie gate. Test missing cookies, reauthentication and Retry, invalid tokens, +and unchanged package bytes. Record desktop and phone results separately. ## Compatibility @@ -647,45 +685,30 @@ backend or package errors. ## Observability -Add counters and duration metrics for: - -- package validation and activation result -- runtime capability issue and rejection result -- data resource and operation result -- state operation and conflict result -- event connection, replay, and resync result -- content-policy and network denial result -- artifact bytes admitted, retained, rejected, unavailable, and removed - -Logs can contain plugin ID, instance ID, release ID, web-application key, -resource type, operation, safe result, and duration. Logs omit source, content, -state values, bodies, payloads, runtime capabilities, and user credentials. +Record bounded result counters and durations for validation, activation, runtime +access, data/state operations, events, policy denials, and artifact storage. +Logs may identify plugin, instance, release, operation, result, and duration. +Never log source, bodies, state values, event payloads, tokens, or credentials. ## Test strategy -- Manifest tests cover web-application declarations and combinations. -- Package tests cover traversal, links, duplicate paths, limits, and digest - stability. -- Repository tests cover instances, releases, grants, state revisions, and - migration replay on SQLite and PostgreSQL. -- Service tests cover release activation and grant intersection. -- HTTP tests cover capability binding, stale tokens, CORS, limits, and stable - errors. -- Security tests cover sandbox flags, content policy, form-action denial, - remote script denial, cookie absence, nested asset resolution, authority - revocation after load, and top-navigation denial. -- Host data contract tests compare browser and gRPC projections. -- Event tests cover scope filtering, reconnect replay, generation changes, and - resync. -- Frontend component tests cover host state and iframe lifecycle. -- Startup tests cover blocked documents, asset failure, missing/failed context, - stale and sibling replies, cleanup, retry, renewal, and unchanged artifact - digests. Browser tests load a retained application without an authored monitor. -- Appearance tests cover initial delivery, source validation, token bounds, - live changes, and computed colors in direct, Dockview, and phone hosts. +- Validate manifests, package combinations, traversal, links, duplicates, limits, + digests, activation, grant intersection, and SQLite/PostgreSQL migrations. +- Exercise runtime bindings, stale tokens, CORS, safe errors, shared state, + pagination, and browser/gRPC projection parity. +- Verify both sandbox declarations, cookie delivery, form and remote-script + denial, nested assets, exact ancestors, and capability revocation. +- Verify scoped events, replay, generation changes, and resync. +- Cover failed documents/assets/context, absent and stale acknowledgements, + sibling replies, timeout cleanup, retries, renewal, and unchanged artifacts. +- Verify initial and live appearance in direct, Dockview, and phone hosts. +- Execute the cookie-proxy and authentication tests in the + [same-origin delivery plan](../../../plans/canvas-same-origin-auth/plan.md). ## Related decisions +- [Trusted same-origin canvases](../../../decisions/2026-09-19-trusted-same-origin-canvases.md) + - [Plugin-backed web-app canvases](../../../decisions/2026-08-26-plugin-backed-web-app-canvases.md) - [Plugin Host data API](../../../decisions/0043-plugin-host-data-api.md) - [Authenticated plugin actions](../../../decisions/2026-07-31-authenticated-plugin-actions.md) diff --git a/docs/specs/ui/requirements/panel-toolbars.md b/docs/specs/ui/requirements/panel-toolbars.md new file mode 100644 index 0000000000..920f38dced --- /dev/null +++ b/docs/specs/ui/requirements/panel-toolbars.md @@ -0,0 +1,54 @@ +--- +status: draft +system: ui +created: 2026-09-19 +owners: + - Kandev frontend +--- + +# Panel toolbar requirements + +## Overview + +The UI system owns the reusable geometry of toolbars inside task panels. +Feature owners retain actions, runtime states, and navigation semantics. +Adjacent panel toolbars must align below their Dockview tab strips. + +## Requirements + +### REQ-UI-PANEL-TOOLBARS-001: Consistent panel toolbar geometry + +**Intent:** Panel actions occupy one predictable row across the workbench. + +#### Acceptance criteria + +- **AC-UI-PANEL-TOOLBARS-001.1:** At a 16px root font, each primary panel + toolbar shall have a fixed border-box height of 30px on fine-pointer desktop. + Adjacent panels in the same Dockview row shall align their toolbar boundaries. +- **AC-UI-PANEL-TOOLBARS-001.2:** Below 768px or with a coarse primary pointer, + equivalent toolbars shall use a fixed 48px height and targets at least 44px. + Geometry shall scale with the root font. +- **AC-UI-PANEL-TOOLBARS-001.3:** Narrow panels and long translated labels shall + not wrap or increase toolbar height. Titles may truncate; secondary actions + shall remain reachable through a visible overflow control. +- **AC-UI-PANEL-TOOLBARS-001.4:** Loading, disabled, busy, and empty states shall + retain toolbar geometry, accessible names, keyboard operation, and focus. +- **AC-UI-PANEL-TOOLBARS-001.5:** Changing viewport or panel width shall not + reset feature state or hide required actions without an alternate path. + Toolbars shall remain outside the content scroll region. +- **AC-UI-PANEL-TOOLBARS-001.6:** Each toolbar control shall fit within its row + without clipping, overlapping targets, or document horizontal overflow. + +## Boundaries + +Dockview tab strips, global page navigation, footers, section headings, chat +composers, and toolbars rendered inside third-party applications have independent +geometry. A panel need not gain a toolbar if it has no toolbar actions. +Nested per-file diff headers are content headings, not primary panel toolbars. + +## Related documents + +- [Design](../system-design/panel-toolbars.md) +- [Control sizing](control-sizing.md) +- [Canvas host requirements](../../canvases/requirements/agent-authored-web-apps.md) +- [Implementation plan](../../../plans/canvas-same-origin-auth/plan.md) diff --git a/docs/specs/ui/system-design/panel-toolbars.md b/docs/specs/ui/system-design/panel-toolbars.md new file mode 100644 index 0000000000..00a2b5960a --- /dev/null +++ b/docs/specs/ui/system-design/panel-toolbars.md @@ -0,0 +1,97 @@ +--- +status: draft +system: ui +requirements: + - REQ-UI-PANEL-TOOLBARS-001 +--- + +# Panel toolbar design + +## Purpose and boundaries + +UI owns shared panel geometry. Canvas lifecycle and runtime status remain +owned by Canvases and Plugins. No backend or persistence change is required. + +## Requirement mapping + +| Criteria | Implementation boundary | +| --- | --- | +| 001.1, 001.2 | Shared header shell | +| 001.3, 001.6 | Caller composition and overflow | +| 001.4, 001.5 | State preservation and browser regression tests | + +## Shared header shell + +Extend the existing `PanelToolbar`, `PanelHeaderBar`, and `PanelHeaderBarSplit` +in `apps/web/components/task/panel-primitives.tsx`. Do not introduce a competing +component. Header geometry has one source: 1.875rem on desktop and 3rem below +768px or with a coarse primary pointer. Use border-box sizing and prevent flex +shrink. Do not change `PanelFooterBar` implicitly through `PANEL_BAR_CLASS`. + +Allow standard div attributes, refs, and test identifiers through the shell. +Keep semantic controls native; do not add ARIA toolbar semantics without the +required keyboard navigation. Feature wrappers retain their handlers and labels. +Remove caller height, min-height, vertical padding, and wrapping overrides that +conflict with this contract. The split layout must permit title truncation and +reserve space for an overflow trigger without clipping focus outlines. + +Use shared control sizing for touch targets. Existing 24px compact desktop +controls may remain; ordinary actions use 28px. Current 32px editor controls +cannot fit the 30px row and must be reconciled. Changes to shell size alone +cannot establish correct control geometry. + +## Source audit and migration inventory + +This inventory records source evidence, not measured browser results. + +| Surface | Current source | Disposition | +| --- | --- | --- | +| Canvas | `settings/canvas-host-route.tsx`, `canvas-host-components.tsx` | Replace embedded PageShell with shared panel header; remove status-only header | +| Changes | `task/changes-panel-header.tsx` | Already uses split shell; verify dense actions and long labels | +| Review top bar | `task/changes-top-bar.tsx` | Remove `h-auto`, wrapping and padding override; keep PR selection through compact selector/overflow | +| Files and search | `task/file-browser-toolbar.tsx`, `file-browser-search-header.tsx` | Existing shell; verify touch controls, search field and narrow-panel containment | +| Plan | `task/task-plan-panel-header.tsx` | Existing shell; preserve Implement and revision controls through overflow | +| Monaco | `editors/monaco/monaco-editor-toolbar.tsx` | Existing shell with 32px controls; normalize controls and overflow | +| CodeMirror | `editors/codemirror/codemirror-code-editor.tsx` | Existing shell; verify action and field sizing | +| Markdown and HTML previews | `task/markdown-preview-content.tsx`, `html-preview-content.tsx` | Existing shell; verify long paths and overflow | +| Browser | `task/browser-panel.tsx` | Existing shell; remove URL field's fixed minimum-width pressure; retain refresh/open/inspect | +| Mobile file viewer | `task/mobile/mobile-file-viewer-panel.tsx` | Existing shell; adopt touch height without clipping controls | +| Bottom terminal | `task/bottom-terminal-panel.tsx` | Padding-sized header; migrate header to shared shell | +| Dock terminal | `task/terminal-panel.tsx` | No independent action header; do not invent one | +| Nested diff headers | `diff/diff-header-toolbar.tsx`, `review/review-diff-toolbar.tsx`, `editors/monaco/diff-viewer-toolbar.tsx` | Content-level file controls retain independent geometry | +| Passthrough/chat | `task/passthrough-toolbar.tsx`, `task/chat/chat-input-toolbar*.tsx` | Composer and queued-message content; retain independent geometry | + +Paths above are relative to `apps/web/components/`. Repeat the search for +`PanelHeaderBar`, `PanelHeaderBarSplit`, `PanelToolbar`, `border-b`, and padding-only +headers during implementation. Record newly discovered primary toolbars in the +work order and migrate them. Exclusions need a concrete role, not a filename. + +## Overflow and responsive composition + +A narrow Dockview panel can occur at desktop viewport widths. Select action +presentation from available row width, not just a page breakpoint. Reuse existing +menu primitives, action models, and callbacks. Prioritize title/current selection +and primary action; place secondary actions in a visible menu before overflow. +Preserve disabled explanations and tooltip/keyboard help. + +Phone canvas navigation follows the existing `MobileCanvasActions` inset drawer. +Mobile file viewing follows `mobile-file-viewer-panel.tsx`; dense content remains +a focused full-height surface. Reuse `useResponsiveBreakpoint` for presentation +and CSS media conditions for pointer-dependent sizes. Keep one content scroll +owner, dynamic viewport height, safe-area clearance, and focus return on dismiss. +A geometry change must not remount an editor or reset a canvas runtime. + +## Verification + +Use computed border-box heights and compare neighboring toolbar boundaries, +with a 1px tolerance at a 16px root font. Check actual control hitboxes and +containment, not class names. Exercise desktop, a narrow Dockview panel, 390px, +767px, 768px, and a 900px coarse-pointer viewport. Include long labels, multiple +PRs, busy actions, editor dirty state, and canvas startup/recovery. + +The work order defines exact unit and Playwright commands. No new logs, metrics, +backend authorization rules, or saved preferences are introduced. + +## Implementation plan + +- [Canvas authentication and panel toolbars](../../../plans/canvas-same-origin-auth/plan.md)