diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 44767bd7a06d..fc9040c40ee9 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -1027,9 +1027,14 @@ jobs:
# The fork mirrors main but does not publish upstream's release tags.
- name: Fetch upstream compatibility baselines
if: github.repository == 'Fartown/orca'
- run: >-
- git fetch --no-tags --filter=blob:none https://github.com/stablyai/orca.git
- 'refs/tags/v*:refs/tags/v*'
+ run: |
+ git fetch --no-tags --filter=blob:none https://github.com/stablyai/orca.git 'refs/tags/v*:refs/tags/v*'
+ # Why: fork builds carry upstream as of the last sync; a release cut after it is not one
+ # fork users upgrade from, and its moved files fail checks written for the older tree.
+ snapshot="$(git log -1 --format=%ct "$(git merge-base HEAD origin/main)")"
+ git for-each-ref --format='%(refname:short) %(creatordate:unix)' 'refs/tags/v*' \
+ | awk -v snapshot="$snapshot" '$2 > snapshot { print $1 }' \
+ | xargs -r git tag -d > /dev/null
- uses: ./.github/actions/install-node-dependencies
with:
diff --git a/config/architecture-policies.jsonc b/config/architecture-policies.jsonc
index db623ef0922a..1d961d77308a 100644
--- a/config/architecture-policies.jsonc
+++ b/config/architecture-policies.jsonc
@@ -248,24 +248,20 @@
"src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts",
"src/renderer/src/hooks/ipc-events/worktree-event-runtime-activation.test.ts",
"src/renderer/src/hooks/ipc-events/worktree-event-lineage-refresh.test.ts",
-
"src/main/runtime/runtime-terminal-list.ts",
"src/main/runtime/project-group-folder-workspace-selectors.test.ts",
-
"src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.spec.ts",
"src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts",
"src/renderer/src/components/terminal-pane/pty-connection-hidden-sleeping-resume.spec.ts",
"src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.spec.ts",
"src/renderer/src/components/terminal-pane/pty-connection-spawn-deferred-sleep.test.ts",
-
"src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts",
"src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts",
"src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts",
"src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-newer-daemon-hold.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-live-bookmark.test.ts",
-
"src/shared/workspace-session-recovery/**",
"src/renderer/src/workspace-session-recovery/**",
"docs/issue/工作区恢复占位收敛/**",
@@ -384,6 +380,7 @@
"mobile/src/session/mobile-session-route-parity.test.ts",
"config/scripts/mobile-web-app/mobile-web-app-session-terminal-closure.test.mjs",
"config/scripts/verify-mobile-web-app-bundle.mjs",
+ "config/scripts/mobile-web-app/mobile-web-app-drawer-render.test.mjs",
"config/scripts/build-mobile-web-app-bundle.test.mjs",
"src/shared/file-attachment-upload/**",
"src/main/file-attachment-upload/**",
@@ -753,6 +750,7 @@
"src/main/startup/main-process-local-tasks.ts",
"src/main/runtime/rpc/methods/local-tasks.ts",
"src/main/runtime/rpc/methods/local-tasks.test.ts",
+ "src/main/runtime/terminal/focus/focus-terminal.ts",
"src/main/runtime/orca-runtime-get-status.ts",
"mobile/app/h/[hostId]/local-tasks.tsx",
"mobile/app/h/[hostId]/local-tasks.web.tsx",
@@ -772,6 +770,7 @@
"src/renderer/src/lib/settings-navigation-types.ts",
"docs/issue/本地任务管理/**",
"src/main/runtime/rpc/methods/local-task-link-methods.ts",
+ "src/main/runtime/rpc/methods/local-task-dispatcher-methods.ts",
"mobile/src/host-screen/host-screen-overlays.tsx",
"mobile/src/tasks/use-mobile-tasks-picker-projection.tsx",
"mobile/src/tasks/mobile-tasks-screen-chrome.tsx",
@@ -780,6 +779,17 @@
"mobile/src/host-screen/workspace-details-sheet.tsx",
"mobile/src/components/WorktreeListRow.tsx",
"mobile/src/session/MobileSessionHeader.tsx",
+ "mobile/src/tasks/mobile-smart-source-modes.ts",
+ "mobile/src/components/SmartSourceModeIcon.tsx",
+ "mobile/src/components/SmartWorkspaceSourceDrawer.tsx",
+ "mobile/src/components/SmartWorkspaceSourceField.tsx",
+ "mobile/src/components/NewWorktreeFormSheet.tsx",
+ "mobile/src/components/NewWorktreeModalDrawers.tsx",
+ "mobile/src/components/NewWorktreeModal.tsx",
+ "mobile/src/components/use-new-workspace-create-submit.ts",
+ "mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts",
+ "mobile/src/mobile-web-shell/page-storage-keys.ts",
+ "mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts",
"src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts",
"src/renderer/src/components/NewWorkspaceComposerModal.tsx",
"src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx",
@@ -864,6 +874,18 @@
"src/renderer/src/components/worktree-jump/use-worktree-jump-palette-task-search.test.tsx",
"src/shared/plugins/languages/protected-copy-keys.json",
"src/renderer/src/components/activity/activity-answered-ask-timeline.test.tsx",
+ "src/renderer/src/components/use-terminal-workspace-store-bindings.ts",
+ "src/renderer/src/components/sidebar/index.tsx",
+ "src/renderer/src/components/sidebar/SidebarHeader.tsx",
+ "src/renderer/src/components/native-chat/NativeChatComposerPane.tsx",
+ "mobile/src/session/use-mobile-session-foundation.ts",
+ "mobile/src/session/use-mobile-session-controller.ts",
+ "mobile/src/session/use-notification-pane-navigation.ts",
+ "mobile/src/session/use-notification-pane-navigation.test.tsx",
+ "config/scripts/mobile-web-app/mobile-web-app-page-grant-call-sites.test.mjs",
+ "config/scripts/mobile-web-app/mobile-web-app-screencast-lane-grant.test.mjs",
+ "config/scripts/mobile-web-app/mobile-web-app-external-navigation-grant.test.mjs",
+ "mobile/src/components/bottom-drawer/bottom-drawer-styles.ts",
"src/main/gitea-host-verification/**",
"docs/issue/Gitea主机识别校验/**",
"src/main/gitea/client.ts",
@@ -892,13 +914,27 @@
"skill-guides/orca-plugin-authoring/references/events.md",
"skill-guides/orca-plugin-authoring/references/host-api.md",
"src/cli/bundled-skill-guides.ts",
+ "src/renderer/src/lib/agent-message-send.ts",
+ "src/renderer/src/lib/active-agent-note-send.ts",
+ "src/renderer/src/lib/active-agent-note-send-delivery.ts",
+ "src/renderer/src/lib/agent-message-send.test.ts",
+ "src/renderer/src/lib/active-agent-note-send-delivery-guard.test.ts",
+ "src/shared/execution-host-registry.ts",
+ "src/renderer/src/components/sidebar/sidebar-host-options.ts",
+ "src/renderer/src/components/sidebar/sidebar-host-options.test.ts",
+ "src/shared/execution-host-registry.test.ts",
"src/renderer/src/components/markdown-review-anchor-recovery/**",
"docs/issue/Markdown评论失效锚点恢复/**",
"tests/e2e/markdown-review-anchor-recovery.spec.ts",
"src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts",
"src/renderer/src/components/editor/rich-markdown-review-note-layout.ts",
"src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx",
- "docs/issue/README.md"
+ "docs/issue/README.md",
+ "src/main/ipc/runtime.ts",
+ "src/main/ipc/desktop-runtime-sender-lifecycle.ts",
+ "src/main/runtime/rpc/core.ts",
+ "src/main/runtime/rpc/dispatcher-stream-options.ts",
+ "src/main/ipc/desktop-runtime-sender-lifecycle.test.ts"
],
"message": "Branch changes an upstream path that no fork feature owns or registers as a seam; extend config/fork-features.jsonc and this allowlist, or move the change into a feature-owned path."
}
@@ -1836,6 +1872,7 @@
"mobile/src/local-tasks/**",
"docs/issue/本地任务管理/**",
"src/main/runtime/rpc/methods/local-task-link-methods.ts",
+ "src/main/runtime/rpc/methods/local-task-dispatcher-methods.ts",
"src/cli/local-tasks/**"
],
"allowedPaths": [
@@ -1882,6 +1919,7 @@
"src/shared/rpc-contract/rpc-params-catalog.generated.ts",
"src/shared/host-operation-ownership.ts",
"src/shared/wsl-move/wsl-move-storage-dispositions.ts",
+ "src/main/runtime/terminal/focus/focus-terminal.ts",
"src/main/runtime/orca-runtime-get-status.ts",
"src/main/runtime/runtime-rpc/fork-mobile-rpc-method-allowlist.ts",
"src/renderer/src/app-shell/AppBackgroundServices.tsx",
@@ -1893,10 +1931,12 @@
"src/renderer/src/components/settings/settings-page-renderer.tsx",
"src/renderer/src/lib/settings-navigation-types.ts",
"src/renderer/src/i18n/locales/en.json",
+ "src/renderer/src/i18n/en-runtime-required.json",
"src/renderer/src/i18n/locales/zh.json",
"config/scripts/mobile-web-page-routes.mjs",
"config/scripts/mobile-web-app/mobile-web-app-page-route-modules.mjs",
"config/scripts/verify-mobile-web-app-bundle.mjs",
+ "config/scripts/mobile-web-app/mobile-web-app-drawer-render.test.mjs",
"config/scripts/build-mobile-web-app-bundle.test.mjs",
"mobile/src/transport/rpc-client-server-subscription.ts",
"mobile/src/home/MobileHomeTasksAndAutomations.tsx",
@@ -1909,6 +1949,7 @@
"docs/issue/README.md",
".gitignore",
"src/main/runtime/rpc/methods/local-task-link-methods.ts",
+ "src/main/runtime/rpc/methods/local-task-dispatcher-methods.ts",
"mobile/src/host-screen/host-screen-overlays.tsx",
"mobile/src/tasks/use-mobile-tasks-picker-projection.tsx",
"mobile/src/tasks/mobile-tasks-screen-chrome.tsx",
@@ -1917,6 +1958,17 @@
"mobile/src/host-screen/workspace-details-sheet.tsx",
"mobile/src/components/WorktreeListRow.tsx",
"mobile/src/session/MobileSessionHeader.tsx",
+ "mobile/src/tasks/mobile-smart-source-modes.ts",
+ "mobile/src/components/SmartSourceModeIcon.tsx",
+ "mobile/src/components/SmartWorkspaceSourceDrawer.tsx",
+ "mobile/src/components/SmartWorkspaceSourceField.tsx",
+ "mobile/src/components/NewWorktreeFormSheet.tsx",
+ "mobile/src/components/NewWorktreeModalDrawers.tsx",
+ "mobile/src/components/NewWorktreeModal.tsx",
+ "mobile/src/components/use-new-workspace-create-submit.ts",
+ "mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts",
+ "mobile/src/mobile-web-shell/page-storage-keys.ts",
+ "mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts",
"src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts",
"src/renderer/src/components/NewWorkspaceComposerModal.tsx",
"src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx",
@@ -1958,7 +2010,37 @@
"src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx",
"src/renderer/src/hooks/ipc-events/new-workspace-command.ts",
"src/renderer/src/components/worktree-jump/use-worktree-jump-palette-task-search.test.tsx",
- "src/shared/plugins/languages/protected-copy-keys.json"
+ "src/shared/plugins/languages/protected-copy-keys.json",
+ "src/renderer/src/app-shell/AppWorkspaceShell.tsx",
+ "src/renderer/src/components/use-terminal-workspace-store-bindings.ts",
+ "src/renderer/src/components/sidebar/index.tsx",
+ "src/renderer/src/components/sidebar/SidebarHeader.tsx",
+ "src/renderer/src/components/native-chat/NativeChatComposerPane.tsx",
+ "mobile/src/session/use-mobile-session-foundation.ts",
+ "mobile/src/session/use-mobile-session-controller.ts",
+ "mobile/src/session/use-notification-pane-navigation.ts",
+ "mobile/src/i18n/locales/en.json",
+ "mobile/src/i18n/locales/zh.json",
+ "mobile/src/session/use-notification-pane-navigation.test.tsx",
+ "config/scripts/mobile-web-app-file-attachment-grant.test.mjs",
+ "config/scripts/mobile-web-app/mobile-web-app-page-grant-call-sites.test.mjs",
+ "config/scripts/mobile-web-app/mobile-web-app-screencast-lane-grant.test.mjs",
+ "config/scripts/mobile-web-app/mobile-web-app-external-navigation-grant.test.mjs",
+ "mobile/src/components/bottom-drawer/bottom-drawer-styles.ts",
+ "src/renderer/src/lib/agent-message-send.ts",
+ "src/renderer/src/lib/active-agent-note-send.ts",
+ "src/renderer/src/lib/active-agent-note-send-delivery.ts",
+ "src/renderer/src/lib/agent-message-send.test.ts",
+ "src/renderer/src/lib/active-agent-note-send-delivery-guard.test.ts",
+ "src/shared/execution-host-registry.ts",
+ "src/renderer/src/components/sidebar/sidebar-host-options.ts",
+ "src/renderer/src/components/sidebar/sidebar-host-options.test.ts",
+ "src/shared/execution-host-registry.test.ts",
+ "src/main/ipc/runtime.ts",
+ "src/main/ipc/desktop-runtime-sender-lifecycle.ts",
+ "src/main/runtime/rpc/core.ts",
+ "src/main/runtime/rpc/dispatcher-stream-options.ts",
+ "src/main/ipc/desktop-runtime-sender-lifecycle.test.ts"
],
"message": "Change is outside the local tasks feature."
},
@@ -1980,6 +2062,7 @@
"**/*.spec.ts",
"**/*.spec.tsx",
"src/main/runtime/rpc/methods/local-task-link-methods.ts",
+ "src/main/runtime/rpc/methods/local-task-dispatcher-methods.ts",
"src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts",
"src/main/runtime/runtime-git-generation-commands.ts"
],
@@ -2039,7 +2122,12 @@
"src/renderer/src/components/sidebar/WorktreeLinkedItemsField.tsx",
"src/renderer/src/components/sidebar/WorktreeMetaDialogDraft.tsx",
"src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx",
- "src/renderer/src/hooks/ipc-events/new-workspace-command.ts"
+ "src/renderer/src/hooks/ipc-events/new-workspace-command.ts",
+ "src/renderer/src/app-shell/AppWorkspaceShell.tsx",
+ "src/renderer/src/components/use-terminal-workspace-store-bindings.ts",
+ "src/renderer/src/components/sidebar/index.tsx",
+ "src/renderer/src/components/sidebar/SidebarHeader.tsx",
+ "src/renderer/src/components/native-chat/NativeChatComposerPane.tsx"
],
"forbiddenTargets": [
"src/renderer/src/local-tasks/**",
diff --git a/config/fork-features.jsonc b/config/fork-features.jsonc
index 2aff60dd375c..4957c36f05e9 100644
--- a/config/fork-features.jsonc
+++ b/config/fork-features.jsonc
@@ -1676,7 +1676,9 @@
"src/renderer/src/components/editor/markdown-rich-size-limit.ts",
"src/renderer/src/components/editor/CodeBlockCopyButton.tsx",
"src/renderer/src/components/editor/markdown-doc-links.ts",
- "src/renderer/src/components/editor/use-mermaid-diagram-expand.ts"
+ "src/renderer/src/components/editor/use-mermaid-diagram-expand.ts",
+ "src/renderer/src/components/editor/rich-markdown-range-bounds.ts",
+ "src/renderer/src/components/markdown-review-anchor-recovery/review-anchor-position.ts"
],
"policyId": "self-hosted-artifacts",
"ownedPaths": [
@@ -1940,6 +1942,7 @@
"src/main/self-hosted-artifacts/artifact-share-owner-comments.test.ts",
"src/cli/self-hosted-artifacts/artifact-share-cli-comments.test.ts",
"config/scripts/build-artifact-share-viewer.test.mjs",
+ "src/renderer/src/components/self-hosted-artifacts/share-viewer/share-comment-anchors.test.ts",
"src/cli/self-hosted-artifacts/artifact-share-cli-target.test.ts",
"src/renderer/src/components/self-hosted-artifacts/share-button/lan-artifact-share-machine.test.ts",
"src/renderer/src/components/self-hosted-artifacts/share-button/lan-artifact-share-target.test.ts",
@@ -2322,7 +2325,7 @@
},
{
"file": "config/scripts/verify-mobile-web-app-bundle.mjs",
- "mustContain": "['./h/_layout.tsx', 142]",
+ "mustContain": "['./h/_layout.tsx', 139]",
"why": "the route-prefix sweep records continuation's measured chunk-sharing change"
},
{
@@ -2989,7 +2992,7 @@
{
"id": "local-tasks",
"title": "本地任务管理:以任务为中心,挂接 Orca 的工作区与会话",
- "goal": "在 Orca 里记录、拆分、跟踪任务;从任务一键开工作区和会话,任务上看到会话进展,会话需要人时进入「需要你」",
+ "goal": "管理各主机的任务、会话与进展,支持直接派活、人工验收和需确认的总管提案",
"journal": "docs/issue/本地任务管理/journal.md",
"dependsOn": [
"src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx",
@@ -3032,6 +3035,8 @@
"mobile/src/platform/text-input-font-size.ts",
"mobile/src/tasks/mobile-task-runtime-operations.ts",
"mobile/src/theme/mobile-theme.ts",
+ "mobile/src/theme/themed-styles.ts",
+ "mobile/src/theme/use-mobile-color-scheme.ts",
"mobile/src/transport/client-context.tsx",
"mobile/src/transport/host-client-hooks.ts",
"mobile/src/transport/host-store.ts",
@@ -3114,6 +3119,9 @@
"mobile/src/worktree/workspace-list-types.ts",
"mobile/src/worktree/worktree-catalog-operations.ts",
"mobile/src/worktree/worktree-catalog-snapshot-client.ts",
+ "mobile/src/storage/mirrored-storage-keys.ts",
+ "mobile/src/components/WorktreeMetaGlyphs.tsx",
+ "src/shared/github/pull-request-types.ts",
"src/renderer/src/components/AgentStateDot.tsx",
"src/renderer/src/components/agent/AgentCombobox.tsx",
"src/renderer/src/components/sidebar/ProjectHeaderActions.tsx",
@@ -3175,6 +3183,7 @@
"src/shared/sidebar-list/host-section-rows.ts",
"src/shared/sidebar-list/row-types.ts",
"src/shared/stable-pane-id.ts",
+ "src/shared/terminal-tab-types.ts",
"src/shared/agent-session/view/structured-agent-session-projection.ts",
"src/shared/tab-types.ts",
"src/shared/terminal-surface-id.ts",
@@ -3192,6 +3201,10 @@
"src/shared/task-providers.ts",
"src/renderer/src/hooks/composer-state/composer-target-store.ts",
"mobile/src/tasks/worktree-create-capability.ts",
+ "mobile/src/tasks/workspace-create-params.ts",
+ "mobile/src/tasks/use-mobile-composer-source.ts",
+ "mobile/src/components/new-worktree-modal-types.ts",
+ "src/shared/project-host-setup-projection.ts",
"src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx",
"src/renderer/src/components/new-workspace/smart-workspace-localized-options.tsx",
"src/shared/new-workspace/smart-workspace-source-results.ts",
@@ -3206,9 +3219,49 @@
"src/cli/dispatch.ts",
"src/cli/flags.ts",
"src/cli/format.ts",
+ "src/cli/command-body-input.ts",
"src/cli/runtime/types.ts",
"src/cli/selectors.ts",
- "mobile/src/i18n/mobile-i18n.ts"
+ "src/renderer/src/components/sidebar/workspace-status.ts",
+ "src/shared/workspace-statuses.ts",
+ "src/renderer/src/session-names/session-name-prompt-projection.ts",
+ "src/shared/agent-row-conversation-name.ts",
+ "src/shared/agent-tab-title.ts",
+ "mobile/src/i18n/mobile-i18n.ts",
+ "src/renderer/src/components/terminal/background-terminal-worktree-mount.ts",
+ "src/renderer/src/components/activity/activity-terminal-portal.ts",
+ "src/renderer/src/components/activity/activity-terminal-portal-status.ts",
+ "src/renderer/src/components/native-chat/NativeChatView.tsx",
+ "src/renderer/src/runtime/structured-agent-session-owner.ts",
+ "src/renderer/src/constants/terminal.ts",
+ "src/renderer/src/components/sidebar/useWorktreeAgentRows.ts",
+ "src/renderer/src/lib/scroll-to-current-workspace-status.ts",
+ "mobile/src/session/MobileSessionActiveContent.tsx",
+ "mobile/src/session/use-mobile-session-controller.ts",
+ "src/renderer/src/components/pull-request-page/mentions/query.ts",
+ "src/renderer/src/runtime/agent-session-operation-id.ts",
+ "src/shared/new-workspace/worktree-create-retry-policy.ts",
+ "src/renderer/src/runtime/structured-agent-session-close.ts",
+ "src/shared/runtime-terminal-contracts.ts",
+ "mobile/src/session/native-chat/mobile-native-chat-eligibility.ts",
+ "src/renderer/src/attention/agent-subject-read-actions.ts",
+ "src/renderer/src/components/terminal-pane/terminal-notification-pane-visibility.ts",
+ "src/renderer/src/hooks/agent-auto-ack-targets.ts",
+ "mobile/src/components/bottom-drawer/bottom-drawer-styles.ts",
+ "src/renderer/src/lib/focus-terminal-tab-surface.ts",
+ "src/shared/constants.ts",
+ "src/renderer/src/lib/terminal-input-activity-coalescing.ts",
+ "src/renderer/src/lib/active-agent-note-target.ts",
+ "src/renderer/src/components/sidebar/use-wsl-hosts-enabled.ts",
+ "src/renderer/src/components/host-picker/use-bounded-host-rows.ts",
+ "src/renderer/src/components/host-picker/HostListOverflowNote.tsx",
+ "src/shared/host-setting-overrides.ts",
+ "mobile/src/i18n/use-mobile-translation.ts",
+ "src/main/runtime/orca-runtime.ts",
+ "src/main/runtime/runtime-notifier-contract.ts",
+ "src/shared/workspace-session-state-types.ts",
+ "src/main/ipc/desktop-runtime-sender-lifecycle.ts",
+ "src/renderer/src/lib/e2e-config.ts"
],
"policyId": "local-tasks",
"ownedPaths": [
@@ -3225,7 +3278,8 @@
"mobile/src/local-tasks/**",
"docs/issue/本地任务管理/**",
"src/main/runtime/rpc/methods/local-task-link-methods.ts",
- "src/cli/local-tasks/**"
+ "src/cli/local-tasks/**",
+ "src/main/runtime/rpc/methods/local-task-dispatcher-methods.ts"
],
"requiredFiles": [
"src/main/local-tasks/local-tasks-service.ts",
@@ -3244,9 +3298,19 @@
"docs/issue/本地任务管理/journal.md",
"docs/issue/本地任务管理/requirements/本地任务管理.md",
"src/main/runtime/rpc/methods/local-task-link-methods.ts",
- "src/main/local-tasks/local-task-execution-service.ts"
+ "src/main/local-tasks/local-task-execution-service.ts",
+ "src/renderer/src/components/local-tasks/LocalTaskSidebarTree.tsx",
+ "src/renderer/src/components/local-tasks/LocalTaskSessionPanelSlot.tsx",
+ "src/main/local-tasks/local-task-turn-records.ts",
+ "src/main/local-tasks/local-task-proposal-results.ts",
+ "src/main/local-tasks/local-task-proposal-ownership.ts"
],
"seams": [
+ {
+ "file": "src/main/runtime/terminal/focus/focus-terminal.ts",
+ "mustContain": "terminalWindowProjectionSource()",
+ "why": "A task preview projects an already live terminal through the background notifier without creating a PTY or navigating (REQ-028)"
+ },
{
"file": "src/main/index.ts",
"mustContain": "startLocalTasksForMainProcess()",
@@ -3280,7 +3344,7 @@
{
"file": "src/main/runtime/orca-runtime-get-status.ts",
"mustContain": "capabilities.push(...localTasksRuntimeCapabilities())",
- "why": "Only a desktop that hosts a task library advertises local-tasks.v1; servers never do (REQ-017, REQ-018)"
+ "why": "Desktop, serve and orcad task hosts advertise local-tasks.v1/v2 while their own library is ready (REQ-017, REQ-018)"
},
{
"file": "src/main/runtime/runtime-rpc/fork-mobile-rpc-method-allowlist.ts",
@@ -3299,9 +3363,14 @@
},
{
"file": "config/scripts/verify-mobile-web-app-bundle.mjs",
- "mustContain": "['./h/[hostId]/local-tasks.tsx', 64],",
+ "mustContain": "['./h/[hostId]/local-tasks.tsx', 67],",
"why": "The route-prefix sweep is re-measured with the task page's route in the tree (REQ-017)"
},
+ {
+ "file": "config/scripts/mobile-web-app/mobile-web-app-drawer-render.test.mjs",
+ "mustContain": "locale: 'en-US'",
+ "why": "The phone drawer's English-label animation fixture fixes its locale instead of inheriting the host WebKit language (REQ-017, REQ-028)"
+ },
{
"file": "mobile/src/transport/rpc-client-server-subscription.ts",
"mustContain": "['localTasks.subscribeChanges', 'localTasks.unsubscribeChanges']",
@@ -3347,6 +3416,81 @@
"mustContain": "",
"why": "A host-list row shows a task icon after upstream's presence glyphs when its workspace belongs to a local task (REQ-016, REQ-017)"
},
+ {
+ "file": "mobile/src/tasks/mobile-smart-source-modes.ts",
+ "mustContain": "return translate('localTasks.sourceTab.label', 'Local')",
+ "why": "The phone's Create From lists Local right after Smart, as the desktop composer does (REQ-009, REQ-017)"
+ },
+ {
+ "file": "mobile/src/tasks/mobile-smart-source-modes.ts",
+ "mustContain": "return input.localTaskSourceOffered === true",
+ "why": "Local is offered only where the target repo's Orca advertises local tasks, has a project and shows Local; never for a folder or non-git repo (REQ-008, REQ-009, REQ-017)"
+ },
+ {
+ "file": "mobile/src/components/SmartSourceModeIcon.tsx",
+ "mustContain": "return ",
+ "why": "The Local tab carries the task icon the Task Source picker uses (REQ-017)"
+ },
+ {
+ "file": "mobile/src/components/SmartWorkspaceSourceDrawer.tsx",
+ "mustContain": "const localTasks = useLocalTaskCreateSourceTab(localTaskSource, effectiveMode, composer.name)",
+ "why": "The Local tab reads the target repo's task library and searches it by title or ref (REQ-009, REQ-017)"
+ },
+ {
+ "file": "mobile/src/components/SmartWorkspaceSourceDrawer.tsx",
+ "mustContain": "isLocalTaskCreateSourceRow(item) ? (",
+ "why": "Local rows lead with the title, the ref small and muted; a task with a workspace is greyed with the reason (REQ-009, REQ-011, REQ-017)"
+ },
+ {
+ "file": "mobile/src/components/SmartWorkspaceSourceField.tsx",
+ "mustContain": "",
+ "why": "The picked task shows as the source pill; clearing it clears the pick (REQ-009, REQ-017)"
+ },
+ {
+ "file": "mobile/src/components/NewWorktreeFormSheet.tsx",
+ "mustContain": "localTaskSource={props.localTaskSource}",
+ "why": "The create form hands the task pick to the source field (REQ-009, REQ-017)"
+ },
+ {
+ "file": "mobile/src/components/NewWorktreeModalDrawers.tsx",
+ "mustContain": "localTaskSource={props.localTaskSource}",
+ "why": "The sheet's drawers hand the task pick to the source drawer (REQ-009, REQ-017)"
+ },
+ {
+ "file": "mobile/src/components/NewWorktreeModal.tsx",
+ "mustContain": "const localTaskSource = useLocalTaskCreateSource({",
+ "why": "The new-workspace sheet owns the Local source for its target repo's library: the paired Orca's for its machine and relay SSH targets, a server's or orcad's for theirs (REQ-009, REQ-017)"
+ },
+ {
+ "file": "mobile/src/components/NewWorktreeModal.tsx",
+ "mustContain": "localTaskSourceOffered: localTaskSource.offered",
+ "why": "Create From's tab list includes Local while it is offered (REQ-009, REQ-017)"
+ },
+ {
+ "file": "mobile/src/components/NewWorktreeModal.tsx",
+ "mustContain": "localTaskCreate: localTaskSource.create,",
+ "why": "A workspace created for a picked task goes through the task's own create (REQ-009, REQ-017)"
+ },
+ {
+ "file": "mobile/src/components/use-new-workspace-create-submit.ts",
+ "mustContain": "const created = await args.localTaskCreate({",
+ "why": "Create runs the task's Start with the sheet's agent, setup choice and note: the link is written in the workspace's library and the task waits in the agent's input as a draft (REQ-009, REQ-017)"
+ },
+ {
+ "file": "mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts",
+ "mustContain": "localTaskCreate: null,",
+ "why": "The recorded submit scenario names the new optional member, so it keeps the upstream create path it pins"
+ },
+ {
+ "file": "mobile/src/mobile-web-shell/page-storage-keys.ts",
+ "mustContain": "'orca:localTasksView'",
+ "why": "The phone's task page, served as a web route, remembers each library's project, segment and layout in the app's store (REQ-017)"
+ },
+ {
+ "file": "mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts",
+ "mustContain": "storageKeys: ['orca:terminalSplitLayout', 'orca:localTasksView']",
+ "why": "The ready-frame contract verifies that task pages negotiate the existing local-task view preference key (REQ-017)"
+ },
{
"file": "src/renderer/src/app-shell/AppBackgroundServices.tsx",
"mustContain": "",
@@ -3527,6 +3671,11 @@
"mustContain": "'local-tasks',",
"why": "The task page deep-links to its settings section (REQ-001)"
},
+ {
+ "file": "src/renderer/src/i18n/en-runtime-required.json",
+ "mustContain": "sidebarCount_one",
+ "why": "Generated boot catalog includes task workflow copy; regenerate after changing translations"
+ },
{
"file": "src/renderer/src/i18n/locales/en.json",
"mustContain": "\"localTasks\": {",
@@ -3654,8 +3803,8 @@
},
{
"file": "src/renderer/src/hooks/composer-state/quick-creation-execution.ts",
- "mustContain": "draftPrompt: [trimmedNote, options.launchPrompt]",
- "why": "The task reaches the agent as an unsent draft, as an issue picked in Create From does (REQ-009)"
+ "mustContain": "options.launchPromptDelivery",
+ "why": "Task prompt delivery follows the library preference while reusing workspace creation (REQ-009)"
},
{
"file": "src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx",
@@ -3667,6 +3816,11 @@
"mustContain": "",
"why": "The task sidebar button's provider shortcuts include Local while it is shown (REQ-008)"
},
+ {
+ "file": "src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx",
+ "mustContain": "resolvedDefaultTaskSource !== 'github' || isLocalTaskSourceActive()",
+ "why": "Hovering Tasks does not warm GitHub work items while the task page opens on Local (REQ-008)"
+ },
{
"file": "src/renderer/src/components/settings/TasksPane.tsx",
"mustContain": "",
@@ -3811,6 +3965,136 @@
"file": "src/shared/plugins/languages/protected-copy-keys.json",
"mustContain": "\"localTasks.",
"why": "fork copy keys protected from plugin language packs"
+ },
+ {
+ "file": "src/renderer/src/app-shell/AppWorkspaceShell.tsx",
+ "mustContain": "LocalTaskSessionPanelSlot",
+ "why": "Task-first navigation and session preview reuse the existing workspace/session surfaces (REQ-026, REQ-028)"
+ },
+ {
+ "file": "src/renderer/src/components/use-terminal-workspace-store-bindings.ts",
+ "mustContain": "localTaskSessionPanelOpen",
+ "why": "Task-first navigation and session preview reuse the existing workspace/session surfaces (REQ-026, REQ-028)"
+ },
+ {
+ "file": "src/renderer/src/components/sidebar/index.tsx",
+ "mustContain": "LocalTaskSidebarTree",
+ "why": "Task-first navigation and session preview reuse the existing workspace/session surfaces (REQ-026, REQ-028)"
+ },
+ {
+ "file": "src/renderer/src/components/sidebar/SidebarHeader.tsx",
+ "mustContain": "LocalTaskSidebarModeButton",
+ "why": "Task-first navigation and session preview reuse the existing workspace/session surfaces (REQ-026, REQ-028)"
+ },
+ {
+ "file": "src/renderer/src/components/native-chat/NativeChatComposerPane.tsx",
+ "mustContain": "LocalTaskDispatcherPanelDock",
+ "why": "The dispatcher's proposals wait above its own chat input, so they are confirmed where it proposed them (REQ-030)"
+ },
+ {
+ "file": "mobile/src/session/use-mobile-session-foundation.ts",
+ "mustContain": "MobileSessionScopeOverride",
+ "why": "Task-first navigation and session preview reuse the existing workspace/session surfaces (REQ-026, REQ-028)"
+ },
+ {
+ "file": "mobile/src/session/use-mobile-session-controller.ts",
+ "mustContain": "useMobileSessionController(override",
+ "why": "Task-first navigation and session preview reuse the existing workspace/session surfaces (REQ-026, REQ-028)"
+ },
+ {
+ "file": "mobile/src/session/use-notification-pane-navigation.ts",
+ "mustContain": "paneKeyOverride",
+ "why": "Task-first navigation and session preview reuse the existing workspace/session surfaces (REQ-026, REQ-028)"
+ },
+ {
+ "file": "mobile/src/i18n/locales/en.json",
+ "mustContain": "\"localTasks\"",
+ "why": "Task-first mobile copy and optional explicit scope preserve the existing session route (REQ-017, REQ-028)"
+ },
+ {
+ "file": "mobile/src/i18n/locales/zh.json",
+ "mustContain": "\"localTasks\"",
+ "why": "Task-first mobile copy and optional explicit scope preserve the existing session route (REQ-017, REQ-028)"
+ },
+ {
+ "file": "mobile/src/session/use-notification-pane-navigation.test.tsx",
+ "mustContain": "paneKeyOverride",
+ "why": "Task-first mobile copy and optional explicit scope preserve the existing session route (REQ-017, REQ-028)"
+ },
+ {
+ "file": "config/scripts/mobile-web-app-file-attachment-grant.test.mjs",
+ "mustContain": "local-tasks",
+ "why": "The task chat drawer reuses session native operations; route grants and the closure census reflect the actual mounted content (REQ-017, REQ-028)"
+ },
+ {
+ "file": "config/scripts/mobile-web-app/mobile-web-app-page-grant-call-sites.test.mjs",
+ "mustContain": "local-tasks",
+ "why": "The task chat drawer reuses session native operations; route grants and the closure census reflect the actual mounted content (REQ-017, REQ-028)"
+ },
+ {
+ "file": "config/scripts/mobile-web-app/mobile-web-app-screencast-lane-grant.test.mjs",
+ "mustContain": "local-tasks",
+ "why": "The task chat drawer reuses session native operations; route grants and the closure census reflect the actual mounted content (REQ-017, REQ-028)"
+ },
+ {
+ "file": "config/scripts/mobile-web-app/mobile-web-app-external-navigation-grant.test.mjs",
+ "mustContain": "local-tasks",
+ "why": "The task chat drawer reuses session native operations; route grants and the closure census reflect the actual mounted content (REQ-017, REQ-028)"
+ },
+ {
+ "file": "mobile/src/components/bottom-drawer/bottom-drawer-styles.ts",
+ "mustContain": "overflow: Platform.OS === 'web' ? webClipOverflow : 'hidden'",
+ "why": "Task chat drawers must keep their full height after browser focus; CSS clip prevents the sheet itself from scrolling on web while native overflow stays unchanged"
+ },
+ {
+ "file": "src/renderer/src/lib/agent-message-send.ts",
+ "mustContain": "beforeTerminalSend",
+ "why": "总管唤醒在粘贴和提交前复核真实终端输入,保护未发送草稿(REQ-031)"
+ },
+ {
+ "file": "src/renderer/src/lib/active-agent-note-send.ts",
+ "mustContain": "beforeTerminalSend",
+ "why": "总管唤醒在粘贴和提交前复核真实终端输入,保护未发送草稿(REQ-031)"
+ },
+ {
+ "file": "src/renderer/src/lib/active-agent-note-send-delivery.ts",
+ "mustContain": "beforeTerminalSend",
+ "why": "总管唤醒在粘贴和提交前复核真实终端输入,保护未发送草稿(REQ-031)"
+ },
+ {
+ "file": "src/shared/execution-host-registry.ts",
+ "mustContain": "buildExecutionHostRegistryForAllHosts",
+ "why": "任务派活枚举全部所属主机,复用注册表且不由当前焦点决定执行目标(REQ-029)"
+ },
+ {
+ "file": "src/renderer/src/components/sidebar/sidebar-host-options.ts",
+ "mustContain": "buildSidebarHostOptionsForAllHosts",
+ "why": "任务派活复用主机健康和名称投影,不受当前焦点服务器影响(REQ-029)"
+ },
+ {
+ "file": "src/main/ipc/runtime.ts",
+ "mustContain": "connectionSignal: callScope.connectionSignal,",
+ "why": "REQ-030: a queued proposal claim retains its caller document lifetime after admission waits"
+ },
+ {
+ "file": "src/main/ipc/desktop-runtime-sender-lifecycle.ts",
+ "mustContain": "callScopeFor(sender: RuntimeSender)",
+ "why": "REQ-030: each desktop document has a retirement signal; a late claim cannot survive a retired window"
+ },
+ {
+ "file": "src/main/runtime/rpc/core.ts",
+ "mustContain": "connectionSignal?: AbortSignal",
+ "why": "REQ-030: claim handlers inspect trusted transport lifetime at the final synchronous registration boundary"
+ },
+ {
+ "file": "src/main/runtime/rpc/dispatcher-stream-options.ts",
+ "mustContain": "connectionSignal: options?.connectionSignal,",
+ "why": "REQ-030: pass the existing connection lifetime to proposal handlers without replacing per-request cancellation"
+ },
+ {
+ "file": "src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts",
+ "mustContain": "connectionSignal: abortRegistration?.signal,",
+ "why": "REQ-030: paired desktop socket retirement invalidates a delayed claim before ownership registration"
}
],
"tests": [
@@ -3819,6 +4103,7 @@
"mobile/src/local-tasks/local-task-list-projection.test.ts",
"mobile/src/local-tasks/local-task-operation-ids.test.ts",
"mobile/src/local-tasks/local-tasks-access.test.ts",
+ "mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts",
"mobile/src/local-tasks/local-tasks-change-feed.test.ts",
"mobile/src/local-tasks/local-tasks-operations.test.ts",
"mobile/src/local-tasks/local-tasks-screen-state.test.ts",
@@ -3876,6 +4161,8 @@
"mobile/src/local-tasks/local-task-agent-options.test.ts",
"mobile/src/local-tasks/local-task-source-option.test.tsx",
"mobile/src/local-tasks/local-task-host-rows.test.tsx",
+ "mobile/src/local-tasks/local-task-create-source-start.test.ts",
+ "mobile/src/local-tasks/use-local-task-create-source.test.tsx",
"src/renderer/src/local-tasks/local-task-background-services.test.tsx",
"src/renderer/src/lib/worktree-creation-structured-session.test.ts",
"src/renderer/src/components/local-tasks/LocalTaskPage.execution.test.tsx",
@@ -3890,12 +4177,46 @@
"src/renderer/src/local-tasks/local-task-palette.test.ts",
"src/renderer/src/local-tasks/local-task-cleanup-ticket-source.test.ts",
"src/main/local-tasks/local-task-pull-request-context.test.ts",
- "src/cli/local-tasks/local-task-cli.test.ts"
+ "src/cli/local-tasks/local-task-cli.test.ts",
+ "mobile/src/local-tasks/local-task-session-drawer-style.test.ts",
+ "src/shared/local-tasks/local-task-dispatcher-rpc-params.test.ts",
+ "src/shared/local-tasks/local-tasks-wsl-move-dispositions.test.ts",
+ "src/renderer/src/local-tasks/local-task-proposal-session-items.test.ts",
+ "src/renderer/src/lib/active-agent-note-send-delivery-guard.test.ts",
+ "src/renderer/src/components/sidebar/sidebar-host-options.test.ts",
+ "src/shared/execution-host-registry.test.ts",
+ "src/renderer/src/components/local-tasks/LocalTaskSessionPanelSlot.test.tsx",
+ "src/main/local-tasks/local-task-dispatcher.test.ts",
+ "src/renderer/src/components/local-tasks/LocalTaskProposalCard.test.tsx",
+ "src/renderer/src/local-tasks/local-task-dispatcher-proposals.test.ts",
+ "src/renderer/src/local-tasks/local-task-proposal-execution.test.ts",
+ "src/main/ipc/desktop-runtime-sender-lifecycle.test.ts",
+ "src/renderer/src/local-tasks/local-task-needs-you-band.test.ts",
+ "src/renderer/src/local-tasks/local-task-progress-sections.test.ts",
+ "src/renderer/src/local-tasks/local-task-writebacks.test.ts",
+ "src/renderer/src/local-tasks/local-task-sidebar-tree.test.ts",
+ "src/renderer/src/local-tasks/local-task-create-workspace-choices.test.ts",
+ "src/renderer/src/local-tasks/local-task-proposal-progress.test.ts",
+ "src/renderer/src/local-tasks/local-task-proposal-item-facts.test.ts",
+ "src/renderer/src/components/local-tasks/LocalTaskCreateDialog.test.tsx",
+ "src/renderer/src/components/local-tasks/LocalTaskSessionChip.test.tsx",
+ "src/renderer/src/components/local-tasks/LocalTaskSidebarTree.test.tsx",
+ "src/renderer/src/components/local-tasks/LocalTaskSessionList.test.tsx",
+ "src/renderer/src/components/local-tasks/settings/LocalTaskProjectRules.test.tsx",
+ "src/shared/local-tasks/local-task-create-plan.test.ts",
+ "mobile/src/local-tasks/local-task-card-facts.test.ts",
+ "mobile/src/local-tasks/local-task-create-choices.test.ts",
+ "mobile/src/local-tasks/local-task-create-kickoff.test.ts",
+ "mobile/src/local-tasks/local-tasks-copy.test.ts",
+ "mobile/src/local-tasks/local-tasks-library-header.test.tsx",
+ "src/renderer/src/local-tasks/local-task-proposal-targets.test.ts",
+ "src/renderer/src/local-tasks/local-task-dispatcher-ask.test.ts"
],
"checks": [
- "pnpm exec vitest run --config config/vitest.config.ts src/main/local-tasks src/shared/local-tasks src/main/runtime/rpc/methods/local-tasks.test.ts src/shared/host-operation-ownership.test.ts src/main/runtime/runtime-rpc/fork-mobile-rpc-method-allowlist.test.ts src/cli/local-tasks",
- "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/local-tasks src/renderer/src/components/local-tasks src/renderer/src/components/task-page src/renderer/src/components/cmd-j src/renderer/src/lib/worktree-creation-structured-session.test.ts",
- "pnpm --dir mobile exec vitest run src/local-tasks src/home/MobileHomeTasksAndAutomations.test.tsx"
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/local-tasks src/shared/local-tasks src/main/runtime/rpc/methods/local-tasks.test.ts src/shared/host-operation-ownership.test.ts src/main/runtime/runtime-rpc/fork-mobile-rpc-method-allowlist.test.ts src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts src/cli/local-tasks src/main/ipc/desktop-runtime-sender-lifecycle.test.ts",
+ "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/local-tasks src/renderer/src/components/local-tasks src/renderer/src/components/task-page src/renderer/src/components/cmd-j src/renderer/src/lib/worktree-creation-structured-session.test.ts src/renderer/src/lib/agent-message-send.test.ts src/renderer/src/lib/active-agent-note-send-delivery-guard.test.ts",
+ "pnpm --dir mobile exec vitest run src/local-tasks src/home/MobileHomeTasksAndAutomations.test.tsx src/session/use-notification-pane-navigation.test.tsx src/components/bottom-drawer src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts config/scripts/mobile-web-app-file-attachment-grant.test.mjs config/scripts/mobile-web-app/mobile-web-app-session-dictation-capture.test.mjs config/scripts/mobile-web-app/mobile-web-app-page-grant-call-sites.test.mjs config/scripts/mobile-web-app/mobile-web-app-screencast-lane-grant.test.mjs config/scripts/mobile-web-app/mobile-web-app-external-navigation-grant.test.mjs"
]
},
{
diff --git a/config/scripts/mobile-web-app-file-attachment-grant.test.mjs b/config/scripts/mobile-web-app-file-attachment-grant.test.mjs
index 3df9529dbded..0685d23ce0bc 100644
--- a/config/scripts/mobile-web-app-file-attachment-grant.test.mjs
+++ b/config/scripts/mobile-web-app-file-attachment-grant.test.mjs
@@ -19,6 +19,8 @@ const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
const SESSION = '/h/[hostId]/session/[worktreeId]'
+const LOCAL_TASKS = '/h/[hostId]/local-tasks'
+const CHAT_ROUTES = [LOCAL_TASKS, SESSION]
const SEAM = 'src/file-attachment-upload/file-attachment-picker.web.ts'
const NATIVE = 'src/file-attachment-upload/file-attachment-picker.ts'
const GRANT_MODULE = 'src/file-attachment-upload/bridge-file-verbs.ts'
@@ -49,7 +51,7 @@ function routesDeclaring(token) {
describe('the grant token this census is written against', () => {
it('is the one the shell declares', () => {
expect(filePickGrantToken()).toBe('native.file.pick')
- expect(routesDeclaring(filePickGrantToken())).toEqual([SESSION])
+ expect(routesDeclaring(filePickGrantToken())).toEqual(CHAT_ROUTES)
})
})
@@ -61,22 +63,24 @@ describeClosure(
async (route, mod) => {
const closure = await routeClosure(mod)
const reachesPicker = closure.local.includes(SEAM)
- expect(reachesPicker).toBe(route === SESSION)
+ expect(reachesPicker).toBe(CHAT_ROUTES.includes(route))
expect(routesDeclaring(filePickGrantToken()).includes(route)).toBe(reachesPicker)
}
)
it('declares it on the optional lane, so a shell without it keeps the screen on the page', () => {
- const session = MOBILE_WEB_PAGE_ROUTES.find((route) => route.pathname === SESSION)
- if (!session) {
- throw new Error('the manifest lost the session route this census is written against')
+ for (const pathname of CHAT_ROUTES) {
+ const route = MOBILE_WEB_PAGE_ROUTES.find((entry) => entry.pathname === pathname)
+ if (!route) {
+ throw new Error(`the manifest lost ${pathname}`)
+ }
+ expect(route.optionalGrants).toContain(filePickGrantToken())
+ expect(route.grants).not.toContain(filePickGrantToken())
+ // Its handles are read and released through the media verbs, which the route requires.
+ expect(route.grants).toEqual(
+ expect.arrayContaining(['native.media.read', 'native.media.release'])
+ )
}
- expect(session.optionalGrants).toContain(filePickGrantToken())
- expect(session.grants).not.toContain(filePickGrantToken())
- // Its handles are read and released through the media verbs, which the route requires.
- expect(session.grants).toEqual(
- expect.arrayContaining(['native.media.read', 'native.media.release'])
- )
})
it('reaches the picker through its web sibling, never the native document picker', async () => {
diff --git a/config/scripts/mobile-web-app/mobile-web-app-drawer-render.test.mjs b/config/scripts/mobile-web-app/mobile-web-app-drawer-render.test.mjs
index 03c1ca9268f5..ba97b237a3ac 100644
--- a/config/scripts/mobile-web-app/mobile-web-app-drawer-render.test.mjs
+++ b/config/scripts/mobile-web-app/mobile-web-app-drawer-render.test.mjs
@@ -134,6 +134,7 @@ describeDrawer('the bottom drawer on the page', () => {
// Reanimated latches media query at import; emulateMedia after goto cannot undo it.
const page = await browser.newPage({
viewport: VIEWPORT,
+ locale: 'en-US',
reducedMotion: 'no-preference'
})
const errors = []
@@ -237,7 +238,11 @@ describeDrawer('the device Back key reaching a sheet on the page', () => {
it('closes the sheet instead of leaving the screen', async () => {
const browser = await ENGINES[0].launch()
try {
- const page = await browser.newPage({ viewport: VIEWPORT, reducedMotion: 'no-preference' })
+ const page = await browser.newPage({
+ viewport: VIEWPORT,
+ locale: 'en-US',
+ reducedMotion: 'no-preference'
+ })
const errors = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
await page.addInitScript(installShellDouble, {
diff --git a/config/scripts/mobile-web-app/mobile-web-app-external-navigation-grant.test.mjs b/config/scripts/mobile-web-app/mobile-web-app-external-navigation-grant.test.mjs
index d07716c68fe3..570aba631e4d 100644
--- a/config/scripts/mobile-web-app/mobile-web-app-external-navigation-grant.test.mjs
+++ b/config/scripts/mobile-web-app/mobile-web-app-external-navigation-grant.test.mjs
@@ -61,23 +61,23 @@ describeClosure(
}
}
// Require nonempty route census; file-preview route has no HTML-preview closure and intentionally stays absent.
- expect(asking).toEqual(['/h/[hostId]/session/[worktreeId]'])
+ expect(asking).toEqual(['/h/[hostId]/local-tasks', '/h/[hostId]/session/[worktreeId]'])
expect([...routesDeclaring(externalNavigationGrantToken())].sort()).toEqual(
[...asking].sort()
)
})
it('declares it on the optional lane, so a shell without it keeps the screen on the page', () => {
- const session = MOBILE_WEB_PAGE_ROUTES.find(
- (route) => route.pathname === '/h/[hostId]/session/[worktreeId]'
- )
- if (!session) {
- throw new Error('the manifest lost the session route this census is written against')
- }
const token = externalNavigationGrantToken()
- expect(session.optionalGrants).toContain(token)
- // Required grant would force entire session native on older shells; keep this optional.
- expect(session.grants).not.toContain(token)
+ for (const pathname of ['/h/[hostId]/local-tasks', '/h/[hostId]/session/[worktreeId]']) {
+ const route = MOBILE_WEB_PAGE_ROUTES.find((entry) => entry.pathname === pathname)
+ if (!route) {
+ throw new Error(`the manifest lost ${pathname}`)
+ }
+ expect(route.optionalGrants).toContain(token)
+ // Required grant would force the whole page native on older shells.
+ expect(route.grants).not.toContain(token)
+ }
})
it('reaches the seam through its web sibling, and the caller is the preview', async () => {
diff --git a/config/scripts/mobile-web-app/mobile-web-app-page-grant-call-sites.test.mjs b/config/scripts/mobile-web-app/mobile-web-app-page-grant-call-sites.test.mjs
index b5cd20da103f..d828dcd07330 100644
--- a/config/scripts/mobile-web-app/mobile-web-app-page-grant-call-sites.test.mjs
+++ b/config/scripts/mobile-web-app/mobile-web-app-page-grant-call-sites.test.mjs
@@ -24,6 +24,7 @@ const mobileDir = fileURLToPath(new URL('../../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
const SESSION = '/h/[hostId]/session/[worktreeId]'
+const LOCAL_TASKS = '/h/[hostId]/local-tasks'
// Memoize closures: each of eight repeated walks builds a bundle.
const closures = new Map()
@@ -201,7 +202,7 @@ describeClosure(
}
})
- it('finds the clipboard reader and the media picker on the session route alone', async () => {
+ it('finds the clipboard reader and media picker on both routes mounting chat', async () => {
const readerRow = PAGE_GRANT_CALL_SITES[4]
const mediaRow = PAGE_GRANT_CALL_SITES[5]
const reaching = { reader: [], media: [] }
@@ -214,8 +215,8 @@ describeClosure(
reaching.media.push(pathname)
}
}
- expect(reaching.reader).toEqual([SESSION])
- expect(reaching.media).toEqual([SESSION])
+ expect(reaching.reader).toEqual([LOCAL_TASKS, SESSION])
+ expect(reaching.media).toEqual([LOCAL_TASKS, SESSION])
})
},
240_000
diff --git a/config/scripts/mobile-web-app/mobile-web-app-screencast-lane-grant.test.mjs b/config/scripts/mobile-web-app/mobile-web-app-screencast-lane-grant.test.mjs
index fcc0a67e577d..2456c2afebe4 100644
--- a/config/scripts/mobile-web-app/mobile-web-app-screencast-lane-grant.test.mjs
+++ b/config/scripts/mobile-web-app/mobile-web-app-screencast-lane-grant.test.mjs
@@ -1,6 +1,6 @@
// Census every route mounting browser pane against screencastBinary grant.
// Web seam reads init.grants.native before wantsBinary; missing grant leaves live pane subscribed to a stream that never delivers frames.
-// Pane has no own route; session route supplies its manifest grant.
+// Pane has no own route; session and local-tasks drawer routes supply its manifest grant.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -53,7 +53,7 @@ describeClosure(
}
}
// Require nonempty route census; unresolved walk would also report no missing grants.
- expect(asking).toEqual(['/h/[hostId]/session/[worktreeId]'])
+ expect(asking).toEqual(['/h/[hostId]/local-tasks', '/h/[hostId]/session/[worktreeId]'])
const declared = MOBILE_WEB_PAGE_ROUTES.filter((route) =>
route.grants.includes(screencastGrantToken())
).map((route) => route.pathname)
diff --git a/config/scripts/mobile-web-page-routes.mjs b/config/scripts/mobile-web-page-routes.mjs
index 650de0315fb2..5243db430c30 100644
--- a/config/scripts/mobile-web-page-routes.mjs
+++ b/config/scripts/mobile-web-page-routes.mjs
@@ -28,10 +28,32 @@ export const MOBILE_WEB_PAGE_ROUTES = [
pathname: '/h/[hostId]/accounts',
grants: ['navigate', 'storage', 'externalLink', 'haptics']
},
- // Fork local-tasks: the accounts route's four, for the same layout-wide reasons.
+ // Fork local-tasks embeds the full session chat controller in a drawer. Its closure reaches the
+ // same native media, audio, clipboard and pane capabilities as the session route.
{
pathname: '/h/[hostId]/local-tasks',
- grants: ['navigate', 'storage', 'externalLink', 'haptics']
+ grants: [
+ 'navigate',
+ 'storage',
+ 'externalLink',
+ 'haptics',
+ 'screencastBinary',
+ 'native.clipboard.write',
+ 'native.clipboard.read',
+ 'native.media.pick',
+ 'native.media.read',
+ 'native.media.release',
+ 'native.audio.start',
+ 'native.audio.read',
+ 'native.audio.stop'
+ ],
+ optionalGrants: [
+ 'externalNavigation',
+ 'native.media.name',
+ 'native.pdf.view',
+ 'terminalKeyRail',
+ 'native.file.pick'
+ ]
},
// Create Workspace overlay needs navigate to close/open session; shared form/layout supply other grants.
{
diff --git a/config/scripts/verify-mobile-web-app-bundle.mjs b/config/scripts/verify-mobile-web-app-bundle.mjs
index 0c971cd46652..c8743cd7ee89 100644
--- a/config/scripts/verify-mobile-web-app-bundle.mjs
+++ b/config/scripts/verify-mobile-web-app-bundle.mjs
@@ -66,7 +66,7 @@ export function mobileWebAppBundleBudgetBytes(manifest) {
// Measure sorted route prefixes with routes.slice(0,n), excluding locale catalogs: distinct importer sets determine chunks, not route count.
// Re-measure table when routes change; it is envelope’s only input, and pins must fail rather than permit arbitrary ceiling bumps.
-// Fork sweep 2026-11-02: local-tasks adds nine scripts after index and splits later routes further; the 18-route tree emits 142, 120 without it.
+// Fork sweep 2026-11-09: local-tasks adds twelve scripts after index; the 18-route tree emits 139 non-catalog scripts.
export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [
['./_layout.tsx', 4],
['./h/[hostId]/[...page].tsx', 8],
@@ -78,14 +78,14 @@ export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [
['./h/[hostId]/files/preview/[worktreeId].tsx', 44],
['./h/[hostId]/history/[worktreeId].tsx', 47],
['./h/[hostId]/index.tsx', 55],
- ['./h/[hostId]/local-tasks.tsx', 64],
- ['./h/[hostId]/new-workspace.tsx', 78],
- ['./h/[hostId]/pr/[worktreeId].tsx', 79],
- ['./h/[hostId]/review/[worktreeId].tsx', 96],
- ['./h/[hostId]/session/[worktreeId].tsx', 113],
- ['./h/[hostId]/source-control/[worktreeId].tsx', 123],
- ['./h/[hostId]/tasks.tsx', 138],
- ['./h/_layout.tsx', 142]
+ ['./h/[hostId]/local-tasks.tsx', 67],
+ ['./h/[hostId]/new-workspace.tsx', 79],
+ ['./h/[hostId]/pr/[worktreeId].tsx', 80],
+ ['./h/[hostId]/review/[worktreeId].tsx', 94],
+ ['./h/[hostId]/session/[worktreeId].tsx', 105],
+ ['./h/[hostId]/source-control/[worktreeId].tsx', 115],
+ ['./h/[hostId]/tasks.tsx', 135],
+ ['./h/_layout.tsx', 139]
]
const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts)
diff --git a/docs/issue/README.md b/docs/issue/README.md
index 968ec78290a1..401f76e854c5 100644
--- a/docs/issue/README.md
+++ b/docs/issue/README.md
@@ -7,12 +7,12 @@
| [工作区恢复占位收敛](工作区恢复占位收敛/journal.md) | 历史选择不复活工作区,按在线主机目录收敛恢复占位并保留可恢复内容 | testing | [requirement](工作区恢复占位收敛/requirements/requirement.md) | [overview](工作区恢复占位收敛/solutions/overview.md) | [恢复与目录](工作区恢复占位收敛/tests/cases/恢复与目录.md) | 2026-10-30 |
| ['手机打开工作区终端身份恢复'](手机打开工作区终端身份恢复/journal.md) | 手机激活与自动恢复保留宿主原终端身份,未证实退出时不创建替代 shell,并收敛重复标签 | testing | [手机打开工作区终端身份恢复](手机打开工作区终端身份恢复/requirements/手机打开工作区终端身份恢复.md) | [手机工作区原终端身份恢复方案](手机打开工作区终端身份恢复/solutions/手机工作区原终端身份恢复方案.md) | [手机终端原身份恢复](手机打开工作区终端身份恢复/tests/cases/手机终端原身份恢复.md) | 2026-11-09 |
| [终端聊天视图恢复](终端聊天视图恢复/journal.md) | 恢复已有终端标签的聊天视图切换,保留同一终端与会话绑定 | testing | [终端聊天视图恢复](终端聊天视图恢复/requirements/终端聊天视图恢复.md) | - | [切换与绑定](终端聊天视图恢复/tests/cases/切换与绑定.md) | 2026-10-23 |
-| [集成分支自动出包](集成分支自动出包/journal.md) | 同源发布 fork 内测包,并提供桌面更新清单和 Android 应用内更新 | testing | [集成分支自动出包](集成分支自动出包/requirements/集成分支自动出包.md) | [2026-10-21-orcad上游同步](集成分支自动出包/solutions/2026-10-21-orcad上游同步.md)、[Linux服务自动更新方案](集成分支自动出包/solutions/Linux服务自动更新方案.md)、[Linux桌面更新入口方案](集成分支自动出包/solutions/Linux桌面更新入口方案.md)、[macOS自动更新签名修复方案](集成分支自动出包/solutions/macOS自动更新签名修复方案.md)、[发布页分层与可读发布说明方案](集成分支自动出包/solutions/发布页分层与可读发布说明方案.md) | [Linux服务自动更新](集成分支自动出包/tests/cases/Linux服务自动更新.md)、[Linux桌面更新入口](集成分支自动出包/tests/cases/Linux桌面更新入口.md)、[自动出包](集成分支自动出包/tests/cases/自动出包.md) | 2026-11-05 |
+| [集成分支自动出包](集成分支自动出包/journal.md) | 同源发布 fork 内测包,并提供桌面更新清单和 Android 应用内更新 | testing | [集成分支自动出包](集成分支自动出包/requirements/集成分支自动出包.md) | [2026-10-21-orcad上游同步](集成分支自动出包/solutions/2026-10-21-orcad上游同步.md)、[Linux服务自动更新方案](集成分支自动出包/solutions/Linux服务自动更新方案.md)、[Linux桌面更新入口方案](集成分支自动出包/solutions/Linux桌面更新入口方案.md)、[macOS自动更新签名修复方案](集成分支自动出包/solutions/macOS自动更新签名修复方案.md)、[发布页分层与可读发布说明方案](集成分支自动出包/solutions/发布页分层与可读发布说明方案.md) | [Linux服务自动更新](集成分支自动出包/tests/cases/Linux服务自动更新.md)、[Linux桌面更新入口](集成分支自动出包/tests/cases/Linux桌面更新入口.md)、[自动出包](集成分支自动出包/tests/cases/自动出包.md) | 2026-11-11 |
| [侧栏项目合并](侧栏项目合并/journal.md) | 复用原仓库归并和显示名称,将同名项目区块合并展示;改名拆分,保留业务归属 | testing | [requirement](侧栏项目合并/requirements/requirement.md) | [overview](侧栏项目合并/solutions/overview.md) | [同显示名称项目归并](侧栏项目合并/tests/cases/同显示名称项目归并.md) | 2026-10-23 |
| [文档预览大小](文档预览大小/journal.md) | 扩大文档文本预览上限,不扩大普通文件读取和 paired RPC 预算 | testing | [文档预览大小](文档预览大小/requirements/文档预览大小.md) | - | [预览大小边界](文档预览大小/tests/cases/预览大小边界.md) | 2026-11-03 |
| [Goal 目标模式](Goal目标模式/journal.md) | 持久目标、既有会话续跑、独立验收与目标管理 UI | testing | [Goal目标模式](Goal目标模式/requirements/Goal目标模式.md) | [Goal守卫监工与唤醒兜底修订方案](Goal目标模式/solutions/Goal守卫监工与唤醒兜底修订方案.md)、[Goal守卫监工与唤醒兜底修订方案.review](Goal目标模式/solutions/Goal守卫监工与唤醒兜底修订方案.review.md)、[Goal目标模式技术说明](Goal目标模式/solutions/Goal目标模式技术说明.md)、[Goal目标管理与交互闭环方案](Goal目标模式/solutions/Goal目标管理与交互闭环方案.md) | [Goal功能测试](Goal目标模式/tests/cases/Goal功能测试.md) | 2026-11-03 |
| [会话命名与身份保护](会话命名与身份保护/journal.md) | 同一会话在各入口使用一致名称,后台调用不顶替主身份 | implementing | [会话命名与身份保护](会话命名与身份保护/requirements/会话命名与身份保护.md) | [Claude在位会话活跃窗口保护方案](会话命名与身份保护/solutions/Claude在位会话活跃窗口保护方案.md)、[Provider优先的会话命名统一方案](会话命名与身份保护/solutions/Provider优先的会话命名统一方案.md)、[会话命名统一方案](会话命名与身份保护/solutions/会话命名统一方案.md) | [Claude活跃窗口保护测试](会话命名与身份保护/tests/cases/Claude活跃窗口保护测试.md)、[Codex嵌套调用保护测试](会话命名与身份保护/tests/cases/Codex嵌套调用保护测试.md)、[Provider优先命名验收](会话命名与身份保护/tests/cases/Provider优先命名验收.md)、[顶部名称投影测试](会话命名与身份保护/tests/cases/顶部名称投影测试.md) | 2026-11-03 |
-| [自托管产物后端](自托管产物后端/journal.md) | 文件在哪台电脑就由那台电脑的 Orca 在局域网提供链接,本机文件与 SSH 主机上的文件走同一套规则 | testing | [自托管产物后端](自托管产物后端/requirements/自托管产物后端.md) | [局域网Artifact分享服务重构方案](自托管产物后端/solutions/局域网Artifact分享服务重构方案.md)、[自托管产物后端技术说明](自托管产物后端/solutions/自托管产物后端技术说明.md) | [自托管产物后端功能测试](自托管产物后端/tests/cases/自托管产物后端功能测试.md) | 2026-11-06 |
+| [自托管产物后端](自托管产物后端/journal.md) | 文件在哪台电脑就由那台电脑的 Orca 在局域网提供链接,本机文件与 SSH 主机上的文件走同一套规则 | testing | [自托管产物后端](自托管产物后端/requirements/自托管产物后端.md) | [局域网Artifact分享服务重构方案](自托管产物后端/solutions/局域网Artifact分享服务重构方案.md)、[自托管产物后端技术说明](自托管产物后端/solutions/自托管产物后端技术说明.md) | [自托管产物后端功能测试](自托管产物后端/tests/cases/自托管产物后端功能测试.md) | 2026-11-12 |
| ['远程工作区绝对路径打开'](远程工作区绝对路径打开/journal.md) | 弹窗输入绝对路径时本地开本地、SSH 开远端;不按路径位置拦截,归属未解析才不可用 | implementing | [远程工作区绝对路径打开](远程工作区绝对路径打开/requirements/远程工作区绝对路径打开.md) | [标签栏新建Tab弹窗远程工作区打开绝对路径方案](远程工作区绝对路径打开/solutions/标签栏新建Tab弹窗远程工作区打开绝对路径方案.md) | [远程工作区绝对路径打开功能测试](远程工作区绝对路径打开/tests/cases/远程工作区绝对路径打开功能测试.md) | 2026-10-21 |
| ['HTML预览在当前面板打开'](HTML预览在当前面板打开/journal.md) | 预览 tab 建在发起操作的 pane 内并切过去,本地与远程工作区落位一致 | testing | [HTML预览在当前面板打开](HTML预览在当前面板打开/requirements/HTML预览在当前面板打开.md) | - | [HTML预览在当前面板打开功能测试](HTML预览在当前面板打开/tests/cases/HTML预览在当前面板打开功能测试.md) | 2026-10-21 |
| ['远程大文件编辑器读取'](远程大文件编辑器读取/journal.md) | files.read 报 truncated 时用已有的 files.readChunk 读全文,不新增 wire 面 | testing | [远程大文件编辑器读取](远程大文件编辑器读取/requirements/远程大文件编辑器读取.md) | - | [远程大文件编辑器读取功能测试](远程大文件编辑器读取/tests/cases/远程大文件编辑器读取功能测试.md) | 2026-10-21 |
@@ -24,7 +24,7 @@
| [终端创建链路诊断](终端创建链路诊断/journal.md) | 记录恢复入口、RPC 调用方、旧终端候选及实际 PTY 创建结果,不改变会话行为 | implementing | [requirement](终端创建链路诊断/requirements/requirement.md) | - | [launch-diagnostics](终端创建链路诊断/tests/cases/launch-diagnostics.md) | 2026-11-03 |
| ['终端 ID 复制主机路由'](终端ID复制主机路由/journal.md) | 复制终端 ID 时查询所属本地、SSH 或配对主机,失联或归属不明时不回退本机 | done | [终端ID复制主机路由](终端ID复制主机路由/requirements/终端ID复制主机路由.md) | [主机路由](终端ID复制主机路由/solutions/主机路由.md) | [主机路由](终端ID复制主机路由/tests/cases/主机路由.md) | 2026-10-21 |
| ['终端头部切到聊天视图'](终端头部切到聊天视图/journal.md) | 终端窗格头部新增「切到聊天视图」,agent 运行中也能就地切换;原按钮中文改名「在新聊天中接管」,两个功能不再同名 | testing | [requirement](终端头部切到聊天视图/requirements/requirement.md) | [overview](终端头部切到聊天视图/solutions/overview.md) | [终端头部切到聊天视图测试](终端头部切到聊天视图/tests/cases/终端头部切到聊天视图测试.md) | 2026-10-23 |
-| [本地任务管理](本地任务管理/journal.md) | 在 Orca 里记录、拆分、跟踪任务;从任务一键开工作区和会话,任务上看到会话进展,会话需要人时进入「需要你」 | implementing | [本地任务管理](本地任务管理/requirements/本地任务管理.md) | [本地任务管理技术方案](本地任务管理/solutions/本地任务管理技术方案.md) | [D-008任务库主机资源化测试](本地任务管理/tests/cases/D-008任务库主机资源化测试.md)、[本地任务管理功能测试](本地任务管理/tests/cases/本地任务管理功能测试.md) | 2026-11-03 |
+| [本地任务管理](本地任务管理/journal.md) | 管理各主机的任务、会话与进展,支持直接派活、人工验收和需确认的总管提案 | implementing | [本地任务管理](本地任务管理/requirements/本地任务管理.md) | [任务为主的工作流技术方案](本地任务管理/solutions/任务为主的工作流技术方案.md)、[本地任务管理技术方案](本地任务管理/solutions/本地任务管理技术方案.md) | [D-008任务库主机资源化测试](本地任务管理/tests/cases/D-008任务库主机资源化测试.md)、[任务为主的工作流测试](本地任务管理/tests/cases/任务为主的工作流测试.md)、[本地任务管理功能测试](本地任务管理/tests/cases/本地任务管理功能测试.md) | 2026-11-10 |
| ['Gitea 主机识别校验'](Gitea主机识别校验/journal.md) | 不认识的自建代码平台先确认是不是 Gitea,明确不是时不再冒充 Gitea 发请求,真 Gitea 不受影响 | done | [Gitea主机识别校验](Gitea主机识别校验/requirements/Gitea主机识别校验.md) | [主机识别](Gitea主机识别校验/solutions/主机识别.md) | [主机识别](Gitea主机识别校验/tests/cases/主机识别.md) | 2026-11-03 |
| ['插件读取工作区远程仓库'](插件读取工作区远程仓库/journal.md) | 插件能拿到聚焦工作区(本地与 SSH)及工作区列表各行所属仓库的远程地址(去掉凭据),不必在主机上执行命令 | done | [插件读取工作区远程仓库](插件读取工作区远程仓库/requirements/插件读取工作区远程仓库.md) | [远程仓库字段](插件读取工作区远程仓库/solutions/远程仓库字段.md) | [远程仓库字段](插件读取工作区远程仓库/tests/cases/远程仓库字段.md) | 2026-11-04 |
| [Markdown评论失效锚点恢复](Markdown评论失效锚点恢复/journal.md) | 原文变更后保留可查看、编辑和删除的评论卡片,恢复可匹配的引文定位 | done | [requirement](Markdown评论失效锚点恢复/requirements/requirement.md) | - | [锚点与评论访问](Markdown评论失效锚点恢复/tests/cases/锚点与评论访问.md) | 2026-11-06 |
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/README.md" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/README.md"
new file mode 100644
index 000000000000..7bb92205c492
--- /dev/null
+++ "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/README.md"
@@ -0,0 +1,84 @@
+---
+title: 以任务为主的交互原型
+status: completed
+---
+
+# 「以任务为主」交互原型
+
+这是[产品方案](../任务为主的工作流产品方案.md)的可交互原型,桌面和手机各一份。用浏览器直接打开 `index.html`(`file://` 就可以),不需要构建,也不联网。
+
+- `index.html`:入口页,列出每一屏的截图和说明,点截图进入那一屏。
+- `desktop.html`:桌面原型(1440×900 的 Orca 窗口)。地址里的 `#d1`、`#d13` 等是屏幕编号。
+- `mobile.html`:手机原型(390×844 的 iPhone 画框)。真实手机 App 的文案是英文;原型为了方便评审,用的是简体中文。
+- `screenshots/`:每一屏的截图,文件名就是屏幕编号。
+- `assets/`:字体。
+
+样式和脚本都内联在页面里,所以仓库的 lint 和格式检查只会看到 HTML。原型是评审材料,不是产品代码:不进构建、不跑测试,也不被任何代码引用。
+
+## 屏幕
+
+| 编号 | 内容 |
+| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| d1 | 任务首页:派活输入框、需要你、按状态分组的任务卡(`d1-board` 看板,`d1-hover` 悬停卡) |
+| d2 | @ 提及:工作区、仓库、任务、主机(`d2-host` 按主机筛选,`d2-ask` 只问) |
+| d3 | 直接派活与撤销(`d3-noproject` 仓库还没有项目时先建项目) |
+| d4 | 派活到正在工作的工作区,另开会话(`d4-idle` 发给做完一轮的会话) |
+| d5 | 新建任务第一屏(`d5-new` 新建工作区,`d5-later` 稍后再定,`d5-composer` 第二屏:Orca 的新建工作区弹窗) |
+| d6 | 任务概览:头部、现在卡、描述与子任务、动态(`d6-sys` 系统记录,`d6-status` 改状态,`d6-ws` 工作区一行,`d6-empty` 空任务,`d6-menu`、`d6-cancel`、`d6-canceled` 取消任务) |
+| d7 | 终端会话的速览浮层(`d7-wide` 加宽) |
+| d8 | 聊天会话的速览:在浮层里直接回答(`d8-answered`,`d8-wide`) |
+| d9 | 通用对话:调度会话与提案卡 |
+| d10 | 待验收与验收条(`d10-peek` 从自检打开会话,`d10-peek-menu`、`d10-peek-switch` 切换会话,`d10-ask-peek`、`d10-ask` 验收中追问,`d10-reject` 打回) |
+| d11 | 主机未连接 |
+| d12 | 设置 → 本地任务 |
+| d13 | 侧栏任务模式:任务树、筛选、任务视图、浮层三级(`d13-session`、`d13-session-wide`、`d13-session-tab`、`d13-session-back`;`d13-unlinked` 未关联任务的会话) |
+| d14 | 任务模式下的会话 |
+| d15 | 总管台账页:状态条、等你确认、它盯着的任务、它还没处理的动态、它派出的(`d15-closed` 还没打开,`d15-nav` 首页导航带等你确认数、「需要你」里的提示行) |
+| d16 | 总管的浮动对话:只读查询再给提案,提案卡停在输入框上方(`d16-terminal` 同一个会话切到终端) |
+| d17 | 提案卡的六类项(`d17-running` 逐项执行中,`d17-running-other` 另一个窗口看到的同一张卡,`d17-finished` 执行窗口断开后收尾) |
+| d18 | 总管被唤醒:一行提示、只汇报要你知道或决定的、附追加提案(`d18-unverifiable` dbox 失去联系) |
+| d19 | 任务动态里「由总管提案、你确认」,能回到那段对话 |
+| m1 | 手机首页(上游首页加本地任务卡;`m1-tasks` 本地任务页与派活输入框) |
+| m2~m8 | @ 提及、已派活(`m3-noproject`)、任务详情、速览抽屉、通用对话、待验收、新建任务 |
+| m9 | 总管入口与提案列表(`m9-decide` 逐项保留或去掉、由桌面执行,`m9-offline` 桌面未连接时只读) |
+
+## 和技术方案的差异
+
+原型按产品方案画。用户已于 2026-11-05 确认下列两处采用[技术方案](../../solutions/任务为主的工作流技术方案.md) §2.3;本目录保留原型历史形态:
+
+- **Q1**:原型里,任务模式下的任务视图有「概览 + 会话」标签栏(d13-task、d13-task-waiting、d13-shop12)。技术方案建议不做这排标签,会话都开在浮层里,「全屏」切到工作区原有的标签。
+- **Q2**:原型里,手机抽屉里的终端会话可以直接打字(m5)。技术方案建议只有聊天会话在抽屉里回答,终端会话点开直接进会话页。
+
+## 迭代记录
+
+| 轮次 | 日期 | 内容 | 决策 |
+| ---- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | ----- |
+| 1 | 2026-11-01 | 按产品方案 §12 的默认做法画出 d1~d14、m1~m8 | D-011 |
+| 2 | 2026-11-02 | 按方案评审改:@任务、@主机、只问;派活分岔;新建任务两屏;速览只对做完一轮回复;任务视图默认概览;手机首页改回上游样式 | D-012 |
+| 3 | 2026-11-02 | 浮层改成速览、加宽、全屏三级;概览精简成四块,去掉评审注释 | D-013 |
+| 4 | 2026-11-02 | 速览窄态完整可操作;去掉完成标准 | D-014 |
+| 5 | 2026-11-03 | 待验收能看会话:待验收行加速览,自检来源可以点,浮层里切换会话,验收中可以追问;窄浮层把主区推开,不遮验收按钮 | — |
+| 6 | 2026-11-09 | 补总管:任务导航最上方的台账页、浮动对话(聊天与终端同一会话)、六类提案项与逐项执行、空闲时唤醒、主机失联、手机决定提案 | D-015 |
+
+每一轮都逐张看过截图。第 5 轮的点击测试:桌面 225 项、手机 139 项全部通过,没有页面错误;脚本在本机 `.docs/本地任务管理调研/2026-11-01/scripts/`,不进仓库。
+
+第 6 轮只追加屏幕,已有屏幕的渲染逻辑不变;唯一的例外是任务导航(视图列、收窄后的图标列、任务模式的侧栏树)最上方多了「总管」一项,受影响的已有截图已重新生成。新截图是 1x。总管这一轮的点击测试 179 项全部通过,没有页面错误;截图、像素比对和点击测试脚本在本机 `.docs/local-tasks-ui-validation/2026-11-09/prototype-super-agent/scripts/`,不进仓库。
+
+### 总管的动线
+
+```mermaid
+flowchart LR
+ Nav["任务导航最上方「总管」 · 带等你确认数"] --> Ledger["台账页 d15"]
+ NeedsYou["「需要你」里的提示行 d15-nav"] --> Ledger
+ Ledger -->|显示对话| Panel["浮动对话 d16 · 聊天 ⇄ 终端"]
+ Pill["右下角收起的对话"] --> Panel
+ Panel -->|最小化| Pill
+ Panel -->|它提出| Card["提案卡 d17"]
+ Ledger --- Card
+ Card -->|确认 N 项| Run["Orca 逐项执行 · d17-running"]
+ Run --> Done["它派出的 · 任务动态 d19"]
+ Run -->|执行窗口断开| Finish["收尾 d17-finished"]
+ Watch["它盯着的任务有动态"] -->|它空闲时提醒一次| Wake["d18 汇报 + 提案"]
+ Wake --> Card
+ Phone["手机 m9"] -->|决定| Card
+```
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/assets/Geist-Variable.woff2" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/assets/Geist-Variable.woff2"
new file mode 100644
index 000000000000..b2f01210625c
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/assets/Geist-Variable.woff2" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/desktop.html" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/desktop.html"
new file mode 100644
index 000000000000..b6d3109e171c
--- /dev/null
+++ "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/desktop.html"
@@ -0,0 +1,16630 @@
+
+
+
+
+
+ Orca 以任务为主 · 桌面原型
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ← 原型目录·以任务为主 · 桌面
+
+
+
+
+
+
+
+
+
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/index.html" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/index.html"
new file mode 100644
index 000000000000..2af3288cf266
--- /dev/null
+++ "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/index.html"
@@ -0,0 +1,629 @@
+
+
+
+
+
+ Orca 以任务为主 · 原型目录
+
+
+
+
+
+ Orca「以任务为主」原型
+
+ 任务是入口和记录,对话是手段。打开 Orca 先看到任务首页;一句话派活,Orca
+ 建任务、绑工作区、开会话;进展由智能体回写到任务上;完成由你验收。方案见本需求的
+ interaction/任务为主的工作流产品方案.md(§5~§8)。
+
+
+
+
1打开 Orca 先看到什么?
+
+ 任务首页:顶部派活输入框,下面先是「需要你」,再是按状态分组的任务卡,每张卡有一行最新进展。d1
+ · m1
+
+
+
+
2一句话怎么变成在工作区里干活的会话?
+
+ 「@工作区 + 一句话」直接派活,出一张「已派活」卡,10 秒内可以撤销;没有 @
+ 的交给调度会话,先提案再执行。d3 ·
+ d4 · d9
+
+
+
+
3不进会话,怎么知道做到哪了?
+
+ 任务卡和详情上的进展卡:来自智能体回写,没有回写时显示它的最后一句话;要细看就用会话速览。d6
+ · d7 · d8
+
+
+
+
4完成由谁定?
+
+ 智能体最多推进到「待验收」,你点「通过」或「打回」;打回的原因会发给会话。d10
+ · m7
+
+
+
+
5谁替我分配和跟进?
+
+ 总管:任务导航最上方是它的台账页,对话在浮动面板里。它只读查询,要动手就给提案,你确认后由
+ Orca 逐项执行。d15 · d16 ·
+ d17 · m9
+
+
+
+
6它怎么知道任务有了进展?
+
+ 它盯着的任务有新动态时,Orca
+ 只在它空闲时提醒一次;它只说你要知道或要决定的。主机失去联系一律「无法确认」。d18
+ · d18-unverifiable ·
+ d19
+
+
+
+
+ 深色主题,颜色、字号、阴影只用 assets/tokens.css
+ 每屏顶部有屏幕切换器,地址里的 # 对应屏幕 id;带后缀的子屏(如
+ #d2-ask、#d13-shop12)也能直接打开
+ 交互都能点:派活、只问、撤销倒计时、速览抽屉、提案卡、通过与打回、两屏新建、取消任务;总管的确认、不要了、去掉一项、改一项、切终端、最小化、从台账打开对话、不再盯、唤醒开关
+ 桌面右上角「重置本屏」回到初始状态
+ 桌面左侧栏顶部可以切换「工作区|任务」:d1~d12 默认工作区模式,d13、d14
+ 是任务模式;切换按屏记住
+ 第二轮(BRIEF-2):标题不再生成;任务模式里仍用速览抽屉看会话
+ 第三轮(BRIEF-3):看会话是同一个右侧浮层;概览页减信息:右栏取消、空块不显示、系统记录折叠、没有评审注释
+ 第四轮(BRIEF-4 + 改口):浮层两档宽度(速览 /
+ 加宽)都完整可操作,再加全屏;没有「完成标准」这个东西——概览、新建任务、验收条、设置里都没有,验收看合入位置、改动、智能体的自检结论
+ 第五轮:待验收的任务能看会话——需要你的待验收行有「速览」,验收条的自检来源能打开会话,浮层头部可在任务和子任务的会话间切换,验收中能在速览里追问;速览(窄)不再盖住内容,而是把主区推开,验收时能一边看会话一边点「通过」;首页的视图列收成图标,行里的主机和时间先隐藏;加宽仍盖在内容上
+ 第六轮:总管。任务导航最上方是「总管」台账页,对话在浮动面板里(聊天和终端是同一个会话);它只读查询、只经提案动手,你确认后由
+ Orca
+ 逐项执行;盯着的任务有动态时,只在它空闲时提醒它一次;主机失去联系一律写「无法确认」(d15~d19、m9)
+ 手机原型为方便评审用简体中文,真实 App 是英文
+ 双击文件即可打开,不依赖网络
+
+
+
+
+ 1440×900 的 Orca 窗口。截图由
+ ../scripts/shoot-desktop-task-first.mjs 生成,交互由
+ ../scripts/check-desktop-task-first-interactions.mjs 点一遍。
+
+
+
+
+
+ 390×844 的 iPhone 画框,由手机那一路制作;一张截图里可能并排放了同一屏的几个状态。
+
+
+
+ 示例数据
+
+ -
+ 主机:本机 MacBook Pro(在联系)、minizc(SSH 主机,在联系)、dbox(配对服务器,2
+ 小时前断开)
+
+ -
+ 任务:ORC-41 重连后终端偶发空白(Claude 工作中)、ORC-38 侧栏按任务分组(Codex
+ 等你回答)、ORC-39 启动耗时优化(Claude 做完一轮)、SHOP-12 订单导出支持
+ CSV(待验收;子任务 SHOP-13 后端:导出接口 · Claude 终端做完一轮,SHOP-14 前端:导出按钮 ·
+ Codex 聊天做完一轮)、INF-3 部署脚本迁移到新集群(dbox 未连接)、SHOP-15 退款 webhook
+ 签名校验(未开始)、ORC-36 Cmd+J 支持任务(已完成);仓库 tools(工作区
+ scripts-cleanup)还没有项目
+
+ -
+ 派活示例:A1「@orca/docs-typo 把 README 里过期的链接都修一下」→
+ ORC-42,标题就是这句话;A2「@orca/feat-reconnect 这里给我调研一下重连为什么要 3 秒」→
+ ORC-41 的子任务(Claude 还在工作中,另开会话);「@tools/scripts-cleanup 把过期脚本清掉」→
+ 先建项目 TOOL;「@orca/perf-startup 顺便看看首屏渲染」→ 发给 ORC-39 的 Claude
+ 当下一句;只问「@orca/feat-x 这个分支改了什么?」→ 只读回答;B「看看 orca 和 shop
+ 里哪些工作区的 PR 还没合,各开一个任务跟进」→ 提案两项
+
+
+
+
+
+
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/mobile.html" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/mobile.html"
new file mode 100644
index 000000000000..760a1ef6f9ac
--- /dev/null
+++ "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/mobile.html"
@@ -0,0 +1,9803 @@
+
+
+
+
+
+ Orca 以任务为主 · 手机原型
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d1-board.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d1-board.png"
new file mode 100644
index 000000000000..d61cd7fbda68
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d1-board.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d1-hover.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d1-hover.png"
new file mode 100644
index 000000000000..ba506b5fca76
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d1-hover.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d1.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d1.png"
new file mode 100644
index 000000000000..682d7aac3bab
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d1.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-ask-peek.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-ask-peek.png"
new file mode 100644
index 000000000000..4f6a372486cd
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-ask-peek.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-ask.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-ask.png"
new file mode 100644
index 000000000000..83d99062e2cf
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-ask.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-peek-menu.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-peek-menu.png"
new file mode 100644
index 000000000000..5e4d05397ab4
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-peek-menu.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-peek-switch.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-peek-switch.png"
new file mode 100644
index 000000000000..3fa8bddbd340
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-peek-switch.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-peek.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-peek.png"
new file mode 100644
index 000000000000..b51916688606
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-peek.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-reject.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-reject.png"
new file mode 100644
index 000000000000..88d296c9e2d3
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10-reject.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10.png"
new file mode 100644
index 000000000000..bb3929695c5f
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d10.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d11.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d11.png"
new file mode 100644
index 000000000000..fc2d2dbcea20
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d11.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d12.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d12.png"
new file mode 100644
index 000000000000..c69f67560e5b
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d12.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-doing.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-doing.png"
new file mode 100644
index 000000000000..3307dce3cc10
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-doing.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-hover.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-hover.png"
new file mode 100644
index 000000000000..cce403bf8059
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-hover.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-plus.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-plus.png"
new file mode 100644
index 000000000000..fc00f2891639
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-plus.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-session-back.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-session-back.png"
new file mode 100644
index 000000000000..eb72d6544fb9
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-session-back.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-session-tab.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-session-tab.png"
new file mode 100644
index 000000000000..413e88bea48b
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-session-tab.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-session-wide.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-session-wide.png"
new file mode 100644
index 000000000000..eb72d6544fb9
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-session-wide.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-session.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-session.png"
new file mode 100644
index 000000000000..5f3177dcd26c
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-session.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-shop.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-shop.png"
new file mode 100644
index 000000000000..4e44d2f12965
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-shop.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-shop12.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-shop12.png"
new file mode 100644
index 000000000000..986d068fb688
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-shop12.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-subtask.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-subtask.png"
new file mode 100644
index 000000000000..b2cabb9fd997
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-subtask.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-task-waiting.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-task-waiting.png"
new file mode 100644
index 000000000000..6899905c0fc9
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-task-waiting.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-task.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-task.png"
new file mode 100644
index 000000000000..35cb2f9b75d3
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-task.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-unlinked-task.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-unlinked-task.png"
new file mode 100644
index 000000000000..13c281ca38d9
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-unlinked-task.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-unlinked.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-unlinked.png"
new file mode 100644
index 000000000000..51e9c8a916a3
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13-unlinked.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13.png"
new file mode 100644
index 000000000000..ea58b2232427
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d13.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d14-chat.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d14-chat.png"
new file mode 100644
index 000000000000..36a8ad623770
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d14-chat.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d14-chip.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d14-chip.png"
new file mode 100644
index 000000000000..bcdf6d371b2d
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d14-chip.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d14.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d14.png"
new file mode 100644
index 000000000000..af47e42fd606
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d14.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d15-closed.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d15-closed.png"
new file mode 100644
index 000000000000..b0694befaf27
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d15-closed.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d15-nav.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d15-nav.png"
new file mode 100644
index 000000000000..3ba8fa20ea3f
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d15-nav.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d15.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d15.png"
new file mode 100644
index 000000000000..f625f9fef25f
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d15.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d16-terminal.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d16-terminal.png"
new file mode 100644
index 000000000000..b8444eea3526
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d16-terminal.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d16.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d16.png"
new file mode 100644
index 000000000000..476955bced0d
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d16.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d17-finished.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d17-finished.png"
new file mode 100644
index 000000000000..444f3622071a
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d17-finished.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d17-running-other.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d17-running-other.png"
new file mode 100644
index 000000000000..72e45f596b5e
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d17-running-other.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d17-running.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d17-running.png"
new file mode 100644
index 000000000000..1f3f7a2e761d
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d17-running.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d17.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d17.png"
new file mode 100644
index 000000000000..0e4bf5509263
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d17.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d18-unverifiable.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d18-unverifiable.png"
new file mode 100644
index 000000000000..64549eccb7e9
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d18-unverifiable.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d18.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d18.png"
new file mode 100644
index 000000000000..cad7349f3da5
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d18.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d19.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d19.png"
new file mode 100644
index 000000000000..12012ea0a74b
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d19.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d2-ask.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d2-ask.png"
new file mode 100644
index 000000000000..81f3aa66e6c6
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d2-ask.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d2-chip.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d2-chip.png"
new file mode 100644
index 000000000000..b5c7d909fe4f
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d2-chip.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d2-host.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d2-host.png"
new file mode 100644
index 000000000000..12b76c137639
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d2-host.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d2.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d2.png"
new file mode 100644
index 000000000000..71dd8b42bc8c
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d2.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d3-noproject-done.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d3-noproject-done.png"
new file mode 100644
index 000000000000..402dbaeb676b
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d3-noproject-done.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d3-noproject.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d3-noproject.png"
new file mode 100644
index 000000000000..e818204efa9c
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d3-noproject.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d3.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d3.png"
new file mode 100644
index 000000000000..c8761baf97b9
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d3.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d4-idle.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d4-idle.png"
new file mode 100644
index 000000000000..078090a98e4e
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d4-idle.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d4.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d4.png"
new file mode 100644
index 000000000000..84cf09136c91
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d4.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d5-composer.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d5-composer.png"
new file mode 100644
index 000000000000..35f73e58beba
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d5-composer.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d5-later.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d5-later.png"
new file mode 100644
index 000000000000..d3c24264b3cd
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d5-later.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d5-new.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d5-new.png"
new file mode 100644
index 000000000000..1bddd8e38c13
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d5-new.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d5.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d5.png"
new file mode 100644
index 000000000000..d4023b83099b
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d5.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-bottom.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-bottom.png"
new file mode 100644
index 000000000000..f1134ec33003
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-bottom.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-cancel.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-cancel.png"
new file mode 100644
index 000000000000..dcb539ed207e
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-cancel.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-canceled.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-canceled.png"
new file mode 100644
index 000000000000..57c02fad8a3b
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-canceled.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-empty.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-empty.png"
new file mode 100644
index 000000000000..b6eeca93cd0b
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-empty.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-menu.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-menu.png"
new file mode 100644
index 000000000000..1b3f9724d6eb
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-menu.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-progress.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-progress.png"
new file mode 100644
index 000000000000..c0f194ae4166
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-progress.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-status.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-status.png"
new file mode 100644
index 000000000000..46b689471360
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-status.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-sys.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-sys.png"
new file mode 100644
index 000000000000..68199312afd3
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-sys.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-ws.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-ws.png"
new file mode 100644
index 000000000000..8d1b1577c5fc
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6-ws.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6.png"
new file mode 100644
index 000000000000..f87d079859e6
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d6.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d7-wide.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d7-wide.png"
new file mode 100644
index 000000000000..663f865a4827
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d7-wide.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d7.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d7.png"
new file mode 100644
index 000000000000..07bf86d7f0a3
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d7.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d8-answered.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d8-answered.png"
new file mode 100644
index 000000000000..836cb7b01adc
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d8-answered.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d8-wide.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d8-wide.png"
new file mode 100644
index 000000000000..9a3dba78901c
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d8-wide.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d8.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d8.png"
new file mode 100644
index 000000000000..3839cdbc6e85
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d8.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d9-confirmed.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d9-confirmed.png"
new file mode 100644
index 000000000000..8f22fe8854a1
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d9-confirmed.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d9.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d9.png"
new file mode 100644
index 000000000000..8660a2731b01
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/d9.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m1-tasks.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m1-tasks.png"
new file mode 100644
index 000000000000..dde98bf20829
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m1-tasks.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m1.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m1.png"
new file mode 100644
index 000000000000..5bf55afca4b0
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m1.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m2.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m2.png"
new file mode 100644
index 000000000000..33011701e3aa
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m2.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m3-noproject.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m3-noproject.png"
new file mode 100644
index 000000000000..9cea2c372405
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m3-noproject.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m3.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m3.png"
new file mode 100644
index 000000000000..25894057cdf6
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m3.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m4.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m4.png"
new file mode 100644
index 000000000000..aa56d72487e7
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m4.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m5.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m5.png"
new file mode 100644
index 000000000000..e30bc4776956
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m5.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m6.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m6.png"
new file mode 100644
index 000000000000..a33a0402bc02
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m6.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m7.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m7.png"
new file mode 100644
index 000000000000..663ae283e27f
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m7.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m8.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m8.png"
new file mode 100644
index 000000000000..ee04ff199799
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m8.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m9-decide.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m9-decide.png"
new file mode 100644
index 000000000000..00e8916a1668
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m9-decide.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m9-offline.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m9-offline.png"
new file mode 100644
index 000000000000..276561a401b0
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m9-offline.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m9.png" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m9.png"
new file mode 100644
index 000000000000..dc080af9598b
Binary files /dev/null and "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/prototype-task-first/screenshots/m9.png" differ
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/\344\273\273\345\212\241\344\270\272\344\270\273\347\232\204\345\267\245\344\275\234\346\265\201\344\272\247\345\223\201\346\226\271\346\241\210.md" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/\344\273\273\345\212\241\344\270\272\344\270\273\347\232\204\345\267\245\344\275\234\346\265\201\344\272\247\345\223\201\346\226\271\346\241\210.md"
new file mode 100644
index 000000000000..f48bfb165f36
--- /dev/null
+++ "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/\344\273\273\345\212\241\344\270\272\344\270\273\347\232\204\345\267\245\344\275\234\346\265\201\344\272\247\345\223\201\346\226\271\346\241\210.md"
@@ -0,0 +1,531 @@
+---
+title: 任务为主的工作流:产品方案
+status: approved
+created: 2026-10-30
+---
+
+# 任务为主的工作流:产品方案
+
+这份方案回应用户 2026-10-30 的要求:工作流要以任务为主,不再以对话为主。
+
+- 进来先建任务,绑定工作区,启动会话;会话可以快捷查看,不想看时,任务下面就有进度摘要。
+- 另外有一个通用对话,用来创建任务、开会话。例如在对话里 @ 某个仓库的某个工作区,说「这里给我调研一下 X」,Orca 就建一个任务、绑定这个工作区、在里面启动会话开始干活。
+
+参考材料:
+
+- Multica 调研:本机 `.docs/本地任务管理调研/2026-10-30-multica/README.md`;
+- 本需求文档 `requirements/本地任务管理.md` 里的 REQ-001~REQ-025;
+- Orca 现有机制的盘点,结论见 §11。
+
+§12 的 11 个问题,用户 2026-11-01 已全部按默认做法确认。2026-11-02 的评审稿(`任务为主的工作流方案评审.md`)用户全部采纳,只改掉一处:任务模式里仍然用速览抽屉看会话,不改成「点会话就是标签」,因为后续要迁移到以任务为主的模式,任务视图不能长成工作区的样子。评审采纳的改动已并入本稿(§15 列了清单)和需求文档 REQ-026~REQ-030。代码等原型第二轮评审后再动。
+
+## 1. 一句话
+
+**任务是入口和记录,对话是手段。**
+
+用户只做三件事:
+
+- 说要做什么;
+- 在智能体需要时回答;
+- 验收。
+
+开工、回写进展、提醒都由 Orca 和智能体完成,并记在任务上。
+
+## 2. 为什么要改:现在是「对话为主」
+
+| 现在 | 结果 |
+| ---------------------------------------------------------------------------------- | ------------------------------------------------------ |
+| 打开 Orca,先看到的是工作区和会话标签。任务页是旁边的一个视图 | 想知道「我手上有哪些事、各做到哪了」,要逐个点进会话看 |
+| 要做一件事,常见做法是直接进工作区开会话、打字。建任务、关联是额外步骤,很多人跳过 | 事情只存在于对话里,过后找不到,也对不上账 |
+| 任务上的进展只有会话状态点和智能体最后一句话 | 不看会话就不知道做成什么样 |
+| 没有不绑定工作区的对话入口。浮动终端是唯一近似的入口,而且只在本机 | 「先说要做什么,再决定在哪做」做不到 |
+
+```mermaid
+flowchart LR
+ subgraph now["现在"]
+ A1["打开 Orca"] --> A2["选工作区"] --> A3["开会话、打字"] --> A4["在会话里来回看进度"]
+ A3 -.->|"可选"| A5["回到任务页关联"]
+ end
+ subgraph target["目标"]
+ B1["打开 Orca:任务首页"] --> B2["一句话派活,或新建任务"]
+ B2 --> B3["Orca 建任务、绑工作区、开会话"]
+ B3 --> B4["智能体干活并回写进展"]
+ B4 --> B5["任务卡上看摘要;需要时速览会话"]
+ B5 --> B6["需要你时回答;待验收时验收"]
+ end
+```
+
+## 3. 设计原则(修订本需求的原则 2,待确认)
+
+1. **任务优先**:任何会话都应属于某个任务。从通用对话或新建任务出发,任务和会话会一起建好。直接在工作区里开的会话,在任务模式侧栏的「未关联任务的会话」组里可以「记成任务」;会话头部不加入口(REQ-011 在 2026-10-30 定的,2026-11-02 评审后保持)。
+2. **人说了才开工**:Orca 不因为状态变化、定时或主机恢复联系而自动开会话。派活一定来自用户的一句话、一次点击,或用户确认过的调度提案。
+ - 这一点和 Multica 不同:它把「指派」当派发,由 daemon 排队认领。
+3. **动作都留痕**:派活、开会话、回写、状态变化、验收,都在任务时间线上留一条记录,写明来源(你、手机、对话、智能体、规则)。
+4. **进度只写明确的内容**:
+ - 摘要来自智能体主动回写的结果;没有回写时,显示智能体每轮结束时的最后一句话(主机在会话每轮结束时落成一条「轮次结束」记录,见 §7.2)。
+ - 让模型读会话再生成摘要是可选操作:用户点了才生成,并标明「Orca 生成」。
+5. **完成由人定**:智能体最多把任务推进到「待验收」;标「完成」只能由人在桌面或手机界面上点。写入时的规则是「来自 CLI 的写入不能进入已完成类别」——CLI 分不清是人还是智能体在敲,所以把完成这一步只留给界面(2026-11-02 评审定)。
+6. **不轮询、不重试、不排队**:主机不在联系时,派活和读取都不可用,并写明原因。恢复联系后只重读一次(AGENTS.md 的 Remote Host Reads)。
+7. **本机、SSH、配对服务器、手机一视同仁**:任务存在拥有工作区的那台 Orca 上(D-008)。客户端在哪台机器上都能派活、看进度。
+
+## 4. 对象模型
+
+```mermaid
+classDiagram
+ class Task["任务"] {
+ 标题
+ 状态:待规划、未开始、进行中、待验收、完成、取消
+ 描述
+ 最新进展
+ }
+ class Binding["工作区绑定"] {
+ 主机
+ 仓库或文件夹
+ worktree
+ }
+ class Session["会话"] {
+ 智能体
+ 状态:工作中、等你、做完一轮、已结束
+ 最后一句话
+ }
+ class Progress["进展记录"] {
+ 来源:智能体回写、Orca 生成、你
+ 摘要
+ 需要你什么
+ 时间
+ }
+ class Chat["通用对话"] {
+ 消息
+ 派活卡片
+ }
+ Task "1" --> "0..1" Binding : 绑定
+ Task "1" --> "0..*" Task : 子任务,继承上级工作区
+ Binding "1" --> "0..*" Session : 运行
+ Session "1" --> "0..*" Progress : 回写
+ Task "1" --> "0..*" Progress : 汇总
+ Chat "1" --> "0..*" Task : 派出
+```
+
+在现有模型(D-008)上的调整:
+
+- **新增「进展记录」**:它是动态(REQ-005)的一种,只是在任务卡和详情顶部单独突出显示。
+- **工作区和任务的对应关系不变**:一个任务最多一个工作区,一个工作区只属于一个任务(任意层级,D-008;2026-11-02 勘误:原文写的「顶层任务」和代码不符)。
+ - 派活到一个已经属于任务 T 的工作区时:T 下有做完一轮的空闲会话,就把这句话发给它当下一句指令,不建任务,T 的时间线记「由对话追加指令」;没有空闲会话(都在工作中,或没有会话),才在 T 下建子任务、另开会话,并在卡上写明同一个工作区里还有会话在工作。详见 §6.3。
+ - 子任务继承上级工作区,这是现有规则。这样不用放开「一个工作区多个任务」,也不会冲突。
+- **任务建到哪个项目里**(2026-11-02 补):工作区所在的仓库只挂在一个项目下就用它;挂在多个项目下,用最近派过活的那个,卡上可以改;一个项目都没挂时,派活卡先出一步「为仓库 X 建一个项目?」,确认后建项目再派(和 REQ-001 的推荐逻辑一致)。
+- **新增「通用对话」**:它只是入口,不存任务数据。每次派活都在任务上留下「由对话派出」的记录,并能跳回那段对话。
+
+## 5. 主要界面
+
+### 5.1 任务首页(新的默认首页)
+
+桌面启动后默认打开任务首页。实现上沿用上游「恢复上次视图」的机制,只加一个「启动时打开任务页」的设置,默认开,关掉就回到上游行为。手机首页不换:本地任务卡加「需要你」和最新进展,派活输入框放在本地任务页顶部(2026-11-02 评审定,理由见 REQ-017)。
+
+自上而下三部分:
+
+- **派活输入框**,也就是通用对话的入口:
+ - 占位文字是「要做什么?@ 一个工作区或仓库,说一句话」;
+ - 支持 @工作区、@仓库、@任务、@主机;
+ - 回车派活;点「展开对话」进入通用对话,见 §6。
+- **需要你**:分两组。「会话在等你」是 REQ-014 的 4 条:等你批准、等你回答、被卡住、做完一轮还没看;「任务在等你」按任务字段判定:待验收(§8),以及 REQ-023 的「等你决定」。条目正文优先用智能体回写里的「需要你什么」,没有才用最后一句话。
+- **按状态分组的任务卡**:进行中、待验收、未开始……沿用现有列表和看板,切换方式不变。
+
+任务卡内容:
+
+- 标题;
+- 状态;
+- 工作区名和主机,主机不在联系时写「未连接」;
+- 会话状态点;
+- **最新进展一行**,例如「已定位到重连逻辑在 X,下一步写复现测试 · 5 分钟前」;
+- 悬停或长按可以展开完整摘要。
+- 有子任务的任务多一行「子任务 2 · 都做完一轮 · 最新:SHOP-14 …」。父任务自己有进展时先显示自己的,这一行放在下面;没有时取子孙里最近一条进展,并标明来自哪个子任务(2026-11-02 补)。
+
+### 5.2 新建任务(一次流程,两屏)
+
+2026-11-02 评审改法:原来想把「新建任务 → 开始做 → 开会话」三个对话框合成一个,但那会把上游新建工作区弹窗(基础分支、运行位置、创建自、智能体、第一句指令、setup 脚本)重画一个阉割版。改成一次流程、两屏,不重做:
+
+1. **第一屏:新建任务**。标题必填,描述可以展开填写(没有「完成标准」,D-014);下面是「在哪做」三选一:
+ - **已有工作区**:当场选(可搜索,列出所有在联系的主机),下面出现「开工」段:智能体(默认你的默认智能体,可以选「先不开」)、第一句指令(默认「按任务说明开始」)、发送方式。主按钮「创建并开工」,建好任务、关联工作区、开会话,停在任务视图。
+ - **新建工作区**:第一屏不再出现仓库、主机、名称、分支。主按钮「创建,下一步新建工作区」。
+ - **稍后再定**:主按钮「创建」。
+2. **第二屏:Orca 现有的新建工作区弹窗**。「创建自」里预选这个任务(REQ-009 在 2026-10-30 已经接好),智能体和第一句指令在这个弹窗里填。创建完成后关联并停在任务视图。
+
+**发送方式**:从任务发起的会话,第一句指令默认直接发送,可以改成「放进输入框,我再看看」;手机只能直接发送(D-010)。这改掉了 REQ-009 在 2026-10-30 定的「放进草稿」,三处(REQ-009、开会话对话框、原型)统一(2026-11-02 用户定)。默认值放在「设置 → 本地任务」的「从任务发起的会话:直接发送 / 放进输入框」里;本功能自己的弹层(新建任务第一屏、开会话、打回开新会话)可以临时改;上游的新建工作区弹窗不加这个选项,按设置执行,免得多开一处接缝。
+
+### 5.3 任务详情
+
+自上而下:
+
+2026-11-02 用户看了原型后要求减信息:这页要回答的只有「现在要我干什么、做到哪了、怎么进去干活」,其余都是次要的。逐块核对后的取舍:工作区在头部和右栏重复、同一个会话在标签、侧栏树、等你卡、会话列表出现四次、「验收」按钮在进行中也显示、系统事件(会话启动、规则改状态)占满动态、右栏属性很少看、原型里还有一批给评审看的注释。精简后只剩四块:
+
+- **头部**:标题;属性 chips 一行(状态可点改;优先级、标签、上级任务设了才显示);工作区一行(名称 · 主机 · 改动统计 · PR,没有的项不显示;点开有「打开工作区」「查看更改」;没有工作区时写「还没有工作区」);右侧**一个主按钮**,按状态变:没有工作区 → 开始做,有工作区 → 开会话,待验收 → 验收,已完成或已取消 → 没有;「…」菜单里是设为目标(Goal;聊天型会话不支持,置灰并写原因)、取消任务、删除、复制链接、改标题。取消或删除时,任务下还有在跑的会话,确认框里给「同时停止 N 个会话」的勾选,默认不勾。右栏取消,创建和更新时间放到动态底部一行小字。
+- **「现在」卡**:唯一一张卡。待验收任务的验收条在卡的最上面(§8);然后是在等你什么(有才显示,带「速览」);最新进展(有才显示:来源、时间、做了什么、结论、下一步、需要你什么;「全部进展 N」只在有记录时显示,「生成摘要」只在有会话内容时显示);每个会话一行(智能体、终端或聊天、所属子任务、状态、一句话、速览)。没有会话也没有进展时整张卡只有一行「还没有会话」,不解释机制。
+- **描述、子任务**:有内容才显示;为空时只有「添加」小链接。子任务含衍生问题(REQ-025)。没有「完成标准」这一块:2026-11-02 用户定不要这个东西(D-014),大多数任务定不出标准,验收看结果(§8)。
+- **动态**:默认只显示你的评论、智能体回写、由对话派出或追加指令、验收通过或打回、记成任务;系统事件(新建、会话启动、规则改状态、关联与取消关联、撤销)折叠成一行「系统记录 N」,点开才列出。
+
+界面里不出现需求编号和机制说明(「每条可以附证据」「粘贴多行可以一次加多个」这类提示一律去掉)。
+
+### 5.4 会话速览
+
+不离开任务就能看会话,并回一句话。速览在首页列表、任务概览和任务模式侧栏里都有;任务模式里点会话也是先开速览,不直接切到会话标签(2026-11-02 用户定:后续要迁移到以任务为主的模式,任务要一直是主体,会话是从属)。
+
+速览就是**对话的小边框模式**(2026-11-02 用户定,D-014):同一个会话挂在右侧浮层里,窄一点,什么都能做。桌面上是一个浮层两档宽度,加全屏;同一个终端或聊天实例只换宽度,不重新挂载:
+
+| 级别 | 怎么进 | 宽度 | 内容与操作 |
+| ---- | -------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
+| 速览 | 点会话、点「速览」、侧栏树点会话 | 窄,约 420px;主区往左让开,任务内容(含验收按钮)不被遮住 | 真实终端或聊天挂进来,**完整可操作**:终端有输入行(打字、方向键选菜单、批准),聊天有完整 composer(选项按钮、输入);「停止」 |
+| 加宽 | 速览里点「加宽」 | 宽,约主区 2/3,盖在任务上 | 同一个实例,只是更宽 |
+| 全屏 | 任一级点「全屏」 | 占满主区 | 任务模式下切到任务视图里这个会话的标签;工作区模式下跳到工作区的那个标签;标签顶部一条「收回侧边」回到浮层 |
+
+- **为什么不再限制回复**:REQ-015 的「只有做完一轮能回复」说的是另一条通道——一个单独的文本框把一句话一次性发进会话,终端里的提问多是菜单,发文字会发错。浮层里挂的是会话本身,输入走会话自己的通道,等你批准、等你回答、工作中都和在标签页里一样能操作,所以浮层里没有单独的回复框,也没有这条限制。
+- **首页「需要你」行的「回复」**:就是打开速览并把光标放进输入,不再是另一个文本框。「任务在等你」的待验收行同样有「速览」,打开写了最新进展的那个会话(2026-11-03 补)。
+- **切换会话**:任务有不止一个会话时(含子任务的),浮层头部会话名旁有下拉,可以在这些会话之间切换;只有一个会话时不显示。
+- **内容**:聊天型会话复用结构化会话视图;终端型会话把真实终端直接挂进浮层(Activity 页 portal 终端的做法),不是文本快照,颜色和滚动都在。显示状态,以及「在等你什么」。
+- **代价**:窄态下终端按浮层宽度重排(TUI 重画一次),和拖分屏一样。
+- **手机**:底部抽屉里挂真实的会话视图,半屏就能回答;上拉到全屏就是会话页。
+- **读取规则**:浮层打开且主机在联系时才读;有推送就跟随推送;主机不在联系时显示上次读到的内容,并写明「未连接」,不重试。
+
+### 5.5 通用对话
+
+见 §6。
+
+### 5.6 主框架的「任务模式」(2026-11-02 用户提出)
+
+用户希望把任务页里那列二级导航(需要你、状态视图、项目列表)挪进主框架的左侧栏,用「任务模式」直接显示任务。
+
+- **切换**:在侧栏「项目」那一行的图标按钮里,加一个「按任务显示」按钮,放在铃铛前面,记住上次的选择(2026-11-02 用户定:用按钮,不用两段开关)。
+ - 切到任务模式后,这一行的标题变成「任务」,同一个按钮高亮,再点一下切回去。右边保留「+」,用来新建任务。
+ - 工作区模式和现在一样,「侧栏按任务分组」仍是它的一个选项;任务模式上线后看它还用不用得上,不用就走下线流程(它是登记过的功能,改注册表和 journal 后再删,2026-11-02 用户定)。
+ - 任务模式下,「搜索、任务、自动化、手机端」这几个入口保留,「项目」区换成任务树。
+ - 任务树每深一层缩进 20px,并有竖向引导线,层级关系一眼能看出来。
+- **选中任务时主区显示什么**(2026-11-02,评审后修订):打开这个任务的视图。任务是主体,会话是从属:默认看到的是「概览」,会话先用速览抽屉看,完整会话才是标签。
+ - 标签依次是:「概览」(任务详情:现在卡、描述、子任务、动态),这个任务和它子任务下的每个会话各一个标签(子任务的会话标签带子任务标题,例如「Claude · 终端 · 后端」),最后是「+」。
+ - 点任务(侧栏树或列表)一律打开「概览」,不自动跳到会话标签。有会话在等你时,「现在」卡先显示「在等你什么」,带「速览」。
+ - 点侧栏里的会话:主区打开它所属任务的概览,右侧同时打开这个会话的速览浮层;浮层里「展开」变成完整会话,「全屏」才切到它的标签(§5.4 的三级)。
+ - 标签栏只放这个任务和它子任务的会话。同一工作区里不属于这个任务的终端不出现,要看就切回工作区模式,或者在侧栏「未关联任务的会话」组里找。
+ - 关闭会话标签就是关闭会话,和工作区模式一样;两种模式下是同一个标签,一边关了另一边也没了。
+ - 「+」:任务有工作区时打开开会话弹层(REQ-010:智能体、第一句指令默认「按任务说明继续」、可选子任务);任务没有工作区时「+」就是「开始做」。
+ - 会话所在主机未连接时,它的标签置灰,悬停写明原因。
+ - **实现口径**:任务视图就是这个任务工作区的 `WorktreeSplitSurface`,标签栏按任务过滤,再加一个「概览」伪标签。不另造一套任务标签集合,终端留在工作区的容器里不重新挂载(上游 `Tab.worktreeId` 必填;换容器挂载正是重连空白那类问题的来源)。
+- **侧栏内容**:把任务页原有的那一列原样搬过来,再让每一行能展开(2026-11-02 用户定)。
+ - 原有的那一列:需要你、全部、进行中、待规划、已完成、已取消,以及「项目」下的全部项目和各个项目。
+ - **选中某一行就是筛选**:主区按选中的视图和项目显示,和原来点任务页那一列一样,左右始终一致。
+ - **展开某一行**:列出属于它的任务。「需要你」下的任务带原因,比如「等你回答」「待验收」。
+ - **展开任务**:直接看到它的会话,每行有状态点和一句话。点会话按上面的规则:概览加速览抽屉。子任务嵌在父任务下面。
+ - **「未关联任务的会话」组**:树的最下面,列出没挂任务的工作区里的会话,否则在任务模式里找不到它们。每行悬停有「记成任务…」,打开新建任务第一屏,标题预填会话最后一句,「在哪做」预选那个工作区(这是原则 1 里「记成任务」唯一的入口)。
+ - 工作区(worktree)不作为树的一层,只作为任务的属性出现:悬停卡片里的「在 某工作区 · 某主机」,以及任务详情的工作区卡。
+ - 树用上游侧栏的虚拟列表行(fork 已有接缝),不另写滚动。
+- **主区**:任务首页不再需要自己的二级导航列,派活输入框和列表、看板占满宽度。任务卡以标题、状态、会话状态和最新进展为主,工作区和主机降为次要信息。
+- **跨主机**:未连接主机上的任务标「未连接」,它下面的会话置灰。读取规则同 §3 第 6 条。
+- **手机**:首页不换,本地任务页本来就以任务为主,不另做任务模式。
+- **隐藏来源**:本地任务来源在「设置 → 任务来源」里隐藏后,任务模式按钮、首页设置、派活输入框、Cmd+J 的「派活…」一并不出现(REQ-008 的规则)。
+- **代码上的接缝**:上游侧栏主体加一个挂载点(任务模式时渲染本功能的任务树),侧栏标题行加一个按钮,标签栏加过滤,主区视图切换一处,设置一处;这一轮新增接缝控制在 6~8 处。开关存本功能自己的偏好键(和「按任务分组」一样),不碰上游只在内存的 `sidebarBody`。登记到 `config/fork-features.jsonc`,其余代码放在功能自有路径。
+
+## 6. 通用对话
+
+### 6.1 两条路径
+
+通用对话的输入框,与任务首页顶部的派活输入框是同一个组件。发出去以后,按内容分两条路处理:
+
+| 路径 | 什么时候走 | 谁来做 | 例子 |
+| -------------------- | -------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------ |
+| **A 直接派活** | 提及了明确的工作区或仓库,话里是一件事 | Orca 直接执行,不经过额外的模型 | 「@orca/feat-x 这里给我调研一下重连为什么慢」 |
+| **B 交给调度智能体** | 没有提及在哪做,或者要拆解、比较、查找 | 一个专门的「调度会话」理解、提方案,确认后执行 | 「看看 orca 仓库里哪些工作区的 PR 还没合,各开一个任务跟进」 |
+
+为什么先有 A:大多数派活就是「在这里做这件事」,不需要先让一个模型理解一遍。A 快,结果可预期,也不多花一次模型调用。B 用来补 A 处理不了的情况。
+
+### 6.2 @ 提及
+
+- 输入 @ 弹出候选,按类型分组:工作区、仓库、任务、主机。候选来自所有在联系的主机;不在联系的主机置灰,并写明原因。
+- 选中后变成一枚 chip,chip 里存的是确定的 id(主机 + 仓库 / worktree / 任务),不是文字。派活时不用再猜「指的是哪个」。
+- 各类候选的含义(2026-11-02 定;原型第一轮把 @任务、@主机 悄悄转给了调度会话,用户不知情):
+ - **@工作区**:在这里派活,见 §6.3。
+ - **@仓库**:为这个任务新建工作区再派活。
+ - **@任务**:在它下面派活:建子任务,会话开在它的工作区;它没有工作区时先走开始做。
+ - **@主机**:不是派活目标。选中后候选只剩这台主机的仓库和工作区,输入框左侧出现一个可去掉的筛选标签,主机不进 chip。
+- **只问**:输入框有「派活 | 只问」两段开关,默认派活。切到「只问」时不建任务,把 chip 里的工作区作为上下文交给调度会话只读回答,例如「@feat-x 这个分支改了什么?」。这是规则,不靠模型猜用户是问还是做。
+- 现状:原生 chat 的 @ 只能提及当前工作区里的文件,新建工作区的 @ 只能关联 issue 或 PR(§11)。这里的输入框是本功能自己的组件,只复用上游的弹层组件(`NativeChatAutocompleteMenus.tsx`)。
+
+### 6.3 路径 A:直接派活
+
+```mermaid
+sequenceDiagram
+ actor U as 你
+ participant C as 派活输入框
+ participant L as 任务库(拥有工作区的那台 Orca)
+ participant H as 工作区所在主机
+ participant A as 智能体会话
+ U->>C: 「@feat-x 这里给我调研一下重连为什么慢」
+ C->>C: 解析 chip:主机、工作区;正文成为第一句指令
+ alt 工作区还不属于任何任务
+ C->>L: 建任务(标题取正文第一句),绑定工作区
+ C->>H: 在工作区启动会话(默认智能体,附任务说明)
+ else 工作区属于任务 T,T 有做完一轮的会话
+ C->>H: 把正文发给那个会话当下一句
+ C->>L: T 的时间线记「由对话追加指令」
+ else 工作区属于任务 T,会话都在工作中或没有会话
+ C->>L: 在 T 下建子任务(会话开在同一个工作区)
+ C->>H: 另开会话,卡上写明同一工作区里还有会话在工作
+ end
+ H-->>A: 智能体开始干活
+ C-->>U: 对话里出现「已派活」卡:任务、会话、撤销
+ A->>L: orca task progress(每轮结束回写结果)
+ L-->>U: 任务卡上的「最新进展」更新
+```
+
+细节:
+
+- **标题**:取正文第一句(截到 60 字),原句留在描述里。Orca 不另起模型调用起名:生成管线每次都是一次智能体 CLI 调用,上游的自动命名也只在本机、只对聊天型会话。任务说明里让干活的智能体开工后用 `orca task update --title` 改一个短标题;用户随时可以改(2026-11-02 评审改)。
+- **派活到有空闲会话的工作区**:「空闲」只指做完一轮的会话(REQ-015 里只有这种能接下一句)。这句话发给它,不建任务;派活卡写「已发给 ORC-39 的 Claude 当下一句」,带「改为建子任务另开会话」和「撤销」。会话在工作中、等你批准、等你回答、被卡住时都不算空闲,走建子任务另开会话,并在卡上写明。
+- **项目归属**:按 §4 的规则;仓库没挂项目时,卡片先出「为仓库 X 建一个项目?」一步,确认后继续。
+- **提及仓库而不是工作区**:为这个任务新建工作区,名称来自任务编号和标题(复用现有的「开始做」)。
+- **提及多个工作区**:建一个父任务,每个工作区一个子任务,各开一个会话;对话里出现一张汇总卡。多个工作区要在同一台任务库上(同一台 Orca 拥有);不在同一台时,走调度会话,各台库里各建一个任务。
+- **撤销**:派活卡上的「撤销」在 10 秒内有效。撤销时停止刚启动的会话、删除任务;新建的工作区保留,并提示用户可以删;智能体已经改过的文件不回滚,卡上写明;删掉的编号不复用(REQ-002)。撤销要在拥有库的那台 Orca 上删任务、在执行主机上停会话,主机不在联系时卡上写「某主机未连接,没有撤销」,不排队。
+- **主机不在联系**:派活按钮置灰,写明「某主机未连接」,不排队、不重试。
+- **任务说明**:会话启动时附在指令后面,内容见 §7.1。
+
+### 6.4 路径 B:调度智能体
+
+```mermaid
+sequenceDiagram
+ actor U as 你
+ participant D as 调度会话(智能体)
+ participant O as Orca(CLI 与任务库)
+ U->>D: 「看看 orca 里哪些工作区的 PR 还没合,各开一个任务跟进」
+ D->>O: orca worktree list / 读 PR 状态(只读)
+ D->>O: orca dispatch propose --file 提案.json
+ O-->>U: 提案卡:3 个任务,各绑哪个工作区、用哪个智能体、第一句指令
+ U->>O: 确认(可以改、可以删其中一项)
+ O->>O: 建任务、绑定、开会话(不再经过调度会话)
+ O-->>U: 「已派活」卡 ×3,10 秒内可撤销
+```
+
+- **调度会话是什么**:一个普通的智能体会话,运行在 Orca 自己的目录里。做法参照浮动终端:它用固定的伪工作区和 `floatingTerminalCwd` 目录。
+ - 默认用你的默认智能体,附一份「调度说明」skill:你的职责是理解请求、提方案、用 orca CLI 执行,自己不做具体工作。
+- **它就是总管**(2026-11-05 增补,D-015):调度会话常驻、唯一,负责分配、管理和感知任务与会话,见 §16。
+- **先提方案,确认后由 Orca 执行**(2026-11-02 评审定):
+ - 只读的查询调度会话直接做。
+ - 凡是要新建的东西(任务、工作区、会话),调度会话用 `orca dispatch propose --file 提案.json` 提交一份结构化提案,Orca 存起来并在对话里渲染成提案卡。你确认后由 Orca 直接建任务、关联、开会话,不再经过调度会话,也不多一轮模型调用。不让智能体输出约定格式的文字再解析,那样脆弱。
+ - 设置里可以改成「直接执行」:提案提交后不等确认。
+- **它跑在哪**:调度会话跑在桌面 Orca 的浮动伪工作区里(上游浮动工作区只在本机,结构化聊天必须有工作区 id)。手机经配对桌面;局域网网页客户端没有调度会话(上游不允许它在浮动工作区里开会话),只能直接派活。它派活到 SSH 主机或配对服务器时,靠 CLI 的 `--host` 和 `--environment` 指定目标,任务写进拥有工作区的那台 Orca 的库。
+- **需要补的 CLI**(§11 盘点结论):
+ - `orca task create --parent`:RPC 已有,只缺 CLI;
+ - `orca task link`:关联工作区,RPC 已有;
+ - `orca task progress --file`:回写进展,RPC 和事件类型都要新加;
+ - `orca task update --title`:改标题;
+ - `orca dispatch propose --file`:提交提案。
+ - 开会话复用现有的 `orca agent launch` 和 `orca worktree create --agent --prompt`;CLI 不能建文件夹工作区,派活到文件夹项目只能选已有的文件夹工作区(§10)。
+
+### 6.5 对话本身怎么存
+
+- 通用对话有自己的历史列表,按天分组;每段对话里能看到它派出的任务卡。
+- 对话不是任务。但每个由对话派出的任务,时间线第一条都是「由对话派出」,点开能回到那段对话。
+- 只聊天、没派活的对话,不留下任何任务。
+- 上游没有会话级的自定义元数据,「这段对话派出了哪些任务」存在本功能的库里(对话的会话 id 对任务 id),不改上游 `AgentSessionRecord`。
+- 派活到别的任务库(orcad 主机、配对服务器)时,任务在那台库里,对话记录在桌面;对方库里的「由对话派出」只带桌面的对话 id,在那台 Orca 上看不到那段对话。
+
+## 7. 进度与摘要
+
+### 7.1 任务说明与回写约定(参考 Multica)
+
+从任务启动的每个会话,Orca 都在第一句指令后附一段任务说明。用户可以在设置里关掉;说明本身做成 skill,按需加载,免得提示词越写越长。内容:
+
+- **任务**:任务编号,并告诉智能体「需要最新内容时运行 `orca task show 编号`」。指令里给的标题和描述只是开工那一刻的快照。编号直接给,不让它靠 `orca task current` 猜:`task current` 按工作区匹配,在子任务的会话里会返回父任务;它要改成先按会话关联找、找不到再按工作区(REQ-019)。
+- **标题**:由对话派出的任务,标题是正文第一句;开工后用 `orca task update --title` 改一个不超过 30 字的短标题。
+- **回写**:每一轮结束时,用 `orca task progress 编号 --file 摘要文件` 写一条结果。
+ - 结果分三段:做了什么、结论或产出、下一步或需要你什么。
+ - 终端里说的话用户看不到。
+ - 中途进展不用写。这条照搬 Multica:它规定每轮只发一条结果,过程靠状态和日志呈现。
+- **卡住时**:写明原因,然后结束这一轮。
+- **回写失败时**:主机不在联系时命令会失败(例如直连 SSH 的工作区,任务在桌面的库里,桌面关了就写不进去)。把摘要写到工作区里的文件,继续干活,不重试、不等待。
+- **状态**:
+ - 状态真的变化了才写;只是回答问题时不改状态;
+ - 交付最多到「待验收」,「完成」由人在界面上点;
+ - 来自 CLI 的写入不能进入已完成类别,CLI 会拒绝并写明「完成要在界面上点」。
+- **衍生问题**:发现这次不处理的问题,用 `orca task create --parent 编号` 记下。
+- **正文写文件**:回写的正文先写成 UTF-8 文件再提交。原因是参数里内联的多行文本会被 shell 转义弄坏,Windows PowerShell 还会把中文变成问号。
+- **报错写给智能体看**:CLI 报错时告诉它下一步该做什么。例如状态被人改过时,说明是谁、改成了什么,并让它这一轮不要再改状态,改为留一条评论。
+
+### 7.2 任务卡上的「最新进展」取什么
+
+按优先级取:
+
+1. 最近一条智能体回写的进展;
+2. 没有回写时,取「轮次结束」记录:拥有会话的主机在会话状态变成 done 时,把 hook 上报的最后一句话(`lastCompletedAssistantMessage`)落进任务库,每个会话只保留最近一条,卡上标注「最后一句话」。现在这句话只在主机内存里(`local-task-pane-memory.ts`),重启、离线后就没了,任务卡上也不显示,所以要落库(2026-11-02 评审定);
+3. 都没有时,只显示会话状态,比如「工作中 · 12 分钟」。
+
+父任务没有自己的进展时,取子孙里最近一条,并标明来自哪个子任务(§5.1)。
+
+### 7.3 「生成摘要」(可选,后做)
+
+- 任务详情的进展卡上有「生成摘要」按钮,点了才生成。
+- 生成方式复用 Orca 生成 commit 信息和 PR 描述的那条管线,也就是用你配置的智能体 CLI 一次性生成。
+- 读的是这个任务下的会话记录。生成结果是一条来源为「Orca 生成」的进展。
+- 默认不自动生成:会读会话内容,也要花模型调用。要不要自动生成,见 §12。
+
+## 8. 验收与打回
+
+```mermaid
+stateDiagram-v2
+ direction LR
+ state "未开始" as todo
+ state "进行中" as doing
+ state "被卡住(会话在等你)" as blocked
+ state "待验收" as review
+ state "完成" as done
+ todo --> doing: 开工(派活或开会话,规则 1)
+ doing --> review: 智能体回写「可以验收」,或 PR 合并(规则 2 可配置)
+ review --> done: 你点「通过」
+ review --> doing: 你点「打回」并写原因,原因发给会话
+ doing --> blocked: 会话在等你
+ blocked --> doing: 你回答
+```
+
+- **待验收的任务**进入首页「需要你」。验收看结果,没有「完成标准」(D-014)。卡片上写明:
+ - 合入位置;
+ - 改动和 PR;
+ - 最新进展里智能体的自检结论(任务说明要求它在交付那一轮写清楚验证了什么、怎么验的);来源(哪个会话、第几次回写)可以点,打开那个会话的速览,验收时可以直接追问。追问不改任务状态:验收条留着,会话工作时验收条顶部显示「某智能体工作中」,它再回写时自检一行跟着更新;「通过」「打回」随时可点(2026-11-03 补:原型里待验收看不到会话,是遗漏);
+ - 在哪里验收(项目里配置的一句验收说明)。
+- **打回**:原因记成一条评论,任务回到进行中类别的第一个状态。如果有「做完一轮」的会话,就把原因作为下一句指令发过去(REQ-015 的规则,多个时都发);没有能回复的会话时,一键用打回原因加任务说明开一个新会话,不只是提示(2026-11-02 评审改)。
+- **PR 合并后移到哪个状态**:在项目设置里选,可以是完成、待验收或不动。这一条借鉴 Multica,修订 REQ-013 的规则 2。规则当前的判定显示在待验收卡上,例如「PR #231 合并后按项目设置进入待验收」「项目关掉了这条规则」。规则只由桌面窗口读到 PR 状态时执行,只用手机的用户要等桌面打开(REQ-013)。
+
+## 9. 与现有功能的关系
+
+| 现有 | 在新方案里 |
+| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 任务页(REQ-006) | 成为首页;顶部加派活输入框,卡片加「最新进展」 |
+| 开始做、开会话(REQ-009、REQ-010) | 合进「新建任务」和任务详情;入口保留 |
+| 需要你(REQ-014) | 首页置顶分组,新增「待验收」(待确认) |
+| 侧栏按任务分组、会话 chip(REQ-016) | 保留;任务模式上线后评估「按任务分组」是否下线;chip 的弹层加最新进展 |
+| Cmd+J(REQ-007) | 保留;加一个「派活…」动作,跳到首页聚焦输入框。上游调色板只能加动作,不解析 @ |
+| 新建工作区「创建自 → Local」 | 保留;这是先有工作区、再挂任务的路径 |
+| Goal(目标模式) | 任务上「设为目标」:验收文档在 Goal 里单独写(任务没有完成标准,D-014),守卫的结论作为进展回写。Goal 只绑终端(已核实:绑定契约只有 terminal),聊天型会话的「设为目标」置灰并写原因 |
+| Automations | 后续可以「定时派活到任务」:每次运行都建或续一个任务。这次不做 |
+| 浮动终端 | 调度会话参照它的「固定伪工作区 + 指定目录」做法,但不复用浮动终端的界面 |
+| 手机首页本地任务卡 | 首页不换;卡片加「需要你」和最新进展;派活输入框放本地任务页顶部 |
+| REQ-020~REQ-025 草稿 | 并入本方案:§7 回写、§8 验收、§6.3 衍生任务;完成标准(REQ-021)取消(D-014)。附件和截图(REQ-020)放到后面的切片 |
+
+## 10. 跨主机、手机、文件夹工作区、新旧版本
+
+- **SSH 主机和配对服务器**:
+ - 派活的目标主机由 @ chip 决定,任务写进拥有那个工作区的 Orca 的库(D-008),会话在那台主机上启动。
+ - 主机不在联系时,不能派活也不能速览,写明原因;恢复联系后只重读一次。
+ - 调度会话跑在客户端连接的那台 Orca 上,用 CLI 的 `--host`、`--environment` 指定目标。
+- **手机**:
+ - 首页有派活输入框,@ 候选来自配对桌面能看到的主机;
+ - 速览用底部抽屉;
+ - 发起会话一律直接发送(D-010)。
+- **文件夹工作区**:可以 @、可以绑定。提及文件夹「项目」时,没有「新建工作区」,改为选择已有的文件夹工作区。
+- **直连 SSH(经 relay,主机上没有 orcad)**:任务在桌面的库里(REQ-018 补记)。智能体在 SSH 主机上跑 `orca task progress`,经 relay 回桌面写;桌面关着或断线时写入失败,任务说明里要求它写文件继续、不重试(§7.1)。手机上要能看出「桌面的库」和「主机自己的库」的区别:前者桌面关了就什么都看不到。
+- **新旧版本混用**:
+ - 新增的字段都是可选的;新的事件类型(progress、dispatch、acceptance)旧桌面和旧手机必须忽略而不是报错,列为开工前验证。
+ - 新 RPC 和新 CLI 按能力位 `local-tasks.v2` 探测,不靠版本号判断。旧主机不支持 `progress` 时,任务卡退回显示「最后一句话」;不支持派活需要的能力时,候选里置灰,写明「更新那台主机的 Orca」。
+
+## 11. 现有机制盘点(2026-10-30)
+
+| 需要的能力 | 现状 | 可以复用 | 缺口 |
+| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 不绑工作区的对话 | 没有。最近的是浮动终端:固定伪工作区 `global-floating-terminal`,只在本机(`src/shared/constants.ts`,`src/main/ipc/floating-workspace-directory.ts`) | 伪工作区和目录的做法 | 调度会话本身;远程主机上的调度 |
+| 智能体建工作区、开会话 | `orca worktree create --agent --prompt --host`;`orca agent launch --worktree`;`orca terminal send/read`;`orca orchestration worker-start/worker-read`(`src/cli/specs/`) | 全部 | 给聊天型会话发消息的 CLI |
+| 智能体读写任务 | `orca task list/show/current/comment/status`(`src/cli/local-tasks/local-task-command-specs.ts`) | 读、评论、改状态 | `task create`(RPC 已有)、`task link`(RPC 已有)、`task progress`(RPC 和事件类型都没有)、`task update --title`、`dispatch propose`;拒绝 CLI 写入「完成」(现在 `task status 编号 completed` 能写进去);`task current` 改为先按会话关联找 |
+| @ 提及 | 原生 chat 只能提及文件;新建工作区的 @ 只能关联 issue 或 PR | 弹层组件 | @工作区、@仓库、@任务、@主机 |
+| 进度信号 | hook 上报 `lastAssistantMessage`、`prompt`、`terminalTitle`、`interactivePrompt`、状态历史(`src/shared/agent-status-types.ts`);任务进展目前直接取最后一句话 | 最后一句话作为兜底 | 回写记录;最后一句话只在主机内存(`local-task-pane-memory.ts`),要落成「轮次结束」记录 |
+| 模型生成文字 | commit 信息、PR 字段、分支名、会话名,都走 `runGenerationRequest`,调用用户配置的智能体 CLI(`src/main/text-generation/`) | 「生成摘要」和标题生成 | — |
+| Goal | 守卫智能体加验收文档,按「一轮结束、终端安静、每 15 分钟」唤醒,用 `orca terminal send` 驱动终端(`src/main/goals/`,`goal-mode/cli/`) | 「设为目标」 | 和任务关联;聊天型会话不支持(已核实),置灰 |
+| Automations | 定时或手动,无界面地建工作区并启动智能体(`src/main/automations/`) | 后续「定时派活」 | 和任务关联 |
+| 手机首页 | 统计卡、主机列表、本地任务卡、快捷操作,没有对话入口 | 本地任务卡 | 派活输入框、速览 |
+
+## 12. 已定(2026-11-01 用户:都按建议,括号里是采纳的做法)
+
+1. **首页**:任务首页是否取代桌面的默认首页?(默认取代,可以在设置里改回)
+2. **通用对话的两条路径**:是否同意「直接派活」和「调度智能体」并存?(同意。只提及一个工作区时直接派活,其余情况走调度智能体)
+3. **派活前要不要确认**:(直接派活不确认,10 秒内可以撤销;调度智能体要新建东西时先出提案卡,等你确认)
+4. **任务说明默认开还是关**:(默认开,用户可以关)
+5. **摘要来源**:只用智能体回写加最后一句话,还是允许 Orca 读会话、用模型生成摘要?(两者都要。生成摘要要用户点了才做,不自动)
+6. **智能体能不能标「完成」**:(不能。智能体最多推进到「待验收」,CLI 拒绝写入「完成」)
+7. **「需要你」是否新增「待验收」**:这一类不是看智能体状态判定的,和 D-3 冲突。(加入,单独分组显示)
+8. **原则修订**:原则 2「只做连接,不做流程」改为本方案 §3 的 7 条,D-1 同步修订。(同意修订)
+9. **派活到已属于别的任务的工作区**:(建成那个任务的子任务,会话开在同一个工作区)
+10. **调度会话用哪个智能体、跑在哪**:(你的默认智能体,跑在客户端连接的那台 Orca 上)
+11. **是否新增全局快捷键**,用来聚焦派活输入框。之前 REQ-007 定的是不新增。(不新增;派活从首页输入框或 Cmd+J 输入 @ 开始)
+
+## 13. 分期
+
+| 切片 | 内容 | 用户能感到的变化 |
+| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
+| 3a 任务为主的骨架 | 任务首页与任务模式侧栏、任务视图;新建任务两屏流程;任务说明与 `orca task progress`(库 schema v6、进展事件类型、RPC、能力位 `local-tasks.v2`);轮次结束记录;任务卡和详情的最新进展;会话速览 | 进来先看到任务;建任务时就开工;不进会话也知道进度 |
+| 3b 直接派活 | 派活输入框;@工作区、@仓库、@任务、@主机;直接派活(单个和多个工作区);撤销;`task create` 和 `task link` CLI | 一句话把事派出去 |
+| 3c 调度智能体与验收 | 调度会话、提案卡、对话历史;待验收、通过和打回;规则 2 可配置;衍生任务 | 复杂请求可以交给对话;完成由你验收 |
+| 3d 后续 | 生成摘要;截图附件与随手记;Goal 与任务打通;定时派活 | 按需要再排 |
+
+每个切片都要覆盖:本机、SSH、配对服务器、手机,以及文件夹工作区。验收方式照现有约定:逐屏截图,并附计数证据。
+
+3a 开工前验证两项:旧版本桌面和手机收到不认识的事件类型要忽略而不是报错;任务视图按任务过滤标签栏时,终端不发生重新挂载。
+
+## 15. 2026-11-02 评审修订清单
+
+评审稿 `任务为主的工作流方案评审.md` 的改动已并入本稿。用户全部采纳,只改掉一处:
+
+- **改掉的**:评审建议「任务模式里点会话就是标签,不弹抽屉」。用户不同意:后续要迁移到以任务为主的模式,任务视图不能长成工作区的样子。最终规则见 §5.4 和 §5.6:点任务开概览,点会话开概览加速览抽屉,完整会话才是标签。
+- **采纳的**:派活到正忙或空闲工作区的分岔(§4、§6.3);@任务、@主机 的含义与「只问」(§6.2);项目归属与首次使用(§4);新建任务两屏(§5.2);发送方式默认直接发送(§5.2);任务视图的实现口径与子任务会话(§5.6);标题不另起模型调用(§6.3);轮次结束记录(§7.2);CLI 写入规则与 `task current`(§7.1);「按任务分组」评估下线(§5.6);手机不换首页(§5.1);Cmd+J 只加动作(§9);提案由 Orca 执行(§6.4);打回一键开新会话、规则判定显示(§8);取消任务时停会话勾选(§5.3);父任务进度汇总(§5.1);撤销三条(§6.3);隐藏来源规则(§5.6);直连 SSH 回写与能力位(§10)。
+- 需求文档同步:REQ-007、009、010、011、012、013、014、015、016、017、018、019、022、024 修订,新增 REQ-026~REQ-030。
+- **原型第三轮之后用户再定两条**(2026-11-02,D-014):速览就是对话的小边框模式,浮层窄态也完整可操作,没有单独的回复框和「只有做完一轮能回复」的限制(§5.4);不要「完成标准」这个东西,REQ-021 取消,字段从任务上去掉,已有内容并入描述,验收看结果(§5.3、§8)。
+- **原型第二轮之后用户再定两条**(2026-11-02,D-013):看会话改成同一个右侧浮层的三级(速览 → 展开 → 全屏),完整会话也浮在任务上面(§5.4);概览页减信息,只剩头部、「现在」卡、描述与标准与子任务、动态四块,右栏取消,系统事件折叠,去掉评审注释(§5.3)。
+
+## 14. 成功标准
+
+- 从「想到一件事」到「智能体在对应工作区开始干活」:派活输入框里 1 次输入,新建任务对话框 1 次提交。
+- 不打开任何会话,也能在首页看出每个进行中任务做到了哪一步、是否需要你。
+- 每个会话都属于某个任务;每次派活、回写、状态变化,都能在任务时间线上找到。
+- 主机断开期间,任务首页和速览不发出任何读请求;恢复后只重读一次。
+
+## 16. 调度会话即总管(2026-11-05,D-015)
+
+用户 2026-11-05 要求:在本方案上先做一个用于分配、管理任务与会话、能感知的总管(super agent),任务和会话复用已有的。结论是把 §6.4 的调度会话升格为总管。本方案已经让任务成为记录:
+
+- 任务就是总管的账本;
+- 提案就是它的审批;
+- 回写进展和轮次结束就是它的回执。
+
+只补「感知」,再加几条命令。
+
+### 16.1 它是什么
+
+- **常驻、唯一**:同一 profile 只有一个总管,在桌面的浮动伪工作区里运行;对话历史里的每段对话都是它的一段。
+- **Claude 用终端**:Claude 总管以终端启动,对话页用聊天视图显示同一个终端会话。经 Claude Agent SDK 的结构化聊天,按 Anthropic 文档不能用 claude.ai 订阅。Codex 可以用结构化聊天。
+- **不动手**:它理解、分配、跟进、汇报,不写代码、不在工作区里动手;要读代码的问题,它提一份只读调查任务。
+
+### 16.2 给它的接口:任务和会话都复用
+
+| 要做的事 | 接口 | 来自 |
+| -------------------- | --------------------------------------------------------------------------------------------- | ------------------- |
+| 看任务与进度 | `orca task list` / `show`(含最新进展、会话、动态);`--all-hosts` 合并所有在联系的库 | 已有,3c 加参数 |
+| 看谁在等你 | `orca task needs-you`:会话在等你与任务在等你 | 判定已有,3c 加 CLI |
+| 看会话 | 挂在任务上看:智能体、状态、判定、最后一句话、在等你什么;没挂任务的会话用 `orca worktree ps` | 已有 |
+| 留言、改标题、改状态 | `orca task comment` / `update --title` / `status`(标不了完成) | 已有与 3a |
+| 分配和驱动 | `orca task propose`:建任务并开会话、建子任务、追加一句、停会话、挂工作区、记成任务 | 3c,提案项类型扩充 |
+| 感知 | `orca task inbox`、`watch` / `unwatch` | 3c 新增 |
+
+凡是新建或驱动,都先出提案卡,你确认后由 Orca 执行(原则 2);「停会话」永远要确认。它不替你批准权限、不替你回答会话的提问,这些在速览里由你处理。
+
+### 16.3 感知:盯着的任务、收件箱、唤醒
+
+- **盯着的任务**:
+ - 它提案建出的任务,确认执行后自动加入;
+ - 你说「帮我盯着 ORC-39」时,它把这个任务加入;
+ - 任务完成或取消后自动移出。
+- **收件箱**:盯着的任务上新发生的、确定的事——回写进展、做完一轮、状态变化(含待验收)、进入或离开「等你」、会话结束、提案被确认或去掉。
+- **唤醒**:有新动态、且总管空闲(做完一轮)时,Orca 往它的会话里写一行提示,每次空闲最多一次。它在工作、在等你,或你正在它的输入框里打字时不写。设置里可以关。
+- **被唤醒后**:它读收件箱,只说需要你知道或决定的,必要时附一张提案卡。例如「ORC-39 已定位到重连逻辑,下一步写复现测试;ORC-40 在等你批准(速览)」,下面附「让 ORC-39 继续」的提案。
+- **记得住**:问它「ORC-39 做到哪了」,它查任务记录回答并写明时间,不靠对话记忆;它被压缩、重启过也一样。
+
+### 16.4 跨主机
+
+- 任务在哪台 Orca 的库里,盯着的记录和已读位置就存在那里;orcad 主机和配对服务器的库只带桌面的对话 id。
+- 主机不在联系时,那台库的任务标「无法确认」,恢复联系后只重读一次。
+
+### 16.5 不在这一版
+
+定时复查、盯 PR 评审或监控这类外部状态、发飞书与合 PR 这类对外动作、按委托自动执行。这些在 super agent 功能的后续切片里做。
+
+需求见 REQ-030 的 2026-11-05 增补与 REQ-031;技术方案见 M13「调度会话即总管」。
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/\344\273\273\345\212\241\344\270\272\344\270\273\347\232\204\345\267\245\344\275\234\346\265\201\346\226\271\346\241\210\350\257\204\345\256\241.md" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/\344\273\273\345\212\241\344\270\272\344\270\273\347\232\204\345\267\245\344\275\234\346\265\201\346\226\271\346\241\210\350\257\204\345\256\241.md"
new file mode 100644
index 000000000000..d454ea240460
--- /dev/null
+++ "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/interaction/\344\273\273\345\212\241\344\270\272\344\270\273\347\232\204\345\267\245\344\275\234\346\265\201\346\226\271\346\241\210\350\257\204\345\256\241.md"
@@ -0,0 +1,303 @@
+---
+title: 任务为主的工作流:方案评审
+status: completed
+created: 2026-11-02
+---
+
+# 任务为主的工作流:方案评审
+
+评审对象:`任务为主的工作流产品方案.md`(含 2026-11-02 的 §5.6 任务模式)和原型 `prototype-task-first`(桌面 d1~d14、手机 m1~m8)。
+
+评审依据:需求 REQ-001~REQ-025、journal D-1~D-011、Multica 调研报告 §7 和 §10、两份代码盘点(上游机制 15 项、local-tasks 现状 14 项,结论已并入 §5 和 §6,文件路径都相对仓库根目录)。
+
+## 0. 结论
+
+方向成立,原型能回答「打开先看什么、一句话怎么变成会话、不进会话怎么知道进度、完成由谁定」这四个问题。但方案还差三类东西:
+
+1. **用户故事只写了顺路,没写分岔**。派活到正忙的工作区、只想问不想派、第一次用还没有项目、父任务有多个子任务时进度怎么汇总、取消任务时会话怎么办,这些都没有写,原型里也就按最简单的一种做了。
+2. **几条动线在重做 Orca 已有的东西**。新建任务对话框里又画了一遍新建工作区;任务视图想自带一套标签页,而上游的标签必须挂在工作区上;速览抽屉、任务视图标签、工作区标签三种看会话的方式并存;Cmd+J 要认 @,而上游调色板没有这个扩展点。
+3. **几处文字和代码对不上**。工作区归属不限顶层;CLI 现在不拒绝「完成」;最后一句话只在内存里,重启就没了;路径 A 说「不多花一次模型调用」,但它的标题生成就是一次模型调用。
+
+先要定的 10 条见 §1,全部改动清单见 §7。
+
+## 1. 先定的 10 条
+
+| # | 问题 | 建议 | 详见 |
+| --- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
+| 1 | @ 一个已有会话在跑的工作区,方案和原型都直接再开一个会话,两个智能体同时改一个工作区 | 那个任务有空闲会话(做完一轮、等你)时,二选一:发给它当下一句,或建子任务另开会话;都在工作中时才另开,并在卡上写明 | §2 S3 |
+| 2 | @任务、@主机 在候选里,但路径 A 不处理它们,原型悄悄转给了调度智能体;「@工作区 + 一个问题」也会被当成派活 | @任务 = 在它下面派活;@主机 只用来缩小候选范围,不单独成为目标;加「只问」模式(不建任务,带着工作区上下文交给调度会话) | §4 I1 |
+| 3 | 派活要往哪个项目里建任务?仓库可以挂在多个项目下,也可以一个都没有;第一次用根本没有项目 | 定一条归属规则,并把「没有项目」做成派活里的一步(确认后按仓库建项目,和 REQ-001 的推荐一致) | §2 S11 |
+| 4 | 新建任务对话框把新建工作区(仓库、主机、名称、分支)又画了一遍,和上游 composer 平行 | 保留「在哪做」三选一,选「新建工作区」就接上游 composer(创建自预选这个任务,智能体和第一句指令上游本来就有);一次流程两屏,不重做 | §3 F3 |
+| 5 | 任务视图想要自己的标签集合,而上游 `Tab.worktreeId` 必填,终端换容器挂载还会撞上重连空白那类问题 | 任务视图 = 该任务工作区的 `WorktreeSplitSurface`,加一个「概览」伪标签,标签栏只按任务过滤,不另造标签集合;子任务的会话一起显示(和 §5.3、REQ-012 一致,改掉 §5.6 的那句) | §5.1 ② |
+| 6 | 路径 A 的标题用生成管线起名,就是一次模型调用(`runGenerationRequest` 调的是用户的智能体 CLI),而且上游自动命名只在本机 | 路径 A 不额外起名:标题取第一句(截 60 字),任务说明里让干活的智能体顺手 `orca task update --title` 改一个短标题;用户随时可改 | §6 ④ |
+| 7 | 「最后一句话」只在主机内存里,重启、离线后任务卡就空了;回写进展需要新的事件类型和 RPC,新旧版本要能混用 | 主机在会话每轮结束时把最后一句话落成一条「轮次结束」记录(只保留每个会话最近一条);新增事件类型走能力位,旧客户端对不认识的事件类型要能忽略 | §5.3 |
+| 8 | 「CLI 拒绝写完成」现在没有,也没法区分是人还是智能体在敲 CLI;`orca task current` 在子任务的会话里返回的是父任务 | 规则写成「来自 CLI 的写入不能进入已完成类别,完成只在界面上点」;`task current` 先按会话关联找任务,找不到再按工作区 | §6 ②、③ |
+| 9 | 看会话有三种方式:速览抽屉、任务视图标签、工作区标签;侧栏又有「按任务分组」和「任务模式」两种按任务看的办法 | 任务模式里只用标签,速览只留在首页列表和手机;「按任务分组」在任务模式上线后评估下线(这是登记过的功能,要走下线流程) | §3 F1、F2 |
+| 10 | 手机首页被整个换成任务首页;本地任务来源在设置里隐藏后,新入口怎么办没写;Cmd+J 认 @ 要改上游调色板 | 手机保留上游首页,派活输入框放在本地任务页顶部;所有新入口都跟随 REQ-008 的隐藏规则;Cmd+J 只加一个「派活…」动作去聚焦输入框,不解析 @ | §4 I10、I12 |
+
+## 2. 用户故事:补什么
+
+方案 §1 把用户的事归成「说要做什么、需要时回答、验收」三件,但只有顺路的故事有动线。下面是评审时按「用户会怎么做」过了一遍,标出缺口。
+
+| 编号 | 故事 | 方案现状 | 缺口与建议 |
+| ---- | ------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| S1 | 早上打开,先看有哪些事在等我 | 首页「需要你」,含待验收 | 够用。条目里有智能体回写的「需要你什么」时,显示它,而不是最后一句话 |
+| S2 | 一句话派活到空闲的工作区 | 路径 A1 | 够用。补两点:撤销不回滚智能体已经改过的文件,卡上写明;撤销后编号不复用(REQ-002),会留一个空号,接受即可 |
+| S3 | 派活到已经有会话在跑的工作区 | 路径 A2:建子任务,再开一个会话 | **缺**。原型里 feat-reconnect 的 Claude 还在工作中,派活后变成两个智能体同时改一个工作区。Multica 的做法是人的话路由回同一个智能体、接着上次会话。建议见 §3 F8 的分岔图:任务有空闲会话时二选一,默认发给它;都在工作中时另开,并提示 |
+| S4 | 在工作区里顺手开了会话,后来想记成任务 | §3 原则 1 写「提示记成任务」 | **矛盾**。REQ-011 在 2026-10-30 定了「工作区没关联任务时,会话头部不显示 chip,不给每个会话头部加入口」。二选一:原则 1 去掉这句;或者只在任务模式下、侧栏「未关联任务的会话」组里提供「记成任务」。建议后者,入口只在任务模式里 |
+| S5 | 在任务下再开一个会话(换智能体,或给子任务) | 任务视图「+」、详情「开会话」 | 没写「+」开出来的会话带什么指令、属于哪个子任务。建议:「+」等于 REQ-010 的开会话,指令默认「按任务说明继续」,弹层里可以选子任务;任务没有工作区时「+」就是开始做 |
+| S6 | 智能体问我问题,我回答一句 | 速览回复框、任务视图标签 | 够用。注意 D-007:回复只对「做完一轮」开放,等你批准、等你回答要进会话点;原型 d8 让「等你回答」直接在速览里发送,与 REQ-015 不符,要改成「打开会话」 |
+| S7 | 做完了,我验收 | §8 | 「打回」在会话不是「做完一轮」时只「提示你开新会话」,建议改成一键:用打回原因加任务说明开新会话 |
+| S8 | 不想做了:取消或删除一个正在跑的任务 | 没写 | **缺**。REQ-004 的删除不动会话。建议:取消或删除时,如果任务下有在跑的会话,确认框里给「同时停止 N 个会话」的勾选,默认不勾 |
+| S9 | 一个任务拆了三个子任务,各自在跑,我想看整体做到哪了 | 任务卡只显示自己的最新进展 | **缺**。父任务自己往往没有会话。建议父任务卡显示「子任务 1/3 待验收 · 最新:ORC-43 …」,取子孙里最近一条进展,并标明来自哪个子任务 |
+| S10 | 我只想问一句,不想建任务:「@feat-x 这个分支改了什么」 | 没写;原型会直接派活 | **缺**。规则派活没法分辨问和做。建议输入框加「只问」模式(见 §4 I1):不建任务,把工作区作为上下文交给调度会话只读回答 |
+| S11 | 第一次用,还没有项目;或者仓库没挂到任何项目 | 没写 | **缺**。任务要有项目才能建。建议规则:工作区所在仓库挂在唯一一个项目下就用它;挂在多个项目下用最近派过活的那个,卡上可改;一个都没有时,派活卡先出「为仓库 X 建项目?」一步,确认后建项目再派(和 REQ-001 的推荐逻辑一致) |
+| S12 | 主机断了,我在手机上看 | §10 | 够用。补一句:直连 SSH 的工作区,任务在桌面的库里(REQ-018 补记),桌面关着时手机什么也看不到,这和 orcad 托管的主机不同,手机上要能看出区别 |
+| S13 | 我想回看:这件事当时是怎么派的、对话里说了什么 | §6.5 对话历史,时间线第一条「由对话派出」 | 够用 |
+| S14 | 同一件事要在两个工作区做 | 路径 A 多 @:父任务加子任务 | 够用。补:两个工作区在不同主机上时,父任务建在哪个库里要定(建议建在第一个 chip 所在的库,另一个子任务在另一台库里只记引用;或者干脆要求多 @ 的工作区在同一台库,否则走调度会话) |
+| S15 | 智能体做完一轮但没有回写 | §7.2 回退到最后一句话 | 依赖 §5.3 的落库,否则重启后什么都没有 |
+
+## 3. 动线
+
+### F1 按任务看的三个入口重叠
+
+现在会同时存在:任务页(带二级导航列)、任务模式侧栏(同一列搬进侧栏)、工作区模式下的「按任务分组」(REQ-016,已上线)。方案 §5.6 说三者都保留。
+
+- 建议:任务模式下任务页不再有二级导航列(方案已写);「按任务分组」只在工作区模式里有,任务模式上线后看使用情况评估下线。它是登记过的功能,下线要改注册表和 journal,不要「顺手去掉」。
+- 任务模式的开关存 fork 自己的偏好键(和「按任务分组」一样,`local-task-sidebar-grouping.ts` 的做法),不碰上游 `sidebarBody`(它只在内存,不持久化)。
+
+### F2 看会话的三种方式
+
+速览抽屉(§5.4)、任务视图标签(§5.6)、工作区标签,三者内容都是同一个终端或聊天。建议定规则:
+
+- 任务模式:点会话就是标签,不弹抽屉;概览标签里会话列表的「速览」改成「切到标签」。
+- 工作区模式的任务首页和任务详情:用速览抽屉,因为这时主区是任务页,不该切走。
+- 手机:底部抽屉,一键进完整会话页。
+
+### F3 新建任务「一次完成三步」
+
+原型 d5 把新建工作区的仓库、主机、名称、分支都画进了任务对话框。上游 composer(`NewWorkspaceComposerCard.tsx`)还有基础分支、运行位置是否已配置、创建自、智能体、第一句指令等,这里重画就是一个阉割的平行实现,以后上游每加一项都对不上。
+
+建议改成「一次流程,两屏」:
+
+1. 新建任务屏:标题、描述、完成标准,加「在哪做」三选一(已有工作区、新建工作区、稍后再定)。
+2. 选「已有工作区」:当场选,建完停在任务视图,开会话走 REQ-010。
+3. 选「新建工作区」:建好任务后直接打开上游 composer,「创建自」预选这个任务(REQ-009 已经这样做),智能体和第一句指令在 composer 里填。创建完成后关联并停在任务视图。
+
+这样「建任务时就开工」的体验不变,代码上只多一次串联。
+
+另外要定一条:**从任务发起的会话,第一句指令默认直接发送还是放草稿。** 现在三处不一致:REQ-009 开始做是草稿(和上游从 issue 开工一致);代码里的开会话对话框默认立即发送(`LocalTaskSessionDialog.tsx:72`);原型 d5 默认直接发送。建议统一成「默认直接发送,可改草稿」,这改掉 REQ-009 在 2026-10-30 的决定,要用户点头。
+
+### F4 派活后落在哪
+
+路径 A 派活后停在首页,卡片在输入框下方,这没问题。任务模式下建议再做一件事:新任务在侧栏树里出现并短暂高亮,但不自动打开,免得打断连续派活。
+
+### F5 调度会话的提案卡由谁执行
+
+方案没写提案卡的数据从哪来、确认后谁执行。两种做法:
+
+- 让智能体输出一段约定格式的文字,Orca 解析成卡片。脆弱,而且确认后还得再和它说一轮。
+- **建议**:给调度智能体一条 `orca dispatch propose --file 提案.json`,提案存进任务库(或对话记录),聊天视图按它渲染卡片;用户确认后由 Orca 直接执行建任务、关联、开会话,不再经过智能体。智能体只负责理解和提方案,这和 §6.4「自己不做具体工作」一致,也省一次模型调用。
+
+调度会话本身:上游的浮动伪工作区只在本机,结构化聊天必须有 `workspaceId`,配对网页客户端不能在浮动工作区里开会话(`client-creation-action-policy.ts:62`)。所以调度会话只在桌面上有,手机和网页客户端经桌面。方案 §6.4 说的是这个意思,但要写明「网页客户端没有」。
+
+### F6 打回
+
+「打回」原因作为下一句指令只在会话「做完一轮」时可发(D-007)。其他情况方案只提示「开新会话」。在以任务为主的动线里,打回是高频动作,建议做成一键:没有可回复的会话时,直接用打回原因加任务说明开一个新会话,和 S7 一致。
+
+### F7 PR 合并后进待验收
+
+规则 2 只由桌面窗口读 PR 时执行(REQ-013)。只用手机的用户要等桌面打开。方案 §8 没提,补一句,并在待验收卡上显示规则当前的判定(Multica 借鉴清单第 7 条)。
+
+### F8 路径 A 的分岔
+
+把 §1 第 1~3 条合起来,路径 A 的分岔应该是:
+
+```mermaid
+flowchart TD
+ A["输入框:chip 加一句话"] --> B{"chip 是什么"}
+ B -->|"没有 chip,或只问模式"| S["调度会话(路径 B),只读回答或提方案"]
+ B -->|"@主机"| S2["主机只缩小候选范围,不单独派活"]
+ B -->|"@仓库"| P{"仓库挂在哪个项目下"}
+ B -->|"@任务"| T["在该任务下建子任务,会话开在它的工作区;没有工作区就先开始做"]
+ B -->|"@工作区"| P
+ P -->|"唯一一个"| W{"工作区属于任务吗"}
+ P -->|"多个"| W
+ P -->|"没有"| C["卡上先出一步:为这个仓库建项目?确认后继续"]
+ C --> W
+ W -->|"不属于"| N["建任务、关联、开会话"]
+ W -->|"属于 T"| L{"T 下有空闲会话吗"}
+ L -->|"有:做完一轮或等你"| D["二选一:发给那个会话当下一句(默认),或建子任务另开会话"]
+ L -->|"没有,或都在工作中"| N2["建子任务另开会话,卡上写明同一工作区里还有会话在工作"]
+```
+
+「发给那个会话」时也要留痕:在 T 的时间线上记一条「由对话追加指令」,这样 S13 的回看仍然成立。
+
+### F9 撤销
+
+- 10 秒内撤销只停会话、删任务,智能体已经改的文件不回滚,卡上写明。
+- 撤销删掉的任务编号不复用(REQ-002),接受。
+- 撤销要在远端库上删任务、在远端主机上停会话,两步都只在主机在联系时做;做不到时卡上写「某主机未连接,没有撤销」,不排队。
+
+## 4. 交互(按界面)
+
+### I1 派活输入框
+
+- @ 候选分组里有任务和主机,但路径 A 只处理工作区和仓库,原型把 @任务、@主机 都转给了调度会话,用户不知情。定义:
+ - @任务:在它下面派活(见 F8)。
+ - @主机:选中后候选只剩这台主机的仓库和工作区,不作为派活目标;chip 里不显示主机。
+- 「只问」模式:输入框右侧加一个切换(派活 / 只问),或用 `Shift+Enter` 发到「只问」。只问不建任务,把 chip 里的工作区作为上下文交给调度会话只读回答。这是规则,不是模型判断,和 §6.1「A 快、可预期」一致。
+- 回车派活是实际启动智能体的动作,和上游聊天「回车发送」一致,靠撤销兜底,不改。
+
+### I2 已派活卡
+
+- 10 秒倒计时结束后卡片保留,直到用户关掉或再派一条。
+- 标题变化:按 §1 第 6 条,不再「几秒后变成生成的短标题」;如果智能体改了标题,卡上更新即可。
+
+### I3 任务卡
+
+- 「最新进展」优先级按 §7.2,但第 2 级要靠 §5.3 落库才在重启和离线后可用。
+- 父任务按 S9 显示子孙汇总。
+- 悬停展开完整摘要,手机长按,原型已做。
+
+### I4 需要你
+
+- 条目正文:有回写的「需要你什么」时用它;没有才用最后一句话。
+- 「待验收」单独分组(已定)。「等你决定」(REQ-023)如果也做,和待验收放一组,叫「任务在等你」,和「会话在等你」区分。
+
+### I5 任务视图标签
+
+- 关闭会话标签 = 关闭会话,和工作区模式一样;两种模式下是同一个标签,一边关了另一边也没了。
+- 「+」按 S5。
+- 标签顺序按会话启动顺序,「概览」固定在最前。
+- 主机未连接时标签置灰并写原因(已定)。
+- 子任务的会话一起显示,标签上带子任务标题(改掉 §5.6「子任务的会话在子任务自己的视图里」)。
+- 任务没有工作区时只有「概览」,头部按钮是「开始做」。
+
+### I6 速览抽屉
+
+- 终端型:不要用 `terminal read --screen` 的文本快照,桌面上终端本来就挂在渲染进程里,用 Activity 页的做法把真实终端 portal 进抽屉(`activity-terminal-portal.ts`),颜色、滚动都在,也不多一次读取。
+- 聊天型:复用结构化会话视图的只读模式,底下接回复框。
+- 回复框只对「做完一轮」开放,其余显示「打开会话」(REQ-015)。原型 d8 要改。
+- 读取规则照 §5.4,不重试。
+
+### I7 验收条
+
+- 通过:移到已完成类别第一个状态(REQ-013 口径)。
+- 打回:移到进行中类别第一个状态,原因记评论,按 F6 发出或开新会话。
+- 显示规则 2 当前的判定(F7)。
+
+### I8 任务模式侧栏
+
+- 「全部」展开后是整棵任务树,可能很长。上游侧栏是虚拟列表(`worktree-list/rows/virtual-row-dispatch.tsx`,fork 已有接缝),任务树也用它,不另写滚动。
+- 树里的会话行显示状态点和一句话;会话行点开走 I5。
+- 「未关联任务的会话」组:任务模式下也要能看到那些没挂任务的工作区会话,否则用户在任务模式里找不到它们;这个组同时承担 S4 的「记成任务」入口。
+
+### I9 设置
+
+- 「首页」这一项的实现见 §5.1 ③;
+- 「任务说明」开关、「调度新建前先确认」、「规则 2 三选一」保留;
+- 所有新入口(任务模式按钮、首页、派活输入框、手机入口)跟随 REQ-008「本地任务来源隐藏」规则,方案要写一句。
+
+### I10 手机
+
+- 原型 m1 把上游首页整个换掉,主机列表挪到底部。方案 §5.1 写的是「本地任务卡升级为任务首页入口」,§9 写的是「升级为手机任务首页」,两处不一致。建议:上游首页不动,本地任务卡加「需要你」和最新进展;派活输入框放在本地任务页顶部。理由:手机的数据模型是「每台主机一页」,@ 候选和任务库都按主机来,输入框放在主机页下面天然对得上;也不用在上游首页开新接缝。
+- 手机上派活由配对桌面执行(已定),输入框上写明「由 某桌面 执行」。
+- 手机只直接发送(D-010),已定。
+
+### I11 通用对话视图
+
+- 对话视图(d9)要复用原生聊天的消息渲染,不另写聊天 UI;只有提案卡和已派活卡是本功能的组件。
+- 对话历史列表按天分组:上游没有会话级元数据,「这段对话派出了哪些任务」存在任务库里(会话 id 对任务 id),列表从本功能的库读,不改上游 `AgentSessionRecord`。
+
+### I12 Cmd+J
+
+方案 §9 写「输入以 @ 开头时直接进入派活」。上游调色板的扩展点只能加动作,不能加实体分区,认 @ 要改 `worktree-jump-palette-model.ts` 和输入解析。建议只加一个动作「派活…」,选中后跳到首页并聚焦输入框。§12 第 11 条已经说「派活从首页输入框开始」,这里对齐即可。
+
+## 5. 复用与兼容
+
+### 5.1 复用对照
+
+| 方案里的能力 | 上游或 fork 现状 | 复用方式 | 新接缝 |
+| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
+| ① 侧栏任务模式 | 侧栏主体在 `sidebar/index.tsx`,在 `WorktreeList` 和 `SidebarAgentsList` 之间二选一;标题行按钮在 `SidebarHeader.tsx`;行渲染走虚拟列表 | 主体再加一个分支渲染本功能的任务树;标题行加一个按钮;开关存 fork 偏好键 | `sidebar/index.tsx`、`SidebarHeader.tsx` 各 1 处 |
+| ② 任务视图与会话标签 | `Tab.worktreeId` 必填;`WorktreeSplitSurface(worktreeId)` 可以渲染任意工作区的标签;Activity 页用 portal 把别的工作区的终端挂进来 | 任务视图 = 任务工作区的 `WorktreeSplitSurface`,标签栏按任务过滤,加「概览」伪标签。不要像浮动终端那样再造一个伪工作区 id,那会让 tabs store 多一套归属 | 标签栏过滤 1~2 处;主区按视图切换 1 处 |
+| ③ 默认首页 | 上游没有首页概念,启动恢复上次 `activeView`(`active-view.json`),没有默认页设置 | 做成一个设置项「启动时打开任务页」,在 hydration 时把 `activeView` 置为 tasks 加本地来源。价值不高,接缝有限,也可以不做:用户上次停在任务页,下次就是任务页 | `ui-slice-hydration-sanitizers.ts` 或 `AppWorkspaceShell.tsx` 1 处,设置 1 处 |
+| ④ 派活输入框与 @ | 原生聊天的 @ 只能提及当前工作区的文件,触发和数据都在 composer 内部;弹层组件 `NativeChatAutocompleteMenus.tsx` 可单独用 | 输入框是本功能自己的组件,只复用弹层组件;手机用 sheet | 0(只 import) |
+| ⑤ 调度会话 | 浮动伪工作区只在本机;结构化聊天必须有 `workspaceId`;网页客户端不能在浮动工作区开会话 | 桌面上在浮动伪工作区里开结构化聊天;提案走 `orca dispatch propose`,Orca 执行(F5) | 0~1 |
+| ⑥ 回写进展 | 事件类型 15 种,没有 progress;没有对应 RPC;CLI 只有 5 个子命令 | 新事件类型 progress、dispatch、acceptance;RPC `events.addProgress`;CLI `task create / link / progress --file / update --title`;能力位升到 `local-tasks.v2` | RPC 目录 1 处(已有同类接缝) |
+| ⑦ 最后一句话 | `lastAssistantMessage` 只在主机内存(`local-task-pane-memory.ts`),不落库;任务卡上现在也不显示它 | 主机在会话状态变成 done 时,把 `lastCompletedAssistantMessage` 落成「轮次结束」记录,每个会话只留最近一条 | 0 |
+| ⑧ 标题与摘要生成 | `runGenerationRequest` 可复用,但每次都是一次智能体 CLI 调用;上游自动命名只在本机非 WSL、只对结构化聊天 | 路径 A 不起名(§1 第 6 条);「生成摘要」用户点了才做,新加一个 operation | `text-generation/` 新文件,0 接缝 |
+| ⑨ 速览抽屉 | `ui/sheet.tsx`,`AgentDashboardDrawer` 处理过 Esc 层级;终端可 portal | Sheet 加 portal | 0 |
+| ⑩ 撤销 | `activity-clear-completed.ts` 的 sonner action 加延迟删除 | 同一模式 | 0 |
+| ⑪ 开始做 / 新建工作区 | 开始做已复用上游 composer,「创建自 Local」已接;CLI `worktree create` 只能建 git worktree,文件夹工作区只有 RPC | F3 的两屏流程;调度会话派活到文件夹项目时只能选已有文件夹工作区(方案 §10 已写) | 0 |
+| ⑫ 开会话 | `launchLocalTaskSession` 包了上游 `planAgentSessionLaunch` 加 `launchAgentInNewTab`,本机、SSH、配对服务器都支持;主机拒绝开聊天时回退终端 | 直接复用;「+」和打回开新会话都走它 | 0 |
+| ⑬ 回复 | `sendLocalTaskReply` 调上游 `sendMessageToAgent`,只对「做完一轮」 | 速览、验收打回都走它 | 0 |
+| ⑭ Goal「设为目标」 | Goal 只绑终端(`GoalBindingSchema` 要 terminal),结构化聊天不支持;验收文档可由用户填或生成 | 聊天型会话的「设为目标」置灰并写原因;完成标准导出成验收文档放 3d | 0 |
+| ⑮ Automations | 只起终端,支持 SSH | 3d 再说 | — |
+| ⑯ 手机首页 | 上游首页没有输入入口;fork 的任务卡挂在 `MobileHomeListFooter.tsx` | 不替换首页,输入框放本地任务页(I10) | 0 |
+| ⑰ Cmd+J | fork 已接任务行和两个动作;没有 @ 解析,也不能加实体分区 | 只加「派活…」动作(I12) | 0(已有接缝) |
+
+接缝预算:local-tasks 现在已有 126 条接缝、107 个上游文件。这一轮新增控制在 6~8 处,集中在侧栏主体、侧栏标题行、标签栏过滤、主区视图切换、设置。原型里其他的东西都应落在功能自有路径。
+
+### 5.2 不要重做的清单
+
+- 新建工作区表单(F3)。
+- 聊天消息渲染(I11)。
+- 终端文本快照(I6)。
+- 会话归属元数据:上游没有统一的会话抽象,也没有会话级自定义字段,「属于哪个任务」继续放在 fork 的 `local_task_sessions` 表,不改上游 `AgentSessionRecord`。
+- 侧栏滚动与行渲染(I8)。
+
+### 5.3 版本兼容、SSH、隐藏来源
+
+- **新事件类型**:旧桌面和旧手机收到 progress、dispatch、acceptance 时必须忽略而不是报错。要核对 `local-task-event-copy.ts`(手机)和桌面的动态渲染对未知 kind 的处理,列为开工前验证。
+- **能力位**:新 CLI 和新 RPC 按 `local-tasks.v2` 能力位探测,旧主机不支持时候选置灰并写「更新那台主机的 Orca」,方案 §10 已有,补上能力位名字。
+- **直连 SSH**:没有 orcad 的 SSH 主机,任务在桌面的库里(REQ-018 补记)。智能体在 SSH 主机上跑 `orca task progress`,走 relay 回到桌面;桌面没开或断线时写入失败。任务说明里要写:**写入失败就把摘要写到工作区里的文件,继续干活,不重试**。否则智能体会在一条失败的命令上打转,这正是 Remote Host Reads 禁止的行为被搬到了智能体侧。
+- **orcad 托管与配对服务器**:各自的库,CLI 用 `--environment` 指定(已有)。调度会话派活到它们时,任务建在它们的库里,对话记录在桌面;两边各记一半,时间线上的「由对话派出」只能带桌面的对话 id,对方库看不到那段对话。方案 §6.5 要写明这一点。
+- **多 @ 跨库**:S14。
+- **隐藏来源**:REQ-008 的隐藏规则覆盖所有新入口(I9)。
+- **Windows**:回写正文走 `--file`(方案已写);Cmd+J 的组合键按平台显示(REQ-007 已定)。
+- **文件夹工作区**:派活到文件夹项目只能选已有文件夹工作区(方案 §10 已写);任务视图的 `WorktreeSplitSurface` 对文件夹工作区同样可用。
+
+## 6. 文字和代码对不上的地方
+
+| # | 方案原文 | 实际 | 改法 |
+| --- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
+| ① | §4「一个工作区只属于一个顶层任务」 | D-008 后任意层级都能挂工作区,规则是「一个工作区只属于一个任务(任意层)」,由 `local_task_workspaces` 的主键和唯一索引保证 | 改成「任意层」;A2 的子任务挂在拥有工作区的那个任务下,不论它在哪一层 |
+| ② | §3 原则 5、§7.1「CLI 会拒绝智能体把任务写成完成」 | `orca task status 编号 completed` 现在能写进去;CLI 也分不清是人还是智能体在敲 | 规则写成「来自 CLI 的写入不能进入已完成类别,完成只在界面上点」;拒绝时按 Multica 第 3 条给智能体写下一步 |
+| ③ | §7.1「需要最新内容时运行 `orca task show 编号`」 | 对;但 `orca task current` 按工作区匹配,在子任务的会话里返回的是父任务 | `task current` 先按会话关联找(终端里有 Orca 的会话标识时),找不到再按工作区;任务说明里仍然直接给编号 |
+| ④ | §6.1「A 不多花一次模型调用」,§6.3「用生成管线起短标题」 | 生成管线就是调用用户的智能体 CLI;上游自动命名只在本机非 WSL、只对结构化聊天 | 见 §1 第 6 条 |
+| ⑤ | §7.2「没有回写时取 hook 上报的最后一句话」 | 它只在主机内存里,重启就没了;任务卡现在也不显示它,只在详情会话行和需要你里 | 见 §5.1 ⑦ |
+| ⑥ | §11「`orca task create`:RPC 已有,只缺 CLI」 | 对;`links.linkWorkspace` 也有;`progress` 既没有 RPC 也没有事件类型 | §13 分期里 3a 要包含 schema v6、事件类型、RPC |
+| ⑦ | §5.1 左侧视图「需要你、全部、进行中、待规划、已完成、已取消」 | 代码里没有「未开始」和「待验收」视图;首页按状态分组时「未开始」「待验收」是分组头,不是视图 | 保持视图不变,分组头按类别出;或者加「待验收」视图,要定 |
+| ⑧ | §9「Goal 现在只绑终端,聊天型会话要另外核实」 | 已核实:`GoalBindingSchema` 只有 terminal,结构化聊天不支持 | 改成定论,「设为目标」对聊天型会话置灰 |
+| ⑨ | §5.4「终端型:显示 `terminal read --screen` 的同等内容」 | 桌面上终端已在渲染进程里,可以 portal;手机已有 xterm 视图 | 见 I6 |
+| ⑩ | §9「Cmd+J 输入以 @ 开头时直接进入派活」 | 调色板没有 @ 解析,扩展点只能加动作 | 见 I12 |
+| ⑪ | §6.4「做法参照浮动终端」 | 浮动工作区只在本机,网页客户端也不能在里面开会话 | 写明调度会话只在桌面有,手机和网页客户端经桌面 |
+| ⑫ | §5.6「子任务的会话在子任务自己的视图里」 | §5.3 和 REQ-012 都是「列出这个任务和它子任务下的会话」 | 以 §5.3 为准,任务视图含子孙会话,标签上带子任务标题 |
+
+## 7. 改动清单(给方案作者)
+
+按先后:
+
+1. §3 原则 1:去掉或改写「直接在工作区里开的会话也提示记成任务」(S4)。
+2. §4:工作区归属改成任意层(§6 ①);补项目归属规则(S11)。
+3. 新增 §「用户故事与动线」:S3、S5、S8、S9、S10、S11、S14 各一段,带 F8 那张分岔图。
+4. §5.2:改成两屏流程(F3);写明第一句指令的默认发送方式,并标为待用户确认。
+5. §5.4:终端 portal、聊天只读视图、回复只对做完一轮(I6)。
+6. §5.6:任务视图的实现口径改成「工作区表面加过滤」;含子孙会话;关闭标签语义;「+」语义;「未关联任务的会话」组;速览与标签的分工(F2、I5、I8)。
+7. §6.2:@任务、@主机 的语义;只问模式(I1)。
+8. §6.3:标题不起名;撤销三条(F9);派活到有空闲会话的工作区的二选一。
+9. §6.4:提案由 `orca dispatch propose` 存、Orca 执行;调度会话只在桌面(F5)。
+10. §6.5:跨库时对话与任务各记一半(§5.3)。
+11. §7.1:CLI 拒绝完成的真实规则;写入失败不重试、写文件继续;`task current` 的改法(§6 ②③)。
+12. §7.2:最后一句话落库(§5.1 ⑦)。
+13. §8:打回一键开新会话;显示规则 2 的判定;只用手机时要等桌面(F6、F7)。
+14. §9:Cmd+J 改成一个动作;「按任务分组」的去留标为待定(I12、F1)。
+15. §10:直连 SSH 的回写路径与失败行为;能力位 `local-tasks.v2`;手机上区分「桌面的库」和「主机自己的库」(§5.3、S12)。
+16. 新增 §「隐藏来源与设置」:所有新入口跟随 REQ-008(I9)。
+17. 手机:§5.1 和 §9 统一成「不替换首页」(I10)。
+18. §13 分期:3a 加 schema v6、事件类型、RPC、能力位;把「旧客户端忽略未知事件类型」列为开工前验证。
+19. 需求文档:REQ-009 的发送方式、REQ-011 的会话 chip 规则、REQ-012 的任务视图、REQ-014 的「任务在等你」分组、REQ-019 的 CLI 清单,等方案定稿后同步。
+
+原型要跟着改的屏:d2(@ 候选的任务、主机语义,只问开关)、d3/d4(标题不再变化;A2 的二选一)、d5(两屏)、d8(等你回答只能打开会话)、d13-task(含子任务会话、未关联会话组)、m1(不替换首页)。
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/journal.md" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/journal.md"
index a6ca5a2b2a8a..b7736bb83eb5 100644
--- "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/journal.md"
+++ "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/journal.md"
@@ -3,25 +3,256 @@ title: 本地任务管理
slug: 本地任务管理
status: implementing
created: 2026-10-26
-updated: 2026-11-03
+updated: 2026-11-10
+external_ids:
+ - 'Fartown/orca#90'
---
# 本地任务管理 开发 Journal
## 1. 关键文档链接
-| 类型 | 文档 | 状态 | 说明 |
-| -------- | -------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------ |
-| 需求 | [requirements/本地任务管理.md](requirements/本地任务管理.md) | ready | REQ-001~REQ-019;REQ-001~REQ-018 于 2026-10-26 用户确认 |
-| 交互 | - | approved | 设计稿在本机 `.docs/本地任务管理调研/2026-10-24/prototype-v4/`,不进仓库 |
-| 调研 | - | completed | 产品与代码调研在本机 `.docs/本地任务管理调研/`,不进仓库;结论已并入方案 |
-| 方案 | [solutions/本地任务管理技术方案.md](solutions/本地任务管理技术方案.md) | approved | 数据层、RPC、推送、桌面与手机接入、需要你、35 处接缝、开工前验证 |
-| 测试用例 | [tests/cases/本地任务管理功能测试.md](tests/cases/本地任务管理功能测试.md) | ready | 切片 1:TC-001~TC-039;切片 2:TC-101~TC-113 |
-| 测试记录 | [tests/runs/2026-10-26-开工前验证.md](tests/runs/2026-10-26-开工前验证.md) | completed | 开工前 4 项:1、2 成立,3 仅代码确认,4 部分成立 |
-| 测试记录 | [tests/runs/2026-10-26-切片1真机验证.md](tests/runs/2026-10-26-切片1真机验证.md) | completed | 切片 1:13 条通过、3 条部分覆盖,4 个修复复测通过 |
+| 类型 | 文档 | 状态 | 说明 |
+| -------- | -------------------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 需求 | [requirements/本地任务管理.md](requirements/本地任务管理.md) | ready | REQ-001~REQ-031;REQ-001~REQ-018 于 2026-10-26 用户确认;REQ-020~REQ-025 于 2026-11-01 确认;REQ-026~REQ-030 于 2026-11-02 按方案评审新增;REQ-031 于 2026-11-05 新增(总管) |
+| 交互 | [interaction/prototype-task-first](interaction/prototype-task-first/README.md) | completed | 以任务为主的交互原型,桌面和手机,迭代 5 轮(D-011~D-014);浏览器打开 `index.html` |
+| 交互 | - | approved | 切片 1、2 的设计稿在本机 `.docs/本地任务管理调研/2026-10-24/prototype-v4/`,不进仓库 |
+| 产品方案 | [interaction/任务为主的工作流产品方案.md](interaction/任务为主的工作流产品方案.md) | approved | 2026-10-30 以任务为主的工作流与通用对话;2026-11-02 评审后修订(D-012) |
+| 评审 | [interaction/任务为主的工作流方案评审.md](interaction/任务为主的工作流方案评审.md) | completed | 2026-11-02 评审稿:用户故事、动线、交互、复用与兼容;用户全部采纳,一处改掉(D-012) |
+| 调研 | - | completed | 产品与代码调研在本机 `.docs/本地任务管理调研/`,不进仓库;结论已并入方案 |
+| 方案 | [solutions/任务为主的工作流技术方案.md](solutions/任务为主的工作流技术方案.md) | draft | 2026-11-03 切片 3a~3d:复用优先,上游接缝新开 5 处;§2.3 有 5 个待定问题 |
+| 方案 | [solutions/本地任务管理技术方案.md](solutions/本地任务管理技术方案.md) | approved | 数据层、RPC、推送、桌面与手机接入、需要你、35 处接缝、开工前验证 |
+| 测试用例 | [tests/cases/本地任务管理功能测试.md](tests/cases/本地任务管理功能测试.md) | ready | 切片 1:TC-001~TC-039;切片 2:TC-101~TC-113 |
+| 测试记录 | [tests/runs/2026-10-26-开工前验证.md](tests/runs/2026-10-26-开工前验证.md) | completed | 开工前 4 项:1、2 成立,3 仅代码确认,4 部分成立 |
+| 测试记录 | [tests/runs/2026-10-26-切片1真机验证.md](tests/runs/2026-10-26-切片1真机验证.md) | completed | 切片 1:13 条通过、3 条部分覆盖,4 个修复复测通过 |
+| 测试记录 | [tests/runs/2026-11-05-总管本机真机验证.md](tests/runs/2026-11-05-总管本机真机验证.md) | completed | 总管本机场景:打开、提案、确认执行、收件箱、回写、唤醒、开关、重开都通过;SSH 场景未做 |
+| 测试用例 | [tests/cases/任务为主的工作流测试.md](tests/cases/任务为主的工作流测试.md) | ready | TC-301~TC-309,任务界面、派活、会话、验收、主机归属与兼容 |
## 2. 决策点记录
+### D-018 认领窗口存续期间禁止人工收尾
+
+- 日期:2026-11-06。
+- 背景:统一分支审查发现另一窗口可把活动提案空结果结案,执行窗口的迟到结果被拒绝。
+- 用户确认:采用推荐规则;活动执行窗口仍在时禁止其他窗口收尾,退出后保留已知结果、不重跑。同时批准补建 GitHub issue、关联 #76 并推送跑 CI。
+- 最终决定:认领绑定服务端可信连接;每项结果持久化确认后才执行后项。连接真正退休后释放认领,仅允许保留结果的人工结案;不把联系中断解释为远端进程退出,不对未确认的在途副作用作未执行判断。
+- 影响:REQ-030、提案 RPC 和窗口/配对连接生命周期、TC-310;完整库仍为 v7。外部跟踪为 [#90](https://github.com/Fartown/orca/issues/90),PR #76 继续评审,不自动合入 integration。
+- 验证:需要双窗口、导航重载、连接退休、服务重启、配对断开/迟到结果、逐项持久化失败及本机/SSH 回归;未执行前不记通过。
+
+### D-019 用 Orca Goal 和 Codex 守卫做完整验收
+
+- 日期:2026-11-06。
+- 用户要求:对照原型和需求,对普通任务与合并 super agent 完整测试验收;用 Codex 守卫,最多 10 小时、100 轮。
+- 目标边界:按 REQ-001~REQ-031、产品方案与原型逐项核对;Q1/Q2 以已确认技术方案导航为准。区分已实现、设计差异、未实现和未覆盖,不把测试 stub 推理结果当真实模型能力。
+- 验收与交付:绑定最终提交和构建,覆盖本机、SSH、配对手机、CLI/RPC、迁移、失联与并发,保留原始证据;#76 推送后持续跟进 CI,不自动合入 integration。Goal 是否已生效以原生服务回读为准,不用普通 Run 代替。
+
+### D-017 全量合并任务功能并重新审查验证
+
+- 日期:2026-11-06。
+- 背景:用户要求「全部合并到一个分支,然后重新 review 代码并测试功能」,并明确选择「连 #81 已有的超级智能体实现也一起合并」。
+- 最终决定:以 feat/local-tasks-walkthrough-followups 作为唯一继续开发的任务分支,合入已完成的 UI 提交、#81 总管与最新 fork/integration,保留原分支和索引快照作回退。
+- 合并准则:保留 v7 总管五表、CLI/RPC、跨库感知与人工确认提案;同时保留普通直接派活、人工验收、任务树、可输入会话浮层及手机聊天抽屉。完成标准仍从界面移除,旧内容与旧 RPC 输入折入描述,不静默丢失。Q1/Q2 已确认的导航保持。
+- 影响:撤销 D-016 的当轮暂缓范围;D-015 继续指总管设计,UI 分支同名历史决策合并时编为 D-016,避免编号冲突。库版本和兼容性按完整 v7 实现重新验证,不把此前 v5 候选通过等同于本轮通过。
+- 关联:REQ-022、024、026~031;本轮结果存于 `.docs/local-tasks-ui-validation/2026-11-06/consolidation/`。
+
+### D-016 UI 分支当轮先做普通任务功能(原分支编号 D-015)
+
+- 日期:2026-11-05。
+- 背景:用户明确要求「管理任务的超级智能体先不做,先把任务界面功能这些做了」。远端方案分支在旧会话之后已有总管提交,本轮以独立 worktree 保留其工作,仅复用已有进展回写提交。
+- 备选项:继续总管、提案及唤醒;先完成任务骨架、明确目标直接派活和独立验收功能。
+- 最终决定:按本轮用户明确范围执行后者。3a、3b 和 3c 中独立于调度的待验收/通过打回/规则三态纳入;总管、只问、提案和自动唤醒暂缓,3d 后续功能不在本轮。
+- 实现选择:Q1/Q2 于本轮获用户确认「按技术方案实现」:概览加浮层,全屏用已有标签;手机聊天抽屉、终端会话页。Q3/Q4 复用 review 类别、任务验收不附加会话稍后提醒。
+- 原因:先闭合任务、会话、进展、验收的普通用户路径;不重复实现既有回写能力。
+- 影响范围:REQ-004、005、007、009~019、022、024、026~029;技术方案 M1~M12(排除 M13);测试 TC-301~TC-309。
+- 关联文档:[本轮用例](tests/cases/任务为主的工作流测试.md)、[技术方案](solutions/任务为主的工作流技术方案.md)。
+
+### D-015 调度会话即总管:分配、管理、感知
+
+- 日期:2026-11-05
+- 背景:
+ - 用户要求在本 PR 上先做一个用于分配、管理任务与会话、能感知的总管(super agent),想好任务、会话怎么给它提供接口,任务和会话复用已有的。
+ - super agent 功能的设计已有,在分支 `claude/gifted-dijkstra-9oipaq` 的 `docs/issue/超级智能体/`。其中「总管第一版接口方案」提议把本方案的调度会话升格为总管,用户选择直接并入本 PR。
+ - 核实了三处代码事实:
+ - 调度会话按用户设置可能走结构化聊天(`nativeChatPreferenceFor`),而 Claude 的结构化聊天经 Claude Agent SDK,不能用 claude.ai 订阅;
+ - `NativeChatView` 接受终端标签;
+ - `ORCA_AGENT_SESSION_ID` 只注入结构化会话。
+- 最终决定:用户同意并入本 PR;方案按 super agent 设计的推荐项写入。各项细节在用户审阅本 PR 时确认。
+ - 调度会话即总管,常驻、唯一;Claude 以终端启动;对话按面板映射。
+ - 总管的写入一律走提案,提案项扩为 `start` / `subtask` / `append` / `stop` / `link` / `record`;`stop` 永远要确认。
+ - 感知:盯着的任务、收件箱、空闲时唤醒。语义照编排的指针投递,但不依赖编排。
+ - 「只问」遇到要读代码的问题,提只读调查任务。
+ - 新增 CLI:`task needs-you`、`task inbox`、`task watch/unwatch`、`task list --all-hosts`、`task show --events`。
+- 原因:
+ - 本方案让任务成为记录:任务就是总管的账本,提案就是审批,回写进展就是回执;复用这些,总管只需补收件箱与唤醒。
+ - 唤醒不另起编排 Run,因为本工作流不经编排。
+- 影响范围:REQ-030 增补、新增 REQ-031;产品方案 §6.4、§16;技术方案 M3、M13、§5.4、§5.5、§6.3(验证 7~11)、§8;切片 3c。
+
+#### 备选项
+
+- 调度会话升格为总管,建在本方案的任务记录与提案上(采纳并入)
+- super agent 另起内核与事项表,与本方案并行
+
+#### 关联文档与需求点
+
+- [interaction/任务为主的工作流产品方案.md](interaction/任务为主的工作流产品方案.md) §16
+- [requirements/本地任务管理.md](requirements/本地任务管理.md) REQ-030、REQ-031
+
+### D-014 速览就是对话的小边框模式;不要「完成标准」
+
+- 日期:2026-11-02
+- 背景:用户看了原型第三轮后问两件事:速览为什么不能回答,不就是对话的一个小边框模式吗;完成标准这东西能固化吗,每个任务都好定义吗。
+ - 速览不能回答,是沿用了 D-007 的旧规则:REQ-015 的回复框是一条单独的「发一句话」通道,终端里的提问多是菜单,发文字会发错。但速览已经改成把真实终端或聊天挂进来,它就是会话本身,限制没有必要。
+ - 完成标准:任务分三类,只有可验证的交付能事先定标准,而且开工时往往定不准;探索类只能是「给出结论」;零碎事写标准比做还费劲。先提了「可选、智能体提议、项目模板、验收不依赖标准」的折中,用户回复「我觉得不如没有」。
+- 最终决定:用户回复「都改」,随后改口不要完成标准。
+ - 速览就是对话的小边框模式:一个浮层两档宽度(速览约 420px、加宽约主区 2/3)加全屏,窄态也完整可操作;浮层里没有单独的回复框,不受「只有做完一轮能回复」的限制;首页「需要你」行的「回复」就是打开速览并聚焦输入;手机抽屉里挂真实会话视图。
+ - 不要「完成标准」:REQ-021 取消(勾选、证据、智能体提议、项目模板、CLI、与 Goal 打通都不做);REQ-004 的字段去掉,升级时已有条目追加到描述末尾;概览、新建任务、待验收卡里都没有它;验收看结果:合入位置、改动和 PR、智能体的自检结论、在哪验收。
+- 原因:浮层挂的是会话本身,输入走会话自己的通道,再限制就是人为设门槛;完成标准对大多数任务定不出来,强求就是填表。
+- 影响范围:REQ-004、REQ-009、REQ-010、REQ-015、REQ-019、REQ-021、REQ-024、REQ-026、REQ-027、REQ-028;方案 §4、§5.2、§5.3、§5.4、§8、§9、§13、§15;切片 3c、3d;原型第四轮(修改说明 BRIEF-4 在本机,不进仓库;第 2 节作废,改为整体去掉完成标准)。
+
+#### 备选项
+
+- 完成标准可选、智能体提议、项目模板、验收不依赖标准(折中,用户没要)
+- 不要完成标准(采纳)
+- 速览只读、加宽才能操作(第三轮原型)
+- 速览窄态也完整可操作(采纳)
+
+#### 关联文档与需求点
+
+- [interaction/任务为主的工作流产品方案.md](interaction/任务为主的工作流产品方案.md) §5.3、§5.4、§8
+- [requirements/本地任务管理.md](requirements/本地任务管理.md) REQ-021、REQ-028
+
+### D-013 看会话改成右侧浮层三级,概览页减信息
+
+- 日期:2026-11-02
+- 背景:用户看了原型第二轮的 ORC-38 概览后提出两点:「打开完整会话」能不能在右侧弹浮层,点全屏才开独立标签;这页信息太多,要说清每块从哪来、有什么意义、哪些有价值。逐块核对:工作区在头部和右栏重复;同一个会话在标签、侧栏树、等你卡、会话列表出现四次;「验收」按钮在进行中也显示;动态里是系统流水;右栏属性很少看;原型里混进一批给评审看的注释(含需求编号)。
+- 最终决定:用户回复「改」。
+ - 看会话是同一个右侧浮层的三级:速览(窄,只看最近内容,回复只对做完一轮)→ 展开(宽,完整可操作)→ 全屏(切到会话标签,标签顶部可收回侧边)。同一个终端或聊天实例只换宽度,不重新挂载。手机仍是两级。
+ - 概览只剩四块:头部(标题、属性 chips、工作区一行、一个按状态变的主按钮、「…」菜单);「现在」卡(验收条、在等你什么、最新进展、会话行,空的部分不显示);描述、完成标准、子任务(有才显示);动态(系统事件折叠成「系统记录 N」)。右栏取消;界面里不出现需求编号和机制说明。
+- 原因:任务是主体,会话浮在它上面;概览要先回答「现在要我干什么、做到哪了、标准是什么、怎么进去」,其余信息要么重复、要么很少看。
+- 影响范围:REQ-005、REQ-012、REQ-024、REQ-026、REQ-028;方案 §5.3、§5.4、§5.6、§15;原型第三轮(修改说明 BRIEF-3 在本机,不进仓库)。
+
+#### 备选项
+
+- 速览抽屉加独立标签两级(第二轮原型)
+- 同一浮层三级,完整会话也浮在任务上(采纳)
+
+#### 关联文档与需求点
+
+- [interaction/任务为主的工作流产品方案.md](interaction/任务为主的工作流产品方案.md) §5.3、§5.4
+- [requirements/本地任务管理.md](requirements/本地任务管理.md) REQ-026、REQ-028
+
+### D-012 方案评审全部采纳,任务模式里仍用速览抽屉看会话
+
+- 日期:2026-11-02
+- 背景:用户要求评审「任务为主的工作流」产品方案,重点看用户故事、动线、交互,以及改动和 Orca 原有功能的复用与兼容。评审前做了两份代码盘点(上游机制 15 项、local-tasks 现状 14 项)。评审稿列了 10 条先定的问题、15 条用户故事(7 条缺口)、9 条动线、12 条交互、17 项复用对照、12 处文字和代码对不上。
+- 最终决定:
+ - 用户:「任务模式里点会话就是标签,不弹抽屉」这条不同意,其他按建议来。原话:「我们希望后续迁移到以任务为主的模式 而不是工作区」。
+ - 因此任务视图的规则是:点任务开「概览」,点会话开概览加速览抽屉,抽屉里「打开完整会话」才切到标签;任务一直是主体,会话是从属,任务视图不能长成工作区的样子。
+ - 其余采纳:派活到正忙或空闲工作区的分岔;@任务、@主机 的含义与「只问」;项目归属与首次使用;新建任务两屏(不重做上游新建工作区弹窗);从任务发起的会话默认直接发送(改掉 REQ-009 在 2026-10-30 的草稿决定);任务视图实现为工作区标签表面加过滤;标题不另起模型调用;轮次结束记录落库;CLI 写入不能进已完成、`task current` 先按会话关联找;「按任务分组」在任务模式上线后评估下线;手机不换首页;Cmd+J 只加动作;提案由 Orca 执行;打回一键开新会话;取消任务时停会话勾选;父任务进度汇总;隐藏来源规则;直连 SSH 回写与能力位 `local-tasks.v2`。
+ - 方案 §15 和需求 §8 记了同一份清单;需求新增 REQ-026~REQ-030,修订 REQ-007、009、010、011、012、013、014、015、016、017、018、019、022、024;原则 2 改为 8 条原则里的 2~5 条,D-1 同步修订。
+ - 原型第二轮按本机的修改说明 BRIEF-2 修改。
+- 原因:以任务为主是方向;评审的其余建议都是让这条动线少重做上游、少开接缝、把分岔写清楚,和方向不冲突。
+- 影响范围:原则 1~8;REQ-007~REQ-019、REQ-022、REQ-024、REQ-026~REQ-030;方案 §3~§11、§13、§15;切片 3a~3d。
+
+#### 备选项
+
+- 评审建议原样采纳(任务模式里会话直接是标签)
+- 全部采纳但任务模式里保留速览抽屉(采纳)
+
+#### 关联文档与需求点
+
+- [interaction/任务为主的工作流方案评审.md](interaction/任务为主的工作流方案评审.md)
+- [interaction/任务为主的工作流产品方案.md](interaction/任务为主的工作流产品方案.md) §15
+- [requirements/本地任务管理.md](requirements/本地任务管理.md) REQ-026~REQ-030
+
+### D-011 工作流改为以任务为主(方向已定,先做原型)
+
+- 日期:2026-10-30
+- 背景:用户认为现在的工作流仍以对话为主,提出了以任务为主的设想:
+ - 进来先建任务,绑定工作区,启动会话;会话可以快捷查看;
+ - 不看会话时,任务下面有进度摘要,类似 Multica;
+ - 另有一个通用对话,用来创建任务和会话,例如 @ 某个工作区说「这里调研一下 X」,就建任务、绑定工作区、在里面开会话。
+ - 要求参考 Multica 和 Orca 现有机制,写出产品方案。
+- 最终决定:
+ - 方案草稿见 [interaction/任务为主的工作流产品方案.md](interaction/任务为主的工作流产品方案.md),内容包括:
+ - 任务首页成为默认首页;
+ - 新建任务一次完成「建任务、绑工作区、开会话」;
+ - 会话速览;
+ - 智能体按约定回写进展,任务卡显示最新进展;
+ - 通用对话分「直接派活」和「调度智能体」两条路径;
+ - 待验收、通过和打回。
+ - 方案 §12 列了 11 个问题。2026-11-01 用户回复「都按建议」,全部采纳默认做法,包括:
+ - 修订原则 2 和 D-1;
+ - 「需要你」新增「待验收」,修订 D-3。
+ - 下一步先做可交互原型,评审后再写代码。
+- 原因:任务是事情的记录和入口;对话只是做事的手段。REQ-020~REQ-025 的草稿并入这个方案。
+- 影响范围:
+ - 原则 2、D-1、D-3;
+ - REQ-005、006、007、009、010、013、014、017、019,以及 REQ-020~REQ-025;
+ - 新增切片 3a~3d。
+
+#### 备选项
+
+- 在现有任务页上逐项补强,保持对话为主的动线
+- 改为以任务为主,任务首页加通用对话派活(采纳为草稿)
+
+#### 关联文档与需求点
+
+- [interaction/任务为主的工作流产品方案.md](interaction/任务为主的工作流产品方案.md)
+- [requirements/本地任务管理.md](requirements/本地任务管理.md)
+
+### D-010 手机发起开始做时,任务内容直接发送
+
+- 日期:2026-10-30
+- 背景:SSH 复验中,手机「开始做」以草稿方式启动,主机回了 `not-delivered`。核对上游主机代码 `src/main/agent-launch/agent-launch-prompt-delivery.ts` 后确认:
+ - 主机对草稿一律不投递,终端会话和结构化会话都一样;
+ - 上游手机自己的启动也全部是直接发送。
+ - 因此手机「创建自 Local」固定用草稿,以及「开始做」关掉「立即发送」时,任务内容都到不了智能体。
+- 最终决定:用户选了「手机直接发送」。
+ - 手机「创建自 Local」和「开始做」都把任务内容作为第一条消息发送。
+ - 「开始做」去掉「立即发送」开关。
+ - 主机回报没有送达时,手机停在任务页,并写明原因。
+ - 桌面不变,仍然放进草稿。
+- 原因:和上游手机行为一致。草稿模式在手机上只会得到一个「没有送达」的提示,没有实际用处。
+- 影响范围:REQ-017;`mobile/src/local-tasks/use-local-task-launch.ts`、`local-task-launch-sheet.tsx`、`local-task-create-source-start.ts`。
+
+#### 备选项
+
+- 手机直接发送(采纳)
+- 手机不带任务内容启动
+- 保留草稿,每次提示没有送达
+
+#### 关联文档与需求点
+
+- [requirements/本地任务管理.md](requirements/本地任务管理.md) REQ-017
+
+### D-009 「从提出到验收」的工作流在本需求下迭代
+
+- 日期:2026-10-30
+- 背景:用户反馈「从提出一个 issue 到完成,整条动线不流畅」,要求想清楚该怎么为用户设计工作流、现在缺什么。这次走查本身就暴露了几处断点:
+ - 反馈只在聊天和截图里,没有逐条记账;
+ - 范围只在聊天里定,后来说「做完了」时对不上;
+ - 进展用户看不到;
+ - 完成标准只在执行方心里;
+ - 合入以后,用户的 App 里还看不到;
+ - 没有用户验收这一步;
+ - 中途发现的问题没有当场记下。
+- 最终决定:
+ - 用户已定:不新开需求,在本需求下迭代。
+ - 待确认:需求文档新增 REQ-020~REQ-025 和切片 3,内容包括截图附件与随手记、完成标准勾选与证据、进展回写、范围去向、合入后待你验收、衍生问题。待确认问题列在需求 §8「2026-10-30 『从提出到验收』待确认问题」。
+ - 确认前不动代码。
+- 原因:这些能力都挂在已有的任务、工作区、会话、PR 关联上,属于同一条「任务连接工作」的动线;另起需求会把同一个对象拆到两处。
+- 影响范围:需求 REQ-013 的规则 2、REQ-014 的分类,新增 REQ-020~REQ-025,§7 切片 3,§8;与 D-1、原则 2 的边界待用户确认。
+
+#### 备选项
+
+- 另开一个「研发工作流」需求
+- 在本需求下迭代,新增 REQ-020~REQ-025
+
+#### 关联文档与需求点
+
+- [requirements/本地任务管理.md](requirements/本地任务管理.md) REQ-013、REQ-014、REQ-020~REQ-025
+
### D-008 任务库改为每台 Orca 的资源,项目可挂多仓、层级不限
- 日期:2026-10-28
@@ -145,6 +376,138 @@ updated: 2026-11-03
## 3. 开发记录
+### 2026-11-10 按原型还原、完整功能测试与总管评测(testing)
+
+- 本轮目标:对照第 6 轮原型(含总管 d15~d19、m9)把未还原的地方做完,改完后完整测试功能,并评测总管智能体。
+- 完成内容:桌面首页「需要你」分组(含总管提案提示行,计数口径与侧栏、导航统一,桌面与手机共用 `src/shared/local-tasks/local-task-needs-you-band.ts`);任务行、悬停进展卡、看板列序、@ 候选、派活框、新建任务撤回父任务、待验收分段、本机写「本机」;侧栏任务模式只留模式按钮;总管聊天里挂提案面板(`NativeChatComposerPane.tsx` 接缝);全屏标签加「收回侧边」,速览面板里不显示;手机标题在本机库无项目时仍能换主机、编号不折行;「只问」未确认送达时提示去对话里看、不让用户重发。总管评测发现的问题全部修复:指南按种类列出提案项格式,CLI 拒收未知字段并给格式,`task list` 带工作区与最新进展,指南补「仓库属于多个项目要写 project」「没有工作区的任务提 link 或请用户点开始」「只有用户在界面上点通过才完成」;提案卡在仓库属于多个项目时锁定该项并给「选一个项目」,执行端失败时写出候选前缀。
+- 代码或文档变更:`src/renderer/src/components/local-tasks/`、`src/renderer/src/local-tasks/`、`src/shared/local-tasks/`、`src/cli/local-tasks/`、`mobile/src/local-tasks/` 下的功能文件;接缝 `SidebarHeader.tsx`、`NativeChatComposerPane.tsx`(已登记 fork-features 与三处架构名单);新增 20 个测试文件登记到 `tests`;中英文案与受保护文案键;用例文档补 TC-312.1~TC-323.3、TC-304.2。
+- 验证证据:功能 A 组 22/22 PASS,最终构建重跑仍 22/22(`.docs/local-tasks-ui-validation/2026-11-10/functional/evidence/final-03/`);回归 B 组 22 行(PASS 14、FAIL 2、BLOCKED 4、INCONCLUSIVE 2),两个 FAIL(全屏无法回到速览、手机本机库为空时不能换主机)修复后复测 PASS,含 SSH 新槽位哈希预检、停止与断联(`.docs/local-tasks-ui-validation/2026-11-10/regression/results.md`);提案卡选项目与「收回侧边」截图验证 6/6 PASS(`.docs/local-tasks-ui-validation/2026-11-10/final/results.md`);手机 m1、m4、m7、m8 逐屏截图(`.docs/local-tasks-ui-validation/2026-11-09/prototype-gap/evidence/mobile-03/`)。总管评测两轮同一组场景:第一轮提案被拒 9 次、调用约 26 次,第二轮被拒 0 次、调用 10 次,两轮都没有越权写、轮询、读代码或读终端(`.docs/local-tasks-ui-validation/2026-11-10/dispatcher-eval/results.md`)。单测:桌面本地任务、CLI、侧栏标题与 native-chat 共 479 个文件 4393 条全过,手机 46 个文件 172 条全过,Hermes 兼容测试通过;`pnpm tc`、手机类型检查、8 道本地化门禁、`check:fork-features`、`check:architecture-policies --base Fartown/main`、`check:fork-docs`、改动文件的 oxlint 与 oxfmt 均通过。
+- 未解决问题:六种提案项里没有「给已有、没开工的任务开会话」,目前只能请用户点开始或先提 link,属需求层面;TC-302、TC-309、TC-310.4/.6、TC-311 步骤 5.1 无驱动,BLOCKED;TC-321.1「合入位置」需真实已合入 PR,INCONCLUSIVE;总管评测的大脑是子代理而非 Orca 终端里的 Claude Code 进程,Codex 作为总管未评;上游功能在 SSH 断开后仍按周期调用该主机(不属本功能,已单列)。推送前 `check:changed` 除相关单测外 31 项跑完(单测一步被 Orca 重启打断,改为定向跑通),其中「changed-code quality」查出本轮新增 83 条(shadcn 改样式 70、类型断言 10、type-aware 3),已修并截图复核外观;项目设置的编号前缀输入框随之改用等宽样式,字号 14px 变 12px。首轮 CI 又在全仓 type-aware 基线上报两处 `switch` 未列全,改为复用 `LocalTaskProgress.tsx` 已有的状态映射。第二轮 CI:挂在终端标题栏和原生聊天输入区的本功能组件把重模块拉进上游组件的加载链,4 个只做了部分 mock 的上游单测失败,改为 chip 卡片、总管提案卡片懒加载,并拆出轻量的总管面板状态模块;启动时接管任务首页让上游 e2e 停在任务页,e2e 构建里不再接管启动页(正式包不变);跨版本协议检查因 fork 落后于上游 v1.4.255 的文件搬迁而失败,与本 PR 改动无关,需同步上游后消失;两项 Linux 打包失败是 apt 下载网络错误。
+- 下一步:用户同意后已推送到 PR #76,看新候选 CI 至全绿;不自动合入 integration;提案项缺口与 Codex 总管评测另行安排。
+
+### 2026-11-09 原型内联脚本的 CI 质量修复(testing)
+
+- 本轮目标:修复统一候选 4478ecc 在 React Doctor changed-lines 门禁中的 363 条原型脚本错误,保留原型交互及现有门禁覆盖。
+- 完成内容:为单行分支补齐花括号,使用等价的数组末项、纯谓词查找和临时数组倒序写法;桌面、手机 HTML 的脚本以外内容逐字节不变。没有排除原型文件或更改门禁配置。
+- 代码或文档变更:`interaction/prototype-task-first/desktop.html`、`mobile.html` 的内联 JavaScript 和本记录。
+- 验证证据:`.docs/local-tasks-ui-validation/2026-11-09/resume/ci/prototype-react-doctor-01/` 保留修前 363 条诊断、原始文件、修后四段脚本零诊断及正式 React Doctor 门禁 exit 0。固定日期和随机源后,happy-dom 中 70 个路由、111 个原型状态的生成 DOM 完全一致;首次比较的 4 处随机操作 ID 差异保留原始失败与定位记录。该等价检查不证明浏览器布局或实际 Orca 应用验收通过。
+- 未解决问题:新提交尚需最终构建、本机/SSH/物理手机等实际回归、最后 check:changed 和推送后的新候选 CI;4478 的应用证据与 CI 结论仍仅属于原提交,逐 REQ 与原型矩阵的缺口保留。
+- 下一步:提交质量修复后构建新的统一候选,按现有脚本复测并继续剩余验收;正常推送 PR #76 并观察 CI,禁止自动合入 integration。
+
+### 2026-11-09 移动 Web 握手存储键的 CI 契约修复(testing)
+
+- 本轮目标:处理统一候选 4478ecc 的实际 CI 失败,保留已完成验收和未验边界。
+- 完成内容:本机复现握手测试 42 项中的 1 项失败;生产握手已按登记通告任务视图存储键,测试仍只期待旧键。补齐固定 wire 契约的精确期望,并登记该上游测试接缝及长期功能检查。
+- 代码或文档变更:`mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts` 的存储键期望、fork 功能和架构登记、本 journal。
+- 验证证据:`.docs/local-tasks-ui-validation/2026-11-09/resume/ci/4478-failures/` 有当前 CI 原始日志与合并树等于候选树的证据;`ci/bridge-storage-contract-before-01/` 为本机修前复现。修后定向、完整移动测试与门禁以同目录后续原始结果为准,不用旧构建给新提交判通过。
+- 未解决问题:Android 在 Gradle 下载 10 秒超时前未编译,Linux AppImage 收到 HTTP 504,尚未证明源码故障;新候选仍需重新构建、真实回归、最后 check:changed、正常 push 和 CI。4478 的真机 Q2 原终端页/真实 Codex 聊天及闭合已读分支通过;31 REQ 与 111 原型其余动作继续保留缺口。
+- 下一步:完成修复候选的本地检查与新构建,按最终 SHA/build/host 复测并继续 CI,不自动合入 integration。
+
+### 2026-11-09 统一候选 CI 复核与移动包预算重测(testing)
+
+- 本轮目标:跟踪已授权推送的统一候选 PR #76,按新候选 CI 原始日志修复失败;保留逐 REQ 与物理手机的未验缺口,不自动合入。
+- 完成内容:确认 2fdb208f 的 Mobile verify / static analysis 因三个重复类型导入失败,合并同源导入;移动 Web 完整树实测 139 个非语言代码 chunk,旧表 142 造成未用余量 7 大于门禁 4,重新测量全部 18 个有序路由前缀并收紧上限至 143。
+- 代码或文档变更:三个 local-tasks 文件的 type import、既有移动包预算及两处实测数值登记、手机抽屉英文夹具的 locale 和窄测试接缝登记及本记录;不改变 chunk margin、原测试断言或产品执行逻辑。
+- 验证证据:`.docs/local-tasks-ui-validation/2026-11-09/resume/ci/` 保存三个新候选 job 原始日志、18 路由前缀测量和同命令复跑;全树 focused native-plugin deny-warnings 已通过。后续当前候选完整检查、重建与应用复测的实际状态以同目录 results.md 为准。
+- 失败定位:移动 Web 完整检查 497/498、抽屉单文件 2/3,两次 WebKit 等待英文 Dismiss drawer 超时。逐事件截图显示真实点击已打开中文抽屉;同构建显式 en-US 后英文标签出现且最终 transform 与底部位置正确,因此给现有英文夹具固定 locale,保留原失败证据。checks-04 中 17/18 通过,功能登记检查指出重测后两处 mustContain 数值仍旧,已同步实测登记,随后重新运行最终候选检查。
+- 未解决问题:2fdb208f 的最终 check:changed 正确 base 下 31/32 通过,related 单测扇出 30 分钟超时且有四项超时/失败标记,三相关文件同源定向 37/37 通过,整体仍非 PASS。物理 Pixel9 的新 release APK 安装哈希已读回、已配对并打开本地任务页,但 release 无本 App WebView CDP,实际 DOM 完整 buildId 尚未读回,业务仍 BLOCKED。真实旧库升级、混合版本及逐 REQ/原型状态仍按矩阵继续验收。
+- 下一步:按修复后的提交重建并回归后台终端与本机/SSH路径,最后执行 check:changed,正常推送 PR #76 后观察当前 head CI;保留设备及专用服务到 Goal 结束。
+
+### 2026-11-09 后台终端速览的缺失 tab 复现与修复(testing)
+
+- 本轮目标:继续统一 Goal,优先定位 ui-13 的新后台终端速览空白;保持 Q1/Q2、现有后台创建行为及原 provider 会话不变。
+- 完成内容:在 b48792da79 新构建的 ui-14 同轮回读主端 session、SQLite、window 布局与 renderer cache;新 tab/pane/PTY 在主端和磁盘持续存在,窗口点击前后缺 tab/layout,未收到 createTerminal。速览打开时按任务会话定位持久化 live pane,核对 PTY/incarnation 后复用后台 notifier 投影;不新建 PTY、不导航或聚焦;失联、删除和身份不符时不补建,不自动重试。
+- 代码或文档变更:功能自有预览准备、仅本桌面可调用的 links.prepareSession RPC、现有 Panel 接入和 TC-304;运行时仅登记通用只读投影适配接缝,RPC manifest 调用功能逻辑,核心不依赖任务模块;同步依赖、架构与参数目录。
+- 验证证据:`.docs/local-tasks-ui-validation/2026-11-06/consolidation/goal-2026-11-07/evidence/ui-14/` 保存修前失败与主端/窗口/磁盘快照;`.docs/local-tasks-ui-validation/2026-11-09/resume/environment/preview-unit-02.log` 12项、`preview-typecheck-02.log` 全域类型、`preview-architecture-02.log` 架构已通过。修后应用重建与本机/SSH/物理手机复测仍待本轮后续证据。
+- 未解决问题:逐 REQ 与111原型状态尚有未测或未实现分支,最终 check:changed、正常 push PR #76 和新候选 CI 未完成;旧手机与 Metro 已过期,新独占 Pixel9 和 root sshd 新隔离 HOME 已核验,不能外推为业务通过。
+- 下一步:以提交后的新构建复测终端输入、宽窄、原标签与返回,再继续两套派活、真实手机/SSH/CLI/RPC/迁移/断联;保留全部缺口,禁止自动合入 integration。
+
+### 2026-11-07 统一候选的并发、SSH与真实模型验收(testing)
+
+- 本轮目标:接续 root 释放的统一候选,逐项验收普通任务、总管和原型,保留缺口并推进 PR #76
+- 完成内容:建立31条REQ及111个原型状态矩阵;真实双连接checkpoint、丢回包、socket退休、宿主重启和两个完整配对桌面竞争通过;普通任务与配对手机Web、SSH完整路径及停止、CRUD三层子树和真实Codex新会话通过
+
+- 代码或文档变更:TC-311去重与D-018确认状态;修正Journal门禁格式;将IPC连接生命周期的sender依赖收窄为实际事件能力,移除测试双重类型断言,不改变连接期行为
+
+- 验证证据:证据目录 .docs/local-tasks-ui-validation/2026-11-06/consolidation/goal-2026-11-07/;checks-01核心157、桌面1087、手机219、grants47项通过;全域与手机类型、本地化、架构、功能登记通过;文档门禁红转绿;应用证据绑定aba2268d54构建,源码类型收窄后tc:node与lint/format通过,checks-02 核心157项、全域类型和架构通过;346个变化代码文件严格代码质量全部0新增;登记锚点随类型名更新、Journal条目格式与生成索引修正后,功能/文档门禁复测通过
+
+- 未解决问题:本机新建后台终端的任务预览在 ui-10/ui-13 空白,ui-13 原始 store 缺关联 tab,待修复;物理 Pixel 9 已占用且当前 APK 构建安装成功,但原生 App 尚未验收;完整逐动作矩阵、其余提案/wake/迁移混合版本及 PR 服务联动尚未完成;最终候选需重建复测、check:changed、push与CI;不把Web或stub外推为物理设备或真实规划验收
+
+- 下一步:继续补足当前实现的真实场景;源码门禁和最终重建复测后正常推送并跟进CI;禁止自动合入fork/integration,测试服务保留到Goal结束
+
+### 2026-11-06 认领连接与逐项结果的并发收尾修复(testing)
+
+- 本轮目标:按 D-018 防止跨窗口提前结案,准备 D-019 原生 Orca Goal 完整验收
+- 完成内容:补齐退休后排队 claim 的最终 signal 门控、数据库关闭前认领清理;issue #90 已创建,PR #76 已确认 Fixes 关联
+
+- 代码或文档变更:本地任务主进程/共享/渲染、ProposalCard、IPC/RPC 连接期窄接缝、双语目录、登记和 TC-311。
+- 验证证据:核心与生命周期 3 文件 28 项、renderer 3 文件 21 项通过;日志 `.docs/local-tasks-ui-validation/2026-11-06/consolidation/evidence/p1-core-final.log` 和 `p1-renderer-current.log`。独立源码复核确认晚到认领和 closed-DB 清理路径闭合;最后真实双窗口、SSH、手机与 CI 尚需在最终候选执行。
+- 未解决问题:完整功能及原型对照验收未完成,不能沿用前候选 1532 项或旧 CI 称新候选通过。
+- 下一步:正常提交并冻结写入,原生 Orca Goal 的 Codex 执行者继续;Codex 守卫,最多 600 分钟、100 轮,推送并跟进 CI,不自动合入 integration。
+
+### 2026-11-06 SSH 浮层挂载时序修复(testing)
+
+- 本轮目标:定位并修复新客户端打开已有 SSH 服务的终端浮层空白。
+- 完成内容:同帧数据确认目录/标签、布局、portal 最终正确,但过早请求的工作区挂载被清理且未重发。浮层改为订阅所属主机的工作区与准确镜像标签就绪状态,就绪后再使用既有后台挂载;不创建新 PTY、不增加定时重试或主机读取。
+- 代码或文档变更:仅四个 local-tasks 自有生产/测试文件,新增组件级前置顺序回归,登记用例入口。
+- 验证证据:`.docs/local-tasks-ui-validation/2026-11-06/consolidation/evidence/ssh-mount-fix.md`;组件回归6项、tc:web及338变化文件严格代码质量通过;重建后真实SSH场景连续两轮通过,含终端输入、current/progress、配对手机选库与项目、跨库inbox及断联保护。远端候选哈希一致,数据库 user_version=7、quick_check=ok。
+- 未解决问题:提案人工收尾的并发规则仍待用户选择;新增GitHub issue与推送选择仍待回复,PR/CI未收尾。验证期间integration新增#89,最后一轮需包含该最新基线。
+- 下一步:合入最新integration并重新检查;按用户选择完善提案规则及issue/PR,最终推送后等待CI。
+
+### 2026-11-06 统一候选的审查修复与功能回归(testing)
+
+- 本轮目标:在统一分支上重新独立审查全部任务功能,并以本轮构建验证本机、SSH 和配对手机。
+- 完成内容:已将 UI、#81 总管与最新 fork/integration 正常合并为 0dd383aec8、4c08af2a03;修复提案长工作区 ID、legacy criteria 替换/清空、主机确认停止、真实输入保护、手机进展来源与续发时间线六项审查问题。派活枚举复用无焦点主机投影,选中项有界渲染且保留全部目标;保留原侧栏主机行为。
+- 代码或文档变更:主机/RPC schema、兼容折叠、桌面 stop/wake 与三处发送守卫接缝、手机任务组件、无焦点主机注册表入口、功能登记和用例 TC-310;删除已移除完成标准的废弃文案并修复单复数默认。
+- 验证证据:本轮 `.docs/local-tasks-ui-validation/2026-11-06/consolidation/`。核心148项及更名的 allowlist 1项、桌面1073项、手机219项、授权47项;另35项定向含重复用例,不累加。全量类型检查、337文件严格代码质量及本地化 catalog/extraction 通过。本机/手机任务会话路径、总管提案台账、手机经SSH读取任务库、桌面总管跨库 inbox/失联提示已实测;模型为受控 CLI stub,手机为实际 Web 构建和受控原生壳,不冒充物理设备或真实模型推理。
+- 未解决问题:SSH 终端浮层在首次冷场景通过后,于后续新客户端出现空白,正在基于真实布局/portal/挂载准入数据定位;不声明整条SSH验收通过。提案活动执行与人工收尾的状态规则待用户选择;GitHub issue及推送选择仍待回复。首轮全分支 check:changed 的过度关联测试扇出在18分钟后停止,该轮不记通过;静态失败项已修复并逐项复测。
+- 下一步:修复并复测SSH挂载;确认提案收尾规则后实现并发回归;补关联issue、最后检查与推送后再等CI,不合入integration。
+
+### 2026-11-06 统一分支并重新审查与回归(implementing)
+
+- 本轮目标:按 D-017 全量整合已有任务功能和总管,在用户指定分支上完成独立代码审查及新的功能验证。
+- 完成内容:保存 UI 370 文件索引树、补丁和原分支引用;UI 实现已提交为 39fd22a8c7;获取最新 followups(已含 #81)、integration;按路径所有权合并,保留总管与普通任务两套入口和契约。
+- 代码或文档变更:任务主机/共享/CLI、桌面/手机路径、功能登记/依赖与文案;历史 D-015 编号冲突已区分为 D-015 总管与 D-016 UI 当轮选择,新增 D-017 记录本轮用户决定。
+- 验证证据:`.docs/local-tasks-ui-validation/2026-11-06/consolidation/`;此时尚在整合,不复用上一轮通过结论。
+- 未解决问题:最新 integration 合并、独立代码审查和完整功能回归尚未收尾。
+- 下一步:完成合并提交、审查并修复发现,重新构建后测本机、SSH、配对手机和总管跨库路径,运行门禁后正常推送任务分支。
+
+### 2026-11-05 任务界面与普通任务功能(done)
+
+- 本轮目标:按 D-016 完成任务界面、明确目标直接派活、进展与独立验收;暂缓超级智能体。
+- 完成内容:任务首页与任务树、四块概览、可输入会话浮层、手机聊天抽屉、直接派活与确认停止后撤销;轮次末句跨重开保存、CLI 会话优先匹配和显式参数优先、去完成标准及旧内容折叠、待验收与 PR 规则三态/旧默认一次性迁移。Q1/Q2 按本轮用户确认实现。
+- 代码或文档变更:功能逻辑留在 local-tasks 路径,侧栏/浮层/手机 controller 接缝和依赖已登记。真实验收修复 SSH 仅 tabId 无 paneKey 时不挂终端,以及 Web 全高抽屉被浏览器程序性滚动;后者新增一个有平台守卫的样式接缝,native 保持原行为。测试规格 TC-301~TC-309,需求与方案同步实际边界。
+- 验证证据:本机 `.docs/local-tasks-ui-validation/2026-11-05/task-first/README.md`、`results.md` 和 `evidence/candidate-manifest.json`。核心117、桌面1025、手机170、route grants47、抽屉相邻43,共1402项测试通过;类型、增量质量、本地化与 fork 门禁结果见最终日志。task-leader 补充校验只剩8份既有 tests/runs 缺少标准结果表;未改写历史验证结论。隐藏 Electron 的本机/配对手机实际消息、派活和撤销通过;冷启动 SSH 部署哈希与候选一致,current/progress、速览和断线保护通过,远端库仍v5且quick_check正常。测试实例已停止、临时SSH key已移除,authorized_keys恢复原哈希。
+- 未解决问题:本轮未做真实模型推理、真实 PR 服务合入、物理手机 IME/语音/文件及 Windows/Linux 原生验收;长期用例未逐项自然操作的分支在 results.md 标 NOT RUN。legacy relay 不支持 task CLI,已修订原稿错误假设,不能据 orcad 自有库结果声称打通旧 relay。超级智能体与3d仍未实现,需求整体继续 implementing。
+- 下一步:本轮改动留在独立分支供评审,未提交、未推送、未合入;旧合并现场与远端 PR 未修改。
+
+### 2026-11-05 PR #76 并入方案 PR,并同步 fork/integration
+
+- 本轮目标:
+ - 用户要求把 PR #76(走查遗留项)合并进方案 PR #81 的分支。#81 仍然只评审、不合入。
+ - 顺带把分支同步到 fork/integration。它已含 2026-11-03 的上游同步,本分支原先落后上游 3302 个提交。
+- 完成内容:合并 fork/integration,解决了 14 个冲突文件,并按上游的新机制做了以下适配。
+ - 手机存储键:上游新增「协商键」机制,旧版页面遇到不认识的键会拒收整个 `init`。#76 新增的 `orca:localTasksView` 已加入 `NEGOTIATED_PAGE_STORAGE_KEYS`。
+ - 手机文案:上游把 `title`、`description` 移进了手机文案目录。#76 改的「Local tasks」随之改到目录键 `localTasks.title`,中文为「本地任务」。
+ - 「从…创建」里的「本地」页签名和搜索占位文字移进手机文案目录 `localTasks.sourceTab.*`,键名与桌面一致,中文为「本地」和「按标题或编号搜索本地任务」。原因是上游新的手机文案覆盖率门禁拦下了这两条英文。
+ - 上游把「从…创建」字段改成了分组列表,已选任务也跟着改用上游的列表行(`ListGroup` + `ListRow`),不再用旧的药丸样式。
+ - 上游统一了手机圆角并去掉 `radii.input`,任务选择弹层的搜索框改用 `radii.control`。
+ - `BottomDrawer` 换成上游的新路径。
+ - 桌面文案保留两边新增的键:#76 的 `writeBlocked`,以及 integration 的 `paletteEvidence`。
+ - 功能清单的接缝列表合并了两边新增的文件;「本地」页签的接缝断言改成本地化后的写法。
+ - 同步了受保护文案键清单,新增 #76 的 `localTasks.settings.reposFull`。
+- 代码或文档变更:合并提交,以及上面这些适配。
+- 验证证据:
+ - 类型检查:`pnpm tc`、手机端 `typecheck` 和 `typecheck:tests` 都通过。
+ - 登记的测试:主进程、共享层与 CLI 13 个文件 96 项;渲染层 129 个文件 1000 项;手机端 107 个文件 859 项。手机端这一组还加跑了存储键协商、「从…创建」相关的测试。全部通过。
+ - 本地化:5 项门禁都通过。
+ - `check:fork-docs`、`check:fork-features` 通过;改动文件的 `oxfmt --check` 和 `oxlint` 通过。
+ - 架构门禁要在提交合并之后跑,因为未提交的合并会被当成一次整体改动。
+- 未解决问题:
+ - 已选任务的新列表行样式还没有在手机上截图看过。
+ - 手机端的 fork 文件仍用 `StyleSheet.create` 读取 `colors`。上游已改成按配色方案生成样式(`themedStyleSheet`),所以这些页面在浅色模式下可能不随主题切换。这个问题不是这次合并引入的,要单独查。
+- 下一步:推送;PR #81 的 base 改为 fork/integration;关闭 #76 并指向 #81。#81 仍不合入。
+
### 2026-11-03 上游同步后的两处适配(testing)
- 本轮目标:处理同步上游到 8cd6a7e5ee 后,本功能触发的上游新规则和新测试。
@@ -163,6 +526,292 @@ updated: 2026-11-03
- 未解决问题:需要在真机上确认关联了任务的工作区里,会话顶栏仍然显示任务条,本地和 SSH 工作区都要看。
- 下一步:与其他同步修正一起推送。
+### 2026-11-05 总管跨任务库,SSH 场景真机验证通过
+
+- 本轮目标:用户回复「继续吧」,补上 SSH 验证暴露的跨任务库缺口,然后重跑 SSH 场景。
+- 完成内容:
+ - 派活:`start`、`record` 按工作区所在的主机找项目、建任务,任务进那台主机的库;`subtask`、`link`、`append`、`stop` 按编号或 id 在所有已同步的库里找任务。
+ - 会话:`append`、`stop` 在主机不在联系时回报「无法确认」,不重连、不重试。只有一个会话时可以不写会话号。
+ - 盯着与转发:其他库的任务记在桌面库的 v7 表里。渲染层只读已同步的数据,把这些任务的新动态转进桌面库,收件箱和台账页一并列出。主机失去或恢复联系时各记一条。
+ - 技术方案 M13 改写「其他库的任务」:原设想把盯着的记录存在任务所在的库里,实际存桌面库,理由写在方案里。
+ - SSH 重跑时发现,SSH 主机按自己的智能体设置启动会话,桌面的命令覆盖不会带过去。这是夹具问题,脚本改为在那台主机上设假智能体。
+- 代码或文档变更:
+ - 提交 7b1cc4e6a:v7 表 `local_task_conversation_remote_watches`、`local_task_conversation_facts`;桌面接口 `localTasks.dispatcher.recordFacts`;`local-task-dispatcher-remote.ts`、`local-task-dispatcher-forwarding.ts`、`local-task-proposal-session-items.ts`;共享的收件箱文案 `local-task-inbox-copy.ts`。
+ - 本次文档:需求 REQ-030、REQ-031 状态;技术方案 M13 与变更记录;测试记录的 SSH 一节。
+- 验证证据:
+ - SSH 场景真机重跑通过:派活到 SSH 工作区、SSH 窗格里 `orca task show` / `progress`、动态转进收件箱、台账页、失联提示、失联时追加回报「无法确认」。
+ - 证据在 `.docs/本地任务管理-ui-validation/2026-11-05/evidence/ssh/`;单测、类型检查和门禁见提交 7b1cc4e6a 的记录。
+- 未解决问题:
+ - 恢复联系的提示只有单元测试;
+ - 配对服务器没有真机验证;
+ - `task list --all-hosts`、`task show --events N`、对话历史、时间线回链仍未做。
+- 下一步:用户试用;按需补配对服务器的真机验证。
+
+### 2026-11-05 总管改为聊天界面、加总管台账、处理代码审查
+
+- 本轮目标:用户问「代码 review 了吗」「超级智能体肯定是 chat UI 吧,允许切到 terminal」「总管的动线在哪里」「怎么知道它记录了哪些任务、跟踪了哪些事」,并选定「浮动面板 + 台账页」。
+- 完成内容:
+ - 聊天界面:新开的总管默认显示为聊天视图,底层仍是官方 CLI 的终端会话;窗格头部可切回终端。
+ - 动线:任务页导航最上方是「总管」,显示等你确认的数量。台账页有状态条(是否在运行、唤醒开关、打开或显示对话),以及等你确认的提案、它盯着的任务(带最新进展,可让它不再盯)、它派出的任务、它还没处理的动态。对话在浮动面板里,任何页面都能打开。「需要你」视图只留一行提示,链到台账页。
+ - 代码审查:两个独立审查代理分别读主机侧和渲染层,确认并修了:
+ - 提案可能执行两次(加桌面认领 confirmed → executing);
+ - 确认已读会丢掉已完成任务之后的动态;
+ - append/stop 在 CLI 里填不出会话(task show 输出会话 id,单会话时可省略);
+ - 任务说明里的 `task create --parent`、`task update --title` 不存在(已补);
+ - 建出任务但没开成会话的条目没被盯上;重复保留序号会执行两次;
+ - CLI 还能经建任务、删状态、PR 合入规则把任务改成完成;
+ - 后台开会话在没打开过的工作区里可能不启动;
+ - 唤醒可能贴在你打了一半的字后面(改为本轮开始后按过键就先不写);
+ - 总管走聊天界面时会留下没登记的会话;停会话不核对窗格;以及几处低风险问题。
+- 代码或文档变更:提交 660e35b9f、c4e919026;新增主机只读接口 `localTasks.dispatcher.watches` 与桌面接口 `localTasks.proposals.claim`;需求 REQ-022、REQ-030 的状态说明更新;测试记录追加第二轮。
+- 验证证据:
+ - 本地任务相关测试 98 个文件、535 条通过;三个 `tsc` 工程、`oxlint`、`oxfmt --check`、`check-changed-code-quality`、三个本地化检查、三道 fork 门禁、RPC 参数目录检查通过;
+ - 真机第二轮全部通过,证据在 `.docs/本地任务管理-ui-validation/2026-11-05/`;
+ - CLI 全量测试另有 2 个文件失败(`agent-launch-process`、`runtime-listener-probe`),去掉本轮改动后同样失败,与本功能无关。
+- 未解决问题:
+ - SSH 场景仍未验(启动 root sshd、添加密钥被会话权限检查拦下);
+ - 执行被中断的提案只能「标记为已结束」,不会逐项续跑;
+ - 半写的唤醒指针视为已写,若它卡在输入框里需要你手动删掉;
+ - 对话历史、时间线回链、跨任务库仍未做。
+- 下一步:用户试用聊天界面和台账页;补 SSH 场景。
+- 同日补充:SSH 场景真机验证不通过。
+ - 夹具与结果见测试记录 2026-11-05 的 SSH 一节。
+ - 原因:SSH 主机上的工作区由托管的 orcad 提供,任务项目建在那台主机自己的任务库里;总管第一版只在桌面的库里找项目,派到 SSH 工作区的新建任务失败。
+ - 要做的是方案 §16.4 的跨任务库部分:任务建在工作区所在主机的库里,盯着的记录和已读位置存在那里,收件箱与唤醒合并所有在联系的库,主机不在联系时标「无法确认」、恢复联系后只重读一次。
+
+### 2026-11-05 总管真机验证与修正、任务上显示最新进展
+
+- 本轮目标:用户说「按你认为最佳路径做」。先在真实 Orca 里把总管走一遍,再修唤醒的已知问题,最后把最新进展显示到任务上。
+- 完成内容:
+ - 本机真机验证:打开总管、提交提案、确认执行、收件箱、回写进展、唤醒、唤醒开关、关掉再打开,全部通过;结果见测试记录 2026-11-05。
+ - 验证中发现并修复两处:确认提案后被带离「需要你」视图(改为在后台开会话);打开唤醒开关后不立即补发(偏好变化也触发检查)。
+ - 唤醒:光标在总管终端里时不写,焦点离开后补发;「需要你」视图里加唤醒开关,默认开。
+ - 最新进展(REQ-022):详情顶部、预览、看板卡片显示;列表行在智能体需要你时有标记。
+- 代码或文档变更:提交 1b1642a9f、d1f0f47bd;新增测试记录 `tests/runs/2026-11-05-总管本机真机验证.md`;需求 REQ-022、REQ-031 的状态说明更新。
+- 验证证据:
+ - 真机:`.docs/本地任务管理-ui-validation/2026-11-05/`,一次跑完全部步骤并通过;
+ - 自动化:本地任务相关测试 65 个文件、347 条通过;`tsc`(web)、`oxlint`、`oxfmt --check`、`check-changed-code-quality`、三个本地化检查、三道 fork 门禁通过。
+- 未解决问题:
+ - SSH 场景没验:本容器原本没有 sshd,装上后添加专用密钥和启动 root sshd 被会话的权限检查拦下;
+ - 光标在总管终端里时不写入,这一条只有单元测试覆盖:隐藏的测试窗口拿不到系统焦点;
+ - 真的 Claude 和 Codex 没在隔离环境里跑:没有登录,用假智能体代替;
+ - 智能体写了「需要你什么」,列表行会标出,但「需要你」视图的计数不变,是否列进去待用户决定;
+ - 侧栏悬停卡上的进展、跨任务库、对话历史与聊天视图仍未做。
+- 下一步:用户放开 SSH 夹具的权限后补 SSH 场景;定下「需要你」视图是否收录回写里的「需要你什么」。
+
+### 2026-11-05 实现总管(切片 3c 及其 3a、3b 前置)
+
+- 本轮目标:用户说「开始做吧」,选择把代码放在本 PR 的分支、直接做 3c 的总管(调度会话、提案、收件箱与唤醒),连同它要用的 3a、3b 前置。
+- 完成内容:
+ - 回写进展(REQ-022 的一部分):`orca task progress`,进展是带标记的普通评论;任务读取带最新进展;从任务开会话时第一句指令附任务说明,`orca task guide` 打印规则;CLI 不能把任务标完成。
+ - 任务库 v6(REQ-030、REQ-031):总管对话、提案、盯着的任务三张表;RPC 与 CLI:`task propose`、`task inbox [--ack]`、`task watch`、`task unwatch`、`task needs-you`;收件箱只列确定的事(回写、状态、会话挂上或解开、等你、提案结果),每条结果写明做成了什么。
+ - 渲染层:在「需要你」视图顶部打开总管,Claude 以终端启动,不走 Agent SDK;标签重开沿用原对话;提案卡逐项保留、确认或不要;确认后执行六类提案项并回报结果;总管空闲且收件箱有新内容时写一行指针。
+ - 手机可列出和决定提案;登记总管与回报结果只限桌面;总管自己的 CLI 调用不对手机开放。
+- 代码或文档变更:
+ - 提交 7dbee8153、a1af9003a、1d5e4407c;
+ - 新文件在 `src/main/local-tasks/`、`src/shared/local-tasks/`、`src/cli/local-tasks/`、`src/renderer/src/local-tasks/`、`src/renderer/src/components/local-tasks/`;
+ - 上游接缝只有已登记的 RPC 方法表和手机方法白名单;
+ - `local-tasks` 的 `dependsOn` 加了 `src/cli/command-body-input.ts`、`src/renderer/src/lib/focus-terminal-tab-surface.ts`、`src/shared/constants.ts`;
+ - 需求里 REQ-022、REQ-030、REQ-031 改为「部分实现」并写明做了和没做的。
+ - 与方案用词的出入:命令是 `orca task propose --body-file`(不是 `orca dispatch propose --file`),因为 `task` 命令组已有 `--body` / `--body-file` 读正文的约定。
+- 验证证据:
+ - `tsc` 三个工程(node、cli、web)通过;
+ - 本地任务相关的 main、CLI、渲染层与运行时 RPC 测试 95 个文件、521 条全部通过,其中新增总管、提案执行、唤醒、提案卡的测试;
+ - RPC 方法测试中 `agent-launch-replay`、`agent-launch-restart-replay` 有 11 条失败,去掉本轮改动后同样失败,与本功能无关;
+ - `oxlint`、`oxfmt --check`、`check-changed-code-quality`(以本 PR 上一提交为基线)、三个本地化检查、`check-architecture-policies`、`check-fork-features`、`check-fork-docs`、RPC 参数目录检查、几项相关 ratchet 均通过;
+ - 本环境装不全依赖(`anti-slop` 的下载被网络策略拒绝),`pnpm check:changed` 没法整体跑,以上是逐项跑的。
+- 未解决问题:
+ - 真机验证未做:本机场景和 SSH 场景(`127.0.0.1:2222` 夹具)都还没在真实 Orca 里走一遍,截图证据未存;
+ - 唤醒不知道你是否正在总管输入框里打字,可能和你正在打的字混在一起(技术方案验证项);设置里还没有关掉唤醒的开关;
+ - 「停会话」只停独占一个标签的终端会话,聊天会话和分屏里的会话会报失败;
+ - 一次执行里只有第一个「新工作区」开新建面板,其余只建任务、由你在任务上点开始;
+ - 对话历史、聊天视图、时间线回链、直接执行、逐项修改、跨任务库(orcad 主机、配对服务器)都没做;
+ - D-015 与技术方案的 5 个待定问题仍待用户审阅。
+- 下一步:在真实 Orca 里按本机和 SSH 两个场景验证总管,存证据到 `.docs/本地任务管理-ui-validation/`;再补唤醒的打字判断与开关、卡片上的最新进展。
+
+### 2026-11-05 调度会话即总管(不写代码)
+
+- 本轮目标:用户要求在本 PR 上先做一个能分配、管理任务与会话、能感知的总管,并想好任务和会话给它的接口,复用已有的;用户选择直接提交到本 PR 的分支。
+- 完成内容:
+ - 产品方案新增 §16,§6.4 指向它;
+ - 需求增补 REQ-030,新增 REQ-031;
+ - 技术方案 M13 增补「调度会话即总管」,M3 加 5 条 CLI,§5.4、§5.5 同步,§6.3 加验证 7~11,§8 加变更记录;
+ - 新增 D-015。
+- 代码或文档变更:只有文档,没有代码;交互原型未改(总管对话页沿用原型里调度会话的对话页)。
+- 验证证据:`check-fork-docs`、`check-fork-features`、`check-architecture-policies`(以 `origin/main` 为基线)通过;需求目录改动文件 `oxfmt --check` 通过。
+- 未解决问题:
+ - D-015 的细节待用户审阅确认;
+ - 技术方案 §6.3 的验证 7~11 待 3c 开工前做;
+ - 原型里尚无收件箱与唤醒的画面。
+- 下一步:用户审阅本 PR;3c 开工前做验证 7~11,并按需补原型。
+
+### 2026-11-04 提交方案与交互原型(不写代码)
+
+- 本轮目标:
+ - 用户要求提交方案,代码先不写,交互设计稿一起附上。
+ - 开一个 PR,不合入。
+- 完成内容:
+ - 交互原型附进 `interaction/prototype-task-first/`,内容如下:
+ - 两个页面:桌面和手机;
+ - 入口页 `index.html`;
+ - 70 张截图;
+ - 字体文件;
+ - `README.md`:怎么打开、屏幕清单、和技术方案的两处差异(Q1、Q2)、5 轮迭代记录。
+ - 原型的样式和脚本内联进页面,再按仓库规则格式化。原因是单独的 JS 文件会被仓库的 oxlint 和 oxfmt 检查拦下,这样做就不用改上游的检查配置。给原型制作者的修改说明和截图脚本不进仓库。
+ - 方案、需求、journal 里指向本机原型的路径,都改成仓库里的路径。
+ - 公开仓库检查:去掉了评审稿里的本机绝对路径,以及技术方案里的「飞书归档」两行。
+- 代码或文档变更:只有文档和原型,没有代码。
+- 验证证据:
+ - 打包、格式化后的原型重新跑了点击测试:桌面 225 项、手机 139 项全部通过,没有页面错误。打包脚本是本机的 `.docs/本地任务管理调研/2026-11-01/scripts/inline-prototype-task-first.mjs`。
+ - 原型目录 `oxlint` 没有要检查的文件;整个需求目录 `oxfmt --check` 通过。
+ - `pnpm check:fork-docs` 通过。
+- 未解决问题:技术方案 §2.3 的 Q1~Q5 仍待用户定。
+- 下一步:用户看 PR;定了 Q1~Q4 并同意合并 PR #76 后,再同步上游、开工。
+
+### 2026-11-03 写「任务为主的工作流」技术方案
+
+- 本轮目标:
+ - 用户要求写切片 3a~3d 的技术方案。
+ - 写作中用户补充:尽量复用已有能力,尽量少和上游冲突,深入研究已有代码,不要生造,除非必须新增。
+- 完成内容:
+ - 三路只读代码调研:任务库与 RPC 与 CLI、桌面侧栏与标签与浮层、派活与调度与手机。每个需求点按「直接复用 / 加一点 / 必须新增」判断;对每个要改的上游文件统计近三月的上游改动次数,用来估冲突风险。几个关键接缝做了二次核对:侧栏主体、终端 portal 的启用条件、主区布局、后台挂载工作区终端的函数。
+ - 方案主稿 `solutions/任务为主的工作流技术方案.md`。结论如下:
+ - 库版本:3a、3b 不升;3a、3b 不加 RPC 方法、变更 scope 和 failure code。
+ - 回写进展:沿用评论接口加 progress 标记。
+ - 轮次结束记录:沿用事件表加 turn-ended,旧客户端会静默丢掉。
+ - 待验收:沿用评审类别。
+ - 派活执行:统一走 `agent.launch`。
+ - 浮层:终端借用 Activity 页的 portal,聊天挂 `NativeChatView`。
+ - 上游接缝:3a 新开 4 处(浮层槽位、终端 portal 开关、侧栏任务模式主体、侧栏模式按钮),3d 新开 1 处。
+ - 发现与产品方案前提不符的事实:
+ - SSH 会话里的 CLI 很可能连不到桌面库;
+ - 远程 CLI 的写入来源会被记成桌面;
+ - 升库版本会让旧 Orca 只读;
+ - 新加的变更 scope 和 failure code 会让旧客户端解析失败;
+ - 任务视图的会话标签栏要改上游标签栏 5 个改动频繁的文件;
+ - 手机终端嵌不进抽屉。
+ - 本分支落后上游 3302 个提交(上游基线 2026-10-21),开工前要先同步上游。
+- 代码或文档变更:只写文档,没有动代码。新建 `solutions/任务为主的工作流技术方案.md`;本文件的文档表加一行。
+- 验证证据:`pnpm check:fork-docs` 通过;方案里 3 张 Mermaid 图用 Orca 自带的 mermaid 解析通过;oxfmt 格式化。
+- 未解决问题:方案 §2.3 的 5 个问题待用户定:
+ - Q1:任务视图要不要会话标签栏,推荐不做;
+ - Q2:手机终端会话点开直接进会话页;
+ - Q3:待验收不新增状态;
+ - Q4:待验收条目不做「稍后提醒」;
+ - Q5:SSH 回写要先做开工前验证。
+- 下一步:用户定 Q1~Q4;同意合并 PR #76 后同步上游;做开工前验证 1~6;从 3a-1 开始实现。
+
+### 2026-11-02 方案评审、需求同步与原型第二轮
+
+- 本轮目标:
+ - 用户要求评审「任务为主的工作流」产品方案:用户故事、动线、交互,以及改动和 Orca 原有功能的复用与兼容。
+ - 用户看过评审后定:「任务模式里点会话就是标签,不弹抽屉」不同意,其他按建议来;更新需求和原型(D-012)。
+- 完成内容:
+ - 评审前做了两份代码盘点:上游机制 15 项(侧栏、标签页、首页、原生聊天、浮动终端、Cmd+J、文本生成、Goal、Automations、状态 hook、会话抽象、抽屉、撤销、手机首页、新建工作区);local-tasks 现状 14 项(注册条目、数据模型、库归属、任务页、需要你、开始做与开会话、回复、侧栏分组、Cmd+J、进度信号、CLI、手机、REQ 状态、决策)。
+ - 评审稿 `interaction/任务为主的工作流方案评审.md`:先定的 10 条、15 条用户故事(7 条缺口)、9 条动线、12 条交互、17 项复用对照与接缝预算、12 处文字和代码对不上、19 条改动清单。
+ - 方案稿按评审修订,新增 §15 修订清单;任务模式的会话查看改回速览抽屉。
+ - 需求:原则改为 8 条;修订 REQ-007、009、010、011、012、013、014、015、016、017、018、019、022、024;新增 REQ-026 任务首页与任务模式、REQ-027 新建任务一次流程、REQ-028 会话速览、REQ-029 派活输入框与直接派活、REQ-030 调度会话与提案;§6 边界、§7 切片 3a~3d、§8 定稿记录;用词表加 15 条。
+ - 原型第二轮(修改说明 BRIEF-2):@任务、@主机 的含义与「只问」;标题不再变化;仓库没有项目先问一步;派活到正忙工作区另开会话并写明,派活到有做完一轮会话的工作区直接发给它;新建任务两屏(第二屏是上游弹窗样式,发送方式按设置);速览只对做完一轮显示回复框;任务视图默认概览、点会话开概览加抽屉、子任务会话标签、「+」、未关联任务的会话组与「记成任务」;取消任务时停会话勾选;父任务汇总行;设置页加「从任务发起的会话」默认值;手机首页改回上游样式、派活输入框放本地任务页、无项目先问一步。
+ - 原型第五轮(2026-11-03,用户问「为什么待验收不能查看会话」,是遗漏):待验收行加「速览」,打开写了最新进展的会话;浮层头部有会话下拉,任务有多个会话(含子任务)时可切换;验收条的自检来源可点,打开那个会话;验收中追问不改状态,验收条顶部显示「某智能体工作中」;窄浮层改为把主区往左推开,验收条和「通过」「打回」不被遮住,加宽态仍覆盖。
+ - 原型第四轮(修改说明 BRIEF-4,D-014):浮层改两档宽度加全屏,窄态完整可操作(终端输入行、聊天 composer 与选项按钮),去掉单独的回复框和全部说明文案;首页「需要你」行的「回复」改成打开速览并聚焦输入;手机抽屉挂真实会话视图;完成标准从桌面和手机整个去掉(概览块、新建任务段、待验收卡的勾选行、悬停卡计数、任务 chip 弹层),待验收卡改为合入位置、改动、自检、在哪验收。
+ - 原型第三轮(修改说明 BRIEF-3,D-013):右侧浮层三级(速览约 420px → 展开约主区 62%,终端有输入行、聊天有完整 composer 和选项按钮 → 全屏切到会话标签,标签顶部「收回侧边」),同一实例只换宽度;概览改成四块:头部(标题、属性 chips 设了才显示、工作区一行、按状态变的一个主按钮、「…」菜单)、「现在」卡(验收条、在等你什么、最新进展、会话行)、描述与完成标准与子任务有才显示、动态折叠「系统记录 N」;右栏取消;清掉全部评审注释和需求编号;手机 m4、m7 同样精简。
+- 代码或文档变更:
+ - 只改文档,没有动代码:`interaction/任务为主的工作流产品方案.md`、`interaction/任务为主的工作流方案评审.md`(新建)、`requirements/本地任务管理.md`、本文件(D-012~D-014)、`docs/issue/README.md`(重新生成)。
+ - 原型当时在本机 `.docs/本地任务管理调研/2026-11-01/prototype-task-first/`;2026-11-04 起附在 `interaction/prototype-task-first/`。
+- 验证证据:
+ - `pnpm check:fork-docs` 通过;方案 5 张、需求 2 张、评审 1 张 Mermaid 图用 Orca 自带的 mermaid 解析通过;三份文档经 oxfmt。
+ - 原型第二轮截图桌面 49 张、手机 10 张,点击测试桌面 143 项、手机 79 项通过;第三轮截图桌面 58 张、手机 10 张,点击测试桌面 183 项、手机 104 项通过;第四轮截图桌面 55 张、手机 10 张,点击测试桌面 198 项、手机 126 项通过,截图脚本加了禁用词扫描(完成标准、证据、需求编号、旧说明)两端为零;第五轮点击测试桌面 225 项、手机 139 项通过,含用 elementFromPoint 检查窄浮层打开时「通过」「打回」「验收」按钮未被遮挡;每轮都 0 页面错误,截图都逐张看过(脚本在 `.docs/本地任务管理调研/2026-11-01/scripts/`)。
+- 未解决问题:
+ - 本轮文档和 2026-10-30 的方案稿都在 PR #76 的分支上未提交,`interaction/` 目录整个是未跟踪状态;提交前要问用户,否则会混进 #76。
+ - 「按任务分组」是否下线,等任务模式上线后再评估。
+ - 切片 3a 开工前验证两项还没做:旧客户端对未知事件类型的处理;任务视图过滤标签栏不触发终端重新挂载。
+- 下一步:
+ - 用户看原型第二轮,有意见再改;同意后写技术方案并从切片 3a 开工。
+ - PR #76 等用户明确说可以合并。
+
+### 2026-10-30 走查遗留项一次修完,并举一反三
+
+- 本轮目标:
+ - 用户追问「前边说的问题全部做完了吗、查完了吗、举一反三了吗」。核对后确认没有做完,逐条清单见 `.docs/local-tasks-ui-validation/2026-10-30/followups/AUDIT.md`。
+ - 这一轮把清单里的条目全部处理完,并把同类问题一起改掉。
+ - 用户选定四点:
+ - 命名按需求原词统一;
+ - 手机「创建自」这次一起加 Local;
+ - 会话页头在工作区未关联时不显示入口,需求改成一致;
+ - 「需要你」保留原名。
+- 完成内容:
+ - 桌面:
+ - 详情的工作区卡新增只读的「Status」行;
+ - 侧栏本地快捷图标带「需要你」数字;
+ - 会话 chip 在工作区未关联时不渲染;
+ - Cmd+J 里失联库的任务标「无法确认」;
+ - 会话名依次兜底:标签页名 → 终端标题 → 智能体名;
+ - 本地来源激活时,悬停任务按钮不预取 GitHub;
+ - 快捷键和「重新开始做」碰到不可用时,说明原因;
+ - 项目关联的仓库全部移出 Orca 时,原因单独说明。
+ - 手机:
+ - 从首页「需要你」进入时是全部项目,按项目计数与桌面一致;
+ - 列表、看板、子任务行显示会话状态点,筛选新增「有会话在等你」;
+ - 详情显示 PR 和 Checks,能识别工作区已删除并提供「重新开始做」;
+ - 主机断开时卡上写明原因;
+ - 长按会话可以取消关联;回复失败写明原因;
+ - 动态写出规则和主机;
+ - 记住项目、分段和布局;
+ - 建项目可以多选仓库;
+ - 三处选择弹层可以搜索;
+ - 新建工作区「创建自」新增 Local,本机、直连 SSH、配对服务器三种情况,关联都写在拥有任务库的那台 Orca 上。
+ - 举一反三:
+ - 编号当主文字的地方,桌面和手机都改为标题;失败提示里主机返回的编号,在客户端查成标题。
+ - 按钮不可用时一律说明原因:置灰按钮外包一层,悬停能显示原因;达到上限时原地写明;任务页以外的地方用提示条说明。
+ - 中文、英文按需求 §4 词表统一,手机英文与桌面逐条对齐。
+ - 逐屏验收后又修了 12 处,清单见 `.docs/local-tasks-ui-validation/2026-10-30/followups-verify/README.md`「验收后修的问题」,包括:
+ - 失联提示被截断;
+ - 换主机后搜索框残留旧名字;
+ - 手机开始做后草稿没送达时不告诉用户;
+ - 建项目按钮被截到屏幕外;
+ - 仓库行显示原始 id;
+ - 分支名带 `refs/heads/`。
+ - 需求文档:
+ - 新增 §4「用词」;
+ - 改写 REQ-001、007、008、011、013、014、016、017、018;
+ - 6 条需求的状态改为如实描述:REQ-001、004、014、015、017、018 原写「待实现(D-008)」,其实已随 PR #71 实现。
+ - 新增 REQ-020~REQ-025 草稿,见 D-009;
+ - 手机这次不做的三项写进 REQ-017「暂缓」。
+ - 另外,用户要求研究 Multica 的任务系统,报告在本机 `.docs/本地任务管理调研/2026-10-30-multica/README.md`。借鉴清单等用户确认后,再并入 REQ-019~REQ-025。
+- 代码或文档变更:
+ - 桌面:`src/renderer/src/local-tasks/**`、`src/renderer/src/components/local-tasks/**`;新增 `local-task-write-reason.ts`、`LocalTaskActionTooltip.tsx`、`LocalTaskStartActions.tsx`。
+ - 上游接缝:`SidebarTaskNavButton.tsx`,本地来源激活时不预取 GitHub。
+ - 手机:`mobile/src/local-tasks/**`;新增「创建自 Local」的 9 个文件、`local-task-view-memory.ts`、`local-task-workspace-row.ts`、`local-task-session-dots.tsx` 等。
+ - 手机接缝:
+ - `mobile-smart-source-modes.ts`、`SmartSourceModeIcon.tsx`;
+ - `SmartWorkspaceSourceDrawer.tsx`、`SmartWorkspaceSourceField.tsx`;
+ - `NewWorktreeModal.tsx` 等新建工作区的 4 个文件;
+ - `page-storage-keys.ts`;
+ - 录制夹具 `workspace-settings-mounts.ts`。
+ - 以上接缝都登记在 `config/fork-features.jsonc` 和 `config/architecture-policies.jsonc`。
+- 验证证据:
+ - 门禁在本 worktree 目录下运行,脚本是 `.docs/local-tasks-ui-validation/2026-10-30/followups-verify/scripts/pr-gates.zsh`。
+ - 第一轮跑了 pr.yml 静态分析、类型检查和 fork 门禁,以及 mobile.yml 的验证步骤,共 35 项,34 项通过。
+ - 唯一失败的是手机全量测试里的 `hermes-array-sorting-compat`:5 秒超时,当时三路验收正在并行跑。单独重跑 0.5 秒通过。
+ - 修复后复跑的结果在 `gates-2/`。
+ - 真实界面:桌面本机、桌面 SSH 加手机经 SSH、手机本机三路逐屏验收。截图和计数证据在 `.docs/local-tasks-ui-validation/2026-10-30/followups-verify/`,结论表在其 README。
+ - SSH 一路确认:断开 170 秒内,`localTasks.*` 和 `ssh:connect` 都是 0 次,恢复后各读 1 次;手机经 SSH 创建的工作区,关联写进了 SSH 主机自己的库。
+ - 第二轮定点复验:本机 8 条、SSH 6 条全部通过,证据在 `followups-verify/recheck/{local,ssh}/`。复验又发现两处问题,都已修复,第三轮复验在 `recheck/round3/`:
+ - 主机不投递草稿,按 D-010 改为手机直接发送;
+ - SSH 库没读过时,创建弹窗没有 Local 页签。现在创建弹窗或工作区详情打开期间,在联系的配对主机读取任务库,属于 D6 设计里「能选任务的界面在屏上」这一类。
+ - 第三轮复验 R1~R4 全部通过:
+ - 手机发起开始做和「创建自」都是直接发送,主机回 `handed-to-terminal`;
+ - 先不开任务页时,SSH 库新建的任务也出现在 Local 页签,计数显示只在弹窗打开期间读一次;
+ - Host 行截断时悬停能看到全名。
+- 未解决问题:
+ - 中文界面下新项目的默认状态名是英文,因为默认状态由主机建库时写入。要改成按客户端语言写入,需要另定方案。
+ - 上游行为,不在本功能内修:
+ - 手机主机页在 SSH 恢复后仍显示「Disconnected」;
+ - 手机新建弹层在 SSH 断开时自动换成本机仓库;
+ - 会话状态点的读屏文字没有本地化。
+ - 验收中两次误用了真实 Claude,详见验收 README 的「事故」。真实 `~/.claude.json` 里多了一条指向临时路径的项目条目,等用户决定是否删除。
+- 下一步:第二轮修复复验通过后提交、开 PR,CI 全绿后合入。D-009 和 Multica 借鉴清单等用户确认。
+
### 2026-10-30 界面走查修复,并接入 Orca 原有的工作项入口
- 本轮目标:
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/requirements/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206.md" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/requirements/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206.md"
index c3e1bf6ff0c5..6b9744e136a3 100644
--- "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/requirements/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206.md"
+++ "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/requirements/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206.md"
@@ -3,7 +3,7 @@ title: 本地任务管理
document_type: requirement
status: ready
created: 2026-10-26
-updated: 2026-10-27
+updated: 2026-11-06
---
# 本地任务管理
@@ -14,21 +14,26 @@ updated: 2026-10-27
**目标**:在 Orca 里加一个以任务为中心的本地任务管理。需求在这里记录、拆分、跟踪;开始做时,一键在 Orca 里开工作区和会话。之后写代码、看 diff、提 PR,都用 Orca 原有的功能,任务这边只负责把它们挂在一起、显示进展,并在会话需要人时告诉你。
-**原则**:
+**原则**(2026-11-02 修订:原第 2 条「只做连接,不做流程」改为下面的 2~5 条,D-1 同步修订,见 D-011、D-012):
-1. 以任务为中心:从任务进入工作区和会话;工作区和会话也能看到自己属于哪个任务。
-2. 只做连接,不做流程:任务这边不判断「做完没有」「做得对不对」,状态由用户决定,只保留两条可以关掉的自动规则(REQ-013)。
-3. Orca 已有的不重做:diff、批注、提交、PR、Checks、智能体状态、通知,都直接用现有的。
-4. 只记录明确的关联:工作区和会话只在两种时候挂到任务上,一是从任务里开,二是用户手动关联。不推断,不追踪会话身份。
-5. 任务库和 Orca 的项目一样,是每台 Orca 自己的资源:项目在哪台 Orca 上,它的任务就存在哪台;其他客户端走现有的远程连接读写,主机不在联系范围内时不读它(D-008)。
+1. 任务优先:任务是入口和记录,对话是手段。从任务进入工作区和会话;从派活、新建任务或任务里开的会话自动属于任务;工作区和会话也能看到自己属于哪个任务。直接在工作区里开的会话,只在任务模式侧栏的「未关联任务的会话」组里能记成任务(REQ-011、REQ-026)。
+2. 人说了才开工:Orca 不因为状态变化、定时或主机恢复联系自动开会话。派活一定来自用户的一句话、一次点击,或用户确认过的调度提案(REQ-029、REQ-030)。
+3. 动作都留痕:派活、开会话、回写、状态变化、验收,都在任务时间线上留一条记录,写明来源(你、手机、对话、智能体、规则)。
+4. 进度只写明确的内容:摘要来自智能体主动回写;没有回写时显示它每轮结束时的最后一句话;让模型读会话生成摘要是用户点了才做的可选操作(REQ-022)。任务这边仍然不判断「做完没有」「做得对不对」,状态由用户决定,只保留两条可以关掉的自动规则(REQ-013)。
+5. 完成由人定:智能体最多把任务推进到「待验收」;「完成」只能在桌面或手机界面上点,来自 CLI 的写入不能进入已完成类别(REQ-019、REQ-024)。
+6. Orca 已有的不重做:diff、批注、提交、PR、Checks、智能体状态、通知、新建工作区弹窗、聊天视图、终端视图,都直接用现有的。不轮询、不重试、不排队:主机不在联系时派活和读取都不可用并写明原因,恢复联系后只重读一次。
+7. 只记录明确的关联:工作区和会话只在两种时候挂到任务上,一是从任务里开(含派活),二是用户手动关联。不推断,不追踪会话身份。
+8. 任务库和 Orca 的项目一样,是每台 Orca 自己的资源:项目在哪台 Orca 上,它的任务就存在哪台;其他客户端走现有的远程连接读写,主机不在联系范围内时不读它(D-008)。
-产品方案与设计稿的讨论过程见 `.docs/本地任务管理调研/`(本机目录,不进仓库),主稿是 2026-10-24 目录下的 `产品方案-v4.md`,设计稿是同目录下的 `prototype-v4/`。
+产品方案与设计稿的讨论过程见 `.docs/本地任务管理调研/`(本机目录,不进仓库),主稿是 2026-10-24 目录下的 `产品方案-v4.md`,设计稿是同目录下的 `prototype-v4/`。以任务为主的工作流(REQ-026~REQ-030 和 REQ-020~REQ-025 的修订)见 `interaction/任务为主的工作流产品方案.md`,评审见 `interaction/任务为主的工作流方案评审.md`,交互原型在 `interaction/prototype-task-first/`,技术方案见 `solutions/任务为主的工作流技术方案.md`。
## 2. 用户场景
用户在商城项目(仓库 shop,前端在 `web/`,后端在 `api/`)里记录需求 SHOP-12「订单导出支持 CSV」,拆出两个子任务:SHOP-13 后端、SHOP-14 前端。在 SHOP-12 上点「开始做」,在 SSH 主机 minizc 上建出工作区 `shop-12-order-export`;再分别在 SHOP-13、SHOP-14 上「开会话」,Claude 做后端、Codex 做前端,两个会话在同一个工作区里同时运行。
-之后用户不用盯着两个终端:任务列表上能看到两个会话的状态点;Codex 提问时,「需要你」里出现一条「等你回答」,用户点开会话回答;Claude 一轮做完,「需要你」里出现「做完一轮,你还没看」,用户在桌面或手机上直接给下一句指令。PR 合并后 SHOP-12 自动移到已完成。
+之后用户不用盯着两个终端:任务列表上能看到两个会话的状态点;Codex 提问时,「需要你」里出现一条「等你回答」,用户点开会话回答;Claude 一轮做完,「需要你」里出现「做完一轮,你还没看」,用户在桌面或手机上直接给下一句指令。PR 合并后 SHOP-12 移到「待验收」,用户在商城后台试过导出后点「通过」。
+
+以任务为主的工作流(2026-11-02 起):用户打开 Orca 先看到任务首页,顶部是派活输入框。输入「@orca/docs-typo 把 README 里过期的链接都修一下」,Orca 建出 ORC-42、关联 docs-typo、用默认智能体开会话,输入框下出现「已派活」卡,10 秒内可以撤销。Claude 每轮结束时用 `orca task progress` 回写一条「做了什么、结论、下一步」,任务卡上的「最新进展」跟着更新;用户不进会话也知道做到哪了,想细看就打开速览抽屉。输入「@orca/perf-startup 顺便看看首屏渲染」时,perf-startup 属于 ORC-39,它的 Claude 刚做完一轮,这句话直接发给它当下一句,不另建任务。没有 @ 的请求「看看 orca 里哪些工作区的 PR 还没合,各开一个任务跟进」交给调度会话,它只读查完后给一张提案卡,用户确认后由 Orca 建任务、开会话。
## 3. 范围
@@ -39,11 +44,12 @@ updated: 2026-10-27
- 显示进展:任务上显示工作区、会话、PR 的现有状态,点击跳过去。
- 需要你:挂在任务上的会话等批准、等回答、被卡住,或做完一轮还没看时,进入「需要你」。
- 以任务为中心的导航:侧栏可以按任务分组;工作区卡片和会话头部显示所属任务。
+- 以任务为主的工作流(2026-11-02,REQ-026~REQ-030):任务首页与任务模式侧栏、任务视图;派活输入框与 @ 提及、直接派活与调度会话;会话速览;智能体回写进展(REQ-022);待验收、通过与打回(REQ-024)。
- 手机端与桌面功能对齐。
### Out of Scope
-- 评审与验收流程、PR 的创建与合并流程、提交、占用锁与排队、智能体交付约定、执行者预设、编排。
+- PR 的创建与合并流程、提交、占用锁与排队、执行者预设、编排。验收(通过、打回)和智能体回写约定从 2026-11-02 起在范围内(REQ-022、REQ-024),但仍不替用户判断「做完没有」。
- 一个任务涉及多个仓库、任务专用目录。前后端在同一个仓库里由用户在工程上约束,任务功能不加锁。
- 任何新的主机侧能力(执行主机上不新增 RPC、文件、端口或服务)。
- 截止日期与提醒、保存视图、任务模板、从外部 issue 导入、任务依赖、在智能体仪表盘和 AI Vault 上显示任务编号。这些以后再说,本版不预先设计。
@@ -71,12 +77,14 @@ flowchart TB
W -.->|"Orca 现有"| PR
```
-| 对象 | 规则 |
-| ------ | ---------------------------------------------------------------------------------------------------------- |
-| 项目 | 对应一个 Orca 仓库(侧栏里的「项目」),一个仓库最多对应一个项目 |
-| 任务 | 属于一个项目;层级只有两层:顶层任务和子任务,子任务下不能再建子任务 |
-| 工作区 | 一个顶层任务最多关联一个工作区,一个工作区最多属于一个顶层任务;子任务不单独关联工作区,使用父任务的工作区 |
-| 会话 | 工作区里的一个智能体标签,终端视图和聊天视图都算;任务和子任务都可以开会话,一个任务可以有多个会话 |
+| 对象 | 规则 |
+| -------- | ---------------------------------------------------------------------------------------------------------- |
+| 项目 | 关联 0 到多个 Orca 仓库(侧栏里的仓库),一个仓库也可以被多个项目关联(D-008) |
+| 任务 | 属于一个项目;层级不限,不能挂到自己或自己的下级下面(D-008) |
+| 工作区 | 每个任务最多关联一个工作区,一个工作区最多属于一个任务;没有自己工作区的任务,沿用最近一层上级任务的工作区 |
+| 会话 | 工作区里的一个智能体标签,终端视图和聊天视图都算;任务和子任务都可以开会话,一个任务可以有多个会话 |
+| 进展记录 | 动态的一种:智能体回写、轮次结束的最后一句话、Orca 生成的摘要;任务卡和详情顶部单独突出显示(REQ-022) |
+| 对话 | 通用对话只是入口,不是任务;由它派出的任务,时间线第一条记「由对话派出」,能跳回那段对话(REQ-030) |
### 任务怎么展示(2026-10-30 定)
@@ -93,20 +101,59 @@ flowchart TB
- 列表、看板、子任务列表里,作为对齐用的小号灰色列;
- 详情页顶部的位置标识,此时标题就在下方。
+### 用词(2026-10-30 定)
+
+桌面和手机、中文和英文都用同一套词,以本文的需求用词为准。
+
+| 概念 | 中文 | 英文 |
+| ---------------------------------- | ---------- | --------------------- |
+| 新建工作区来做这个任务(快捷键 I) | 开始做 | Start work |
+| 不继承上级的工作区,自己开 | 单独开始做 | Start work separately |
+| 工作区被删后再来一次 | 重新开始做 | Start work again |
+| 新开一个会话(快捷键 A) | 开会话 | New session |
+| 跳到已有的会话 | 打开会话 | Open session |
+| 任务的分组 | 项目 | Project |
+| Orca 侧栏里的仓库 | 仓库 | Repository |
+| 主机断开 | 未连接 | not connected |
+| 主机断开或连接中断,状态看不到 | 无法确认 | Can’t confirm |
+| 智能体没有状态上报 | 状态未知 | Status unknown |
+| 会话等人处理登录、额度等 | 被卡住 | Blocked |
+| 会话做完一轮 | 做完一轮 | Finished a turn |
+| 会话退出 | 已结束 | Ended |
+| 任务与工作区、会话的关系 | 关联 | Link |
+| 暂缓 | 稍后提醒 | Snooze |
+| 一句话把事交出去 | 派活 | Dispatch |
+| 只要回答,不建任务 | 只问 | Ask only |
+| 不离开任务看会话 | 速览 | Quick view |
+| 会话本身的标签或页面 | 完整会话 | Full session |
+| 智能体把结果写到任务上 | 回写 | Write back |
+| 任务卡上的那一行 | 最新进展 | Latest progress |
+| 每轮结束时记下的最后一句话 | 轮次结束 | Turn ended |
+| 合入后等用户验收的状态 | 待验收 | Awaiting acceptance |
+| 验收通过 | 通过 | Accept |
+| 验收不通过 | 打回 | Send back |
+| 侧栏按任务显示 | 任务模式 | Task mode |
+| 理解请求、提方案的会话 | 调度会话 | Dispatcher session |
+| 调度会话提出、等用户确认的计划 | 提案 | Proposal |
+| 附在第一句指令后的约定 | 任务说明 | Task brief |
+
+- 本地任务界面里一律用「关联」。上游自己的「已链接的工作」等文案不改。
+- 手机页面标题是「Local tasks」。
+
## 5. 需求点
以下「SSH 场景」指客户端是用户的桌面 Orca,工作区、会话和 PR 所在的执行主机是 SSH 主机;配对服务器与 SSH 主机遵守同样的规则。会话事实只用 `live`(在运行)、`unverifiable`(无法确认)、`exited`(已结束)三个词。
### REQ-001 项目与仓库
-- 目标级别:P0;当前状态:部分实现(2026-10-26 切片 1:一个项目对应一个仓库的版本已实现并验证,见 TC-001;2026-10-28 改为项目可关联 0 到多个仓库,待实现,D-008)。
+- 目标级别:P0;当前状态:部分实现(2026-10-26 切片 1:一个项目对应一个仓库的版本已实现并验证,见 TC-001;2026-10-28 改为项目可关联 0 到多个仓库(D-008),已随 PR #71 实现,真机证据在本机 `.docs/local-tasks-ui-validation/2026-10-28/codex-d008-verify/`,测试用例文档尚未补对应条目)。
- 描述:项目是任务的容器,字段有名称、前缀、状态集、两条自动规则的开关,以及关联的仓库(0 到多个 Orca 仓库,其中一个是默认仓库)。
- 没有仓库的项目只做任务管理,比如调研、运营、写文档。
- 多个仓库的项目用于前后端分仓等跨仓的情况;一个仓库也可以被多个项目关联。
- 第一次进入任务页时,按用户已有的仓库推荐生成项目(每个仓库一个,用户确认后才创建);也可以随时新建一个空项目,之后再关联仓库。
- - 设置 → 任务 → 项目可以改名称、前缀、状态、规则开关和关联的仓库。
+ - 在「设置 → 本地任务」分区里,可以改项目的名称、前缀、状态、规则开关和关联的仓库;「设置 → 任务来源」的本地任务卡片里,「管理项目与状态」也跳到这里。手机建项目时同样可以一次勾选多个仓库(2026-10-30)。
- 本机场景:本机仓库可以关联到项目。
-- SSH 场景:SSH 主机上的仓库同样可以关联;项目只记录仓库在 Orca 里的标识。项目存在拥有它的那台 Orca 上(REQ-018):桌面上的项目在 SSH 主机断开时照常显示和编辑;SSH 主机上的项目在断开时显示为无法连接,不读也不写,恢复联系后再读。
+- SSH 场景:SSH 主机上的仓库同样可以关联;项目只记录仓库在 Orca 里的标识。项目存在拥有它的那台 Orca 上(REQ-018):桌面上的项目在 SSH 主机断开时照常显示和编辑;SSH 主机上的项目在断开时显示为未连接,不读也不写,恢复联系后再读。
- 验收标准:建一个没有仓库的项目,能建任务、改状态;建一个关联前端、后端两个仓库的项目,「开始做」时两个仓库都能选;推荐列表包含本机仓库和 SSH 仓库。
### REQ-002 编号与前缀
@@ -125,11 +172,11 @@ flowchart TB
### REQ-004 任务与子任务
-- 目标级别:P0;当前状态:部分实现(2026-10-26 两层版本已实现并验证,见 TC-004、TC-005、TC-006、TC-027、TC-030;2026-10-28 改为层级不限,待实现,D-008)。
+- 目标级别:P0;当前状态:部分实现(2026-10-26 两层版本已实现并验证,见 TC-004、TC-005、TC-006、TC-027、TC-030;2026-10-28 改为层级不限(D-008),已随 PR #71 实现,整棵子树防环,测试用例文档尚未补对应条目)。
- 描述:
- - 字段:标题、描述、完成标准(可选,普通列表,不带勾选框)、状态、优先级(无、紧急、高、中、低)、标签、父任务。
+ - 字段:标题、描述、状态、优先级(无、紧急、高、中、低)、标签、父任务。「完成标准」字段于 2026-11-02 取消(D-014):界面不再显示和编辑,升级时把已有的条目追加到描述末尾,库里的列只读保留一个版本再删。
- 层级不限:任何任务都可以有子任务。不能把任务挂到它自己或它的下级下面。列表按树缩进显示并可折叠,看板卡片和详情显示「上级 › 任务」路径。
- - 新建任务对话框带「继续创建」;在子任务输入框里粘贴多行文本,可以一次建多个子任务。手机多行创建会将表单中的状态、优先级、标签与完成标准应用到这些子任务;部分保存失败时明确保留未完成状态,重试不重复创建。
+ - 新建任务对话框带「继续创建」;在子任务输入框里粘贴多行文本,可以一次建多个子任务。手机多行创建会将表单中的状态、优先级、标签应用到这些子任务;部分保存失败时明确保留未完成状态,重试不重复创建。
- 删除任务只删任务记录,关联的工作区和会话都不动。删除带下级的任务时,所有下级一起删除,确认框写明总数。
- 移到别的项目时,整棵子树一起移,各自拿到新项目的编号,旧编号仍能打开。已关联工作区的任务也能移,工作区关联保留。
- 下级完成不会带动上级,上级的状态由用户决定。
@@ -139,7 +186,7 @@ flowchart TB
### REQ-005 评论与动态
- 目标级别:P1;当前状态:部分实现(2026-10-26 切片 1:评论与属性动态已实现,见 TC-007;2026-10-27 工作区、会话关联与规则触发的动态已接入,真实 PR 合并触发的动态未验收,见 TC-105)。
-- 描述:任务详情下方是评论与动态。动态记录状态、优先级、父任务等属性变化,工作区和会话的关联与取消关联,以及自动规则触发的变化(例如「PR #72 合并后自动移到已完成」)。
+- 描述:任务详情下方是评论与动态。动态记录状态、优先级、父任务等属性变化,工作区和会话的关联与取消关联,以及自动规则触发的变化(例如「PR #72 合并后自动移到已完成」)。2026-11-02 起默认只显示你的评论、智能体回写(REQ-022)、由对话派出或追加指令(REQ-029)、验收通过或打回(REQ-024)、记成任务;系统事件(新建、会话启动、规则改状态、关联与取消关联、撤销)折叠成一行「系统记录 N」,点开才列出。
- 本机场景与 SSH 场景:一致。动态里写明工作区所在的主机。
- 验收标准:自动规则改了状态时,动态里有一条记录写明触发原因。
@@ -153,7 +200,7 @@ flowchart TB
- 看板的「已取消」列默认隐藏。
- 计数口径:左侧视图、手机首页和分段控件上的数字,是当前项目范围内的任务总数(含子任务);导航上「需要你」的数字是全局的,不分项目。
- 列表分组的数字,是这一组的值(状态、优先级或项目)下有多少任务,子任务也算在内,和看板列的数字一致(2026-10-30 修订)。子任务的状态和父任务不同时,它仍显示在父任务下面,但计在自己状态的那一组里。原口径「只数顶层条目」的问题是:侧栏写「进行中 2」,分组头却写「进行中 1」,用户一眼看到就对不上。
-- 本机场景与 SSH 场景:列表和看板读的是各台主机自己的任务库(REQ-018),只对在联系的主机请求;不在联系的主机,它的任务不显示新数据,标为无法连接。打开和筛选不额外访问执行主机。会话状态点按 REQ-012 显示。
+- 本机场景与 SSH 场景:列表和看板读的是各台主机自己的任务库(REQ-018),只对在联系的主机请求;不在联系的主机,它的任务不显示新数据,标为未连接。打开和筛选不额外访问执行主机。会话状态点按 REQ-012 显示。
- 验收标准:任意数据下,三处计数按上述口径互相对得上。
### REQ-007 键盘与 Cmd+J
@@ -167,14 +214,16 @@ flowchart TB
- Cmd/Ctrl+Enter:开始做。任务自己的工作区还在时,跳到那个工作区。主机不可达时说明原因。
- 搜编号或标题,也能找到任务所属的工作区。
- 本地任务来源在设置里被隐藏时,以上都不出现。
- - 全局快捷键按快捷键 ID 注册,按平台显示,避开 Orca 已占用的组合键。
+ - 2026-11-02 起 Cmd+J 加「派活…」动作:跳到任务首页并聚焦派活输入框(REQ-029)。不在调色板里解析 @,上游调色板只能加动作。
+ - 不新增全局快捷键(2026-10-30 定)。上面的单键只在任务页里生效,不进 Orca 的快捷键设置;在任务页里,Cmd/Ctrl+F 聚焦搜索框,离开任务页后交还给 Orca。
+ - 暂缓:本地任务详情不进 Orca 的前进/后退历史,鼠标侧键和 Cmd+[ 不能返回,需要在上游历史条目上加接缝,以后再做。
- 本机场景与 SSH 场景:一致。
- 验收标准:macOS、Windows、Linux 上 Cmd+J 菜单显示的组合键与平台一致;在输入框里按 C 不会新建任务。
### REQ-008 任务页入口
- 目标级别:P0;当前状态:已实现(2026-10-26 切片 1:任务页「本地」来源,见 TC-011、TC-028;2026-10-27 任务按钮上「需要你」的数字已接入,随需要你条目出现与消失,见 TC-106)。
-- 描述:Orca 现有的「任务」页新增来源「本地」,排在 GitHub、GitLab、Linear、Jira 前面。左侧栏的「任务」按钮上显示「需要你」的数字,悬停时的来源快捷图标里也有「本地」。GitHub、GitLab 等现有来源的行为不变。
+- 描述:Orca 现有的「任务」页新增来源「本地」,排在 GitHub、GitLab、Linear、Jira 前面。左侧栏的「任务」按钮上显示「需要你」的数字,悬停时的来源快捷图标里也有「本地」,图标上带同一个数字;不能悬停的设备上,快捷图标按上游规则常显,数字也就看得到(2026-10-30)。用户在设置里隐藏了「任务」按钮时,数字不显示,入口仍在任务页和 Cmd+J。GitHub、GitLab 等现有来源的行为不变。
- 「设置 → 任务来源」的提供方卡片里有「本地任务」,排在 Jira 后面,和其他提供方一样可以显示或隐藏(2026-10-30 增补):
- 隐藏后,这些入口都不再出现:
- 任务页来源栏、侧栏快捷图标、「需要你」数字;
@@ -193,13 +242,15 @@ flowchart TB
### REQ-009 开始做
- 目标级别:P0;当前状态:部分实现(2026-10-27:开始做、无智能体创建与完成后关联已接入;本机与 SSH 的空终端真实界面回归通过;真实智能体启动未验收,见 TC-101、TC-110)。
-- 描述:在任意一层任务上点「开始做」(快捷键 I),打开 Orca 现有的新建工作区弹窗并预填:仓库为项目的默认仓库,可以换成项目关联的其他仓库;项目没有仓库时从全部仓库里选;运行位置为上次在这个仓库用的主机,可以改;名称由编号和标题生成(例如 `shop-12-order-export`),可以改;分支名由 Orca 按用户的分支前缀设置从名称生成(例如 `zc/shop-12-order-export`);智能体可选,选了时任务的标题、描述和完成标准放进智能体输入框当草稿,不直接发送(和上游从 issue 开工一致,2026-10-30 改)。
+- 描述:在任意一层任务上点「开始做」(快捷键 I),打开 Orca 现有的新建工作区弹窗并预填:仓库为项目的默认仓库,可以换成项目关联的其他仓库;项目没有仓库时从全部仓库里选;运行位置为上次在这个仓库用的主机,可以改;名称由编号和标题生成(例如 `shop-12-order-export`),可以改;分支名由 Orca 按用户的分支前缀设置从名称生成(例如 `zc/shop-12-order-export`);智能体可选,选了时任务的标题和描述作为第一句指令,末尾附任务说明(REQ-022),默认直接发送,可以改成放进输入框当草稿(2026-11-02 用户定:从任务发起的会话一律默认直接发送,和开会话、派活一致;此前 2026-10-30 定的是只放草稿)。默认值来自「设置 → 本地任务」的「从任务发起的会话:直接发送 / 放进输入框」;上游的新建工作区弹窗不加这个选项,按设置执行;本功能自己的弹层里可以临时改。
+ - 2026-11-02 起,新建任务的流程见 REQ-027:第一屏选「新建工作区」就直接接本弹窗。
- 2026-10-30 起,本地任务接入新建工作区的「创建自」,和 GitHub、Linear 等来源同一套交互:
- 「开始做」打开弹窗时,在「创建自」里预选这个任务,显示来源胶囊(标题为主,编号是小号灰字)。
- 「创建自」新增「本地」页签(和上游页签一样只写一个词),排在「智能」之后,按标题或编号搜索;「智能」页签的结果里不混入本地任务。
- 清掉胶囊就是普通新建,不关联任务。文件夹和非 git 的创建目标不提供这个页签。
- 在本地任务详情里按 Cmd+N,等于对这个任务开始做。
- 关联仍记在任务库,不写进上游的「已链接的工作」,原因是旧版本会拒收或丢弃不认识的提供方。
+ - 暂缓:从本地任务创建的工作区,在上游的创建统计里来源记为 `unknown`。改它要在上游的统计口径里加值,不由 fork 改。
创建成功后工作区关联到任务;创建失败或取消时不留关联。之后改标题,分支名不变。任务这边也可以选「用已有工作区」,走 REQ-011 的关联。每个任务最多一个工作区;跨仓的工作拆成子任务,每个子任务在自己的仓库里开始做。没有自己工作区的任务,沿用最近一层上级任务的工作区。选中的仓库是文件夹(不是 git 仓库)时,只提供「用已有工作区」,不新建,也没有分支。
@@ -214,8 +265,10 @@ flowchart TB
- 智能体默认为 Orca 设置里的默认智能体,可以改;
- 位置是任务自己的工作区,没有就用最近一层上级任务的工作区,只读;
- 显示方式(终端或聊天视图)跟随 Orca 的设置;
- - prompt 是任务的标题、描述、完成标准,子任务额外带上各级上级任务的摘要;
- - 发送方式由用户选:直接发送,或只放进输入框当草稿。
+ - prompt 是任务的标题、描述,子任务额外带上各级上级任务的摘要;
+ - 发送方式默认直接发送,可以改成只放进输入框当草稿(2026-11-02 定);
+ - 指令末尾附任务说明(REQ-022),可以在设置里关掉;
+ - 任务视图标签栏的「+」就是开会话,弹层里可以选子任务,第一句指令默认「按任务说明继续」(REQ-026);任务没有工作区时「+」就是开始做。
会话开起来后记在这个任务下。任务和它的上级都还没有工作区时,「开会话」先走一遍「开始做」。同一个工作区里可以同时运行多个会话。
@@ -228,14 +281,14 @@ flowchart TB
- 目标级别:P0;当前状态:部分实现(2026-10-27:任务详情的手动关联、唯一性、取消关联与换父后的历史会话规则已接入并回归;2026-10-27 侧栏工作区卡片 chip、右键「关联到任务…」、终端会话头部 chip 已在真实界面验证,结构化聊天会话 chip 未实测;手机关联已有工作区含 SSH 与配对服务器上的工作区,见 TC-103、TC-109、TC-110、TC-112)。
- 描述:
- 已有工作区(2026-10-30 起走 Orca 的「工作区详情 → 已链接的工作 → 添加引用 → 本地」):
- - 侧栏右键「关联到任务…」和会话 chip 卡片里的「关联工作区到任务…」,都打开这个弹窗并定位到「本地」页签;任务详情里也能选「用已有工作区」。
+ - 侧栏右键「关联到任务…」打开这个弹窗并定位到「本地」页签;任务详情里也能选「用已有工作区」。
- 页签可以按标题或编号搜索;关联了这个仓库的项目里的任务排在前面,其余任务也能选;已有工作区的任务置灰并说明原因。
- 已关联的任务显示在「议题和任务」里,以标题为主,计入「已链接 N 项」,可以取消。
- 改动是草稿,点「保存」才写入。选第二个任务时直接替换,并标「将替换」。任务这一步保存失败时弹窗不关。
- 文件夹工作区同样可以关联。
- 来源在设置里被隐藏时页签不出现,但已关联的任务仍显示,也能取消。
- 任务库所在的 Orca 不在联系时,页签里的任务置灰并写明原因。
- - 已有会话:在会话头部的任务 chip 上选「关联到任务…」。可选范围是会话所在工作区所属的任务及其全部下级;工作区没关联任务时,先关联工作区。
+ - 已有会话:在会话头部的任务 chip 上选「关联到任务…」。可选范围是会话所在工作区所属的任务及其全部下级。工作区还没关联任务时,会话头部不显示 chip,要先从侧栏右键或工作区详情关联工作区(2026-10-30 定:不给每个会话头部加入口)。任务模式侧栏的「未关联任务的会话」组里,每个会话有「记成任务…」:打开新建任务第一屏,标题预填会话最后一句,「在哪做」预选那个工作区,建好后工作区和会话一起关联(2026-11-02 补,REQ-026、REQ-027)。
- 关联都可以取消。取消关联只删关联记录,不动工作区和会话;取消工作区关联时,这个任务及其子任务在该工作区里的会话关联一并取消。
- 任务换上级时,它自己的工作区关联和已有的会话关联都保留(会话关联记的是它实际运行的工作区);没有自己工作区的任务,之后新开的会话用新上级沿用下来的工作区。
- 本机场景:本机工作区和会话可以关联。
@@ -247,10 +300,11 @@ flowchart TB
- 目标级别:P0;当前状态:部分实现(2026-10-27:进展快照、列表/看板徽标和详情工作区/会话区域已接入;真实会话和 PR 状态的完整验收未覆盖,见 TC-104)。
- 描述:任务上只显示 Orca 已有的数据,不新增判断。
- 列表行和看板卡片:工作区标记、会话状态点(复用 Orca 的智能体状态点)、PR 状态。
- - 任务详情右栏「工作区」:工作区名、主机、分支、改动统计(Orca 已读取过时才显示)、PR 和 Checks;操作有「打开工作区」和「看改动」(跳到 Orca 的源码管理)。
- - 任务详情「会话」:每个会话一行,显示智能体、所属子任务、状态点、最后一句话,点击跳到会话。
+ - 2026-11-02 起,列表行、看板卡片和任务模式的悬停卡显示「最新进展」一行,取值优先级见 REQ-022;有子任务的任务多一行「子任务 N · 状态 · 最新:某子任务 …」,父任务自己有进展时先显示自己的,没有时取子孙里最近一条并标明来自哪个子任务。
+ - 任务详情右栏「工作区」:工作区名、主机、分支、改动统计(Orca 已读取过时才显示)、PR 和 Checks;操作有「打开工作区」和「看改动」(跳到 Orca 的源码管理)。2026-11-02 起右栏取消,这些合成头部的一行工作区信息,没有的项不显示,点开有「打开工作区」「查看更改」(REQ-026)。
+ - 任务详情「会话」:每个会话一行,显示智能体、所属子任务、状态点、最后一句话,点击打开速览浮层(REQ-028,2026-11-02 改);会话行并入概览的「现在」卡(REQ-026)。
- 子任务行显示它开的会话的状态点。
- - 筛选「有会话在等我」:任务的会话里有进入「需要你」的条目。
+ - 筛选「有会话在等你」:任务的会话里有进入「需要你」的条目。
- 例外状态:工作区被删除,显示「工作区已删除」,可以重新开始做;会话关闭了,显示「已结束」,可以再开一个;Orca 重启后找不到记录的会话,显示「找不到这个会话」,提供「打开工作区」;没有集成状态上报的智能体,显示「状态未知」。
- 本机场景:状态实时更新。
- SSH 场景:主机在联系范围内时,状态与本机一样实时更新。主机断开时,会话状态点和 PR 显示「无法确认」(`unverifiable`),不当成已结束;恢复联系后自动对上。断开期间不重连、不轮询、不读该主机。
@@ -261,9 +315,9 @@ flowchart TB
- 目标级别:P1;当前状态:部分实现(2026-10-27:开始做规则已在本机实际界面验证,PR 规则维护与去重已接入;真实 PR 合并触发未验收,见 TC-105、TC-110)。
- 描述:状态由用户改,只有两条自动规则,都可以在项目设置里关掉,默认都开:
1. 从任务里「开始做」(新建或关联工作区,或开第一个会话),任务还在待规划或未开始类别时,移到进行中类别的第一个状态。
- 2. 工作区关联的 PR 合并后,关联这个工作区的任务移到已完成类别的第一个状态;它的上级不自动完成。
+ 2. 工作区关联的 PR 合并后,关联这个工作区的任务移到项目设置里选定的状态:「待验收」(默认;评审类别下新增的默认状态,老项目也补上)、已完成类别的第一个状态,或不动(2026-11-01 定,见 REQ-024);它的上级不自动完成。规则当前的判定显示在任务详情和待验收卡上,例如「PR #231 合并后按项目设置进入待验收」「项目关掉了这条规则」。
- 规则触发时在动态里留一条记录(REQ-005)。
+ 规则触发时在动态里留一条记录(REQ-005)。规则 2 由桌面窗口读到 PR 状态时执行:只用手机、桌面窗口一直没打开时,任务要等下次打开桌面窗口才会自动完成。
- 本机场景:本机工作区的 PR 合并后触发规则 2。
- SSH 场景:SSH 工作区的 PR 状态由 Orca 现有的 PR 读取提供;主机不在联系范围内时不读,恢复联系后读到「已合并」再触发,不补发、不重复触发。
@@ -271,7 +325,7 @@ flowchart TB
### REQ-014 需要你:判定
-- 目标级别:P0;当前状态:部分实现(2026-10-27:桌面判定版本已接入;2026-10-28 改为由拥有会话的主机判定,待实现,D-008)。
+- 目标级别:P0;当前状态:部分实现(2026-10-27:桌面判定版本已接入;2026-10-28 改为由拥有会话的主机判定(D-008),已随 PR #71 实现,SSH 真机证据在本机 `.docs/local-tasks-ui-validation/2026-10-30/ui-final/ssh/`,测试用例文档尚未补对应条目)。
- 描述:判定在拥有会话(也就是拥有这个任务库)的那台 Orca 上做,桌面、手机、局域网网页版看到的是同一份结果。完全使用 Orca 现有的智能体状态(hooks 优先,其次是终端里的 OSC 7501),不自己猜测。只看挂在任务上的会话。规则只有 4 条:
| 类型 | 判定 | 何时自动消失 |
@@ -281,9 +335,13 @@ flowchart TB
| 被卡住 | 状态为阻塞,比如要登录、额度用完 | agent 恢复工作 |
| 做完一轮,你还没看 | 状态为完成,并且这一轮还没人看过 | 用户在任意一端打开会话看过、标记已看,或给了下一句指令 |
- 不进「需要你」的:正在工作的会话;已完成且看过的会话;主机断线的会话(显示「无法确认」);Orca 刚重启、还没收到新状态的会话;没有挂在任务上的会话(仍由智能体仪表盘和现有通知负责,两边不重复);没有集成状态上报的智能体(会话行显示「状态未知」)。
+ 不进「需要你」的:正在工作的会话;已完成且看过的会话;主机断线的会话(显示「无法确认」);Orca 刚重启、还没收到新状态的会话;没有挂在任务上的会话(仍由智能体仪表盘和现有通知负责);没有集成状态上报的智能体(会话行显示「状态未知」)。
+
+ 「完成」只表示 agent 的这一轮结束了,不代表任务做完了:条目只写「Claude 停下了,最后一句:……」,任务状态不因此改变。每条显示任务标题和编号、类型、智能体和它的最后一句话、时间;同一个任务下的多条合并成一行。入口有左侧栏「任务」按钮上的数字、任务页的「需要你」视图、手机首页的任务卡片;系统通知沿用 Orca 现有的智能体通知设置,不新增开关。
- 「完成」只表示 agent 的这一轮结束了,不代表任务做完了:条目只写「Claude 停下了,最后一句:……」,任务状态不因此改变。每条显示任务编号和标题、类型、智能体和它的最后一句话、时间;同一个任务下的多条合并成一行。入口有左侧栏「任务」按钮上的数字、任务页的「需要你」视图、手机首页的任务卡片;系统通知沿用 Orca 现有的智能体通知设置,不新增开关。
+ 「任务在等你」(2026-11-01 定,修订 D-3):除了上面按会话状态判定的 4 类,另有按任务字段判定的两类,在视图和首页里单独分组显示,叫「任务在等你」,和「会话在等你」区分:待验收(任务处于「待验收」状态,REQ-024)、等你决定(REQ-023)。条目正文优先用智能体回写里的「需要你什么」,没有才用最后一句话(REQ-022)。
+
+ 「需要你」与智能体仪表盘的「需要你」同名,口径不同(2026-10-30 定,不改名):这里只看挂在任务上的会话,仪表盘看全部会话;挂在任务上的会话会在两边同时出现,这是预期的。在这里标记已看(E)只移出本地列表,不会清掉 Orca 的未读和仪表盘里的条目;反过来,在会话里看过会清掉这里的「做完一轮,你还没看」。
- 本机场景:4 条规则都能触发,也都按上表自动消失。
- SSH 场景:判定用的状态由执行主机上报,与本机一致;主机断开时条目不再算「需要你」,改为在会话行显示「无法确认」;恢复联系后按真实状态重新判定。拿不到等待原因的主机,一律显示「等你回答」。
@@ -291,7 +349,7 @@ flowchart TB
### REQ-015 需要你:操作
-- 目标级别:P0;当前状态:部分实现(2026-10-27:桌面暂存队列版本已接入;2026-10-28 改为照 Orca 现有会话发消息一次发送、失败退回文字,待实现,D-008)。
+- 目标级别:P0;当前状态:部分实现(2026-10-27:桌面暂存队列版本已接入;2026-10-28 改为照 Orca 现有会话发消息一次发送、失败退回文字(D-008),已随 PR #71 实现,暂存表已在库版本 4 删除,测试用例文档尚未补对应条目)。
- 描述:每条可以:
- 打开会话。
- 回复:和 Orca 现有的给会话发消息一样,直接发进会话,发一次,显示送达状态(已送达、已发出未确认,或失败原因);发不出去时文字退回回复框,不排队、不自动重发。只有「做完一轮,你还没看」能回复(给下一句指令,终端和聊天视图都可以)。其余只提供「打开会话」:
@@ -300,6 +358,7 @@ flowchart TB
- 被卡住:登录、额度这类问题要在会话里解决。
- 标记已看(E):只把条目移出列表,不改变任何事实;同一个会话下次进入需要你的状态时会重新出现。
- 暂缓(H):1 小时、今晚、明天、下周,或自定义时间;到时间后条目回到列表(如果仍满足判定)。
+ - 2026-11-02 补:这条「只有做完一轮能回复」只管一次性发送一句话的通道(首页「需要你」行、手机回复抽屉、打回、派活到空闲会话)。会话速览浮层(REQ-028)挂的是会话本身,输入走会话自己的通道,不受这条限制(D-014)。验收打回(REQ-024)把原因发给做完一轮的会话,没有时一键开新会话;直接派活到有做完一轮会话的工作区时,正文也按这条规则发给它(REQ-029)。
- 本机场景:回复后显示送达状态。
- SSH 场景:主机在联系范围内时与本机一致。主机未连接时与 Orca 现有的会话输入框一样:桌面回复框上方提示主机离线或重连中,发送失败时文字保留;手机回复框禁用并显示「重连中」。不暂存、不在恢复联系后自动发出。
- 验收标准:桌面和手机上各回复一次,看到送达;SSH 断开时回复,文字保留在回复框,恢复联系后可以再发。
@@ -308,7 +367,7 @@ flowchart TB
- 目标级别:P1;当前状态:部分实现(2026-10-27:按任务分组、组折叠、定位工作区时自动展开、分组时隐藏不适用的项目排序、工作区卡片与终端会话 chip 已在真实界面验证;结构化聊天会话 chip 未实测,见 TC-103、TC-108)。
- 描述:
- - 左侧栏分组方式新增「按任务」:组头是任务,组内是现有的工作区卡片;没关联的放在「未关联任务」组。默认分组不变。
+ - 左侧栏分组方式新增「按任务」:组头是任务,组内是现有的工作区卡片;没关联的放在「未关联任务」组。默认分组不变。任务模式(REQ-026)上线后评估「按任务」分组是否还用得上,不用就走下线流程:它是登记过的功能,改注册表和 journal 后再删(2026-11-02 用户定)。
- 工作区卡片(2026-10-30 起):
- 关联了任务的卡片显示任务标题小标签,编号放进悬停提示,工作区名优先。
- 按任务分组时,组头已经显示了任务,卡片里只显示工作区名。
@@ -318,13 +377,13 @@ flowchart TB
- 挂了多项时,任务排在「议题和任务」第一行;
- 只挂了本地任务的工作区也有悬停详情;
- 任务库所在的 Orca 不在联系时,状态写「无法确认」。
- - 会话头部显示任务 chip,主文字是任务标题,点开是任务小卡(父任务、打开任务);没关联时提供「关联到任务…」。
-- 本机场景与 SSH 场景:本机和 SSH 工作区都按关联记录分组和显示,主机断开时工作区卡片照常显示任务编号。
+ - 会话头部显示任务 chip,主文字是任务标题,点开是任务小卡(父任务、打开任务)。会话所在的工作区已关联任务、会话本身没关联时,chip 提供「关联到任务…」;工作区也没关联时不显示 chip(见 REQ-011)。
+- 本机场景与 SSH 场景:本机和 SSH 工作区都按关联记录分组和显示,主机断开时工作区卡片照常显示任务标题。
- 验收标准:切到「按任务」分组后,SHOP-12 组内是 `shop-12-order-export`,refund-webhook 在「未关联任务」组;切回默认分组与改动前一致。
### REQ-017 手机端
-- 目标级别:P0;当前状态:部分实现(切片 1、切片 2 手机功能已实现,协议层与云真机部分验证,见 TC-012、TC-013、TC-016、TC-026、TC-027、TC-035、TC-102、TC-109;2026-10-28 改为按主机读写任务库并支持树形层级,待实现,D-008)。
+- 目标级别:P0;当前状态:部分实现(切片 1、切片 2 手机功能已实现,协议层与云真机部分验证,见 TC-012、TC-013、TC-016、TC-026、TC-027、TC-035、TC-102、TC-109;2026-10-28 改为按主机读写任务库并支持树形层级(D-008),已随 PR #71 实现;2026-10-30 补齐与桌面对齐的细节,暂缓项见下文)。
- 描述:手机和查看项目、会话一样读写任务:在某台主机的页面里,看到这台主机的任务库,以及经它连接的服务器上的任务库;功能与桌面对齐:
- 首页任务卡片显示「需要你」和进行中的数量,列出最急的 3 条;
- 「需要你」页可以回复、打开会话,左滑可以标记已看或暂缓;
@@ -338,9 +397,37 @@ flowchart TB
- 主机页顶栏不再有单独的本地任务按钮。
- 长按工作区的详情里,在评审之后列出它所属的任务,点开是任务详情。
- 工作区行显示任务图标,跟随桌面的显示开关。
- - 本地任务来源在桌面被隐藏时,这些入口都不出现。
-
- 无法连接桌面 Orca 时,任务入口显示「无法连接 mbp5 的 Orca」这类提示(仅使用已读到的机器名;没有机器名时不显示 hostId);配对失效单独提示重新配对。桌面 Orca 版本过旧、不支持本地任务时,手机上不显示任务入口。
+ - 新建工作区的「创建自」里有「Local」来源,排在 Smart 之后:选中任务后,建好的工作区关联到任务(2026-10-30 定)。
+ - 手机把任务内容作为智能体的第一条消息直接发送,不放进输入框当草稿;「开始做」也不再提供「立即发送」开关(2026-10-30 用户定)。
+ - 原因:主机对草稿一律不投递,上游手机自己的启动也都是直接发送。
+ - 桌面不变,仍然放进草稿。
+ - 主机回报没有送达时,比如智能体还停在它自己的信任提示上,手机留在任务页,并写明「任务内容没有送达,还在任务上」。
+ - 本地任务来源在桌面被隐藏时,这些入口都不出现;已关联任务的工作区仍显示任务图标、长按详情里的任务行和「Link to task…」。
+ - 以任务为主的工作流(2026-11-02):
+ - 首页不换:上游首页保留,本地任务卡加「需要你」(最急的两条)和最新进展一条,点卡片进本地任务页。
+ - 本地任务页顶部是派活输入框(REQ-029),写明「由 某桌面 执行」;@ 候选来自配对桌面能看到的主机;「只问」和调度会话经桌面(REQ-030)。
+ - 速览用底部抽屉(REQ-028),一键进完整会话页;等你回答的会话没有回复框。
+ - 验收通过和打回(REQ-024);任务详情的会话行带所属子任务。
+ - 手机要能看出「桌面的库」和「主机自己的库」:直连 SSH 的任务在桌面的库里,桌面关了就看不到,提示写明。
+ - 理由:手机的数据按主机分页,@ 候选和任务库都跟着主机走,输入框放在主机页下面天然对得上;不替换上游首页,也不用在首页开新接缝。
+ - 与桌面对齐的细节(2026-10-30 补齐):
+ - 列表行和看板卡显示会话状态点;
+ - 筛选有「有会话在等你」;
+ - 详情的工作区区显示 PR、Checks,识别工作区已删除;
+ - 会话行可以取消关联;
+ - 动态写明规则触发原因和工作区主机;
+ - 回复失败写明原因;
+ - 建项目可以多选仓库;
+ - 任务页记住上次的项目、分段和布局;从首页「需要你」进入时是全部项目;
+ - 「需要你」为空、但有会话无法确认时,写明「无法确认」。
+ - 暂缓(2026-10-30):
+ - 手机暂缓只有预设时间,没有「自定义时间」。原因:手机工程没有日期时间选择控件,不为这一处引入新依赖。
+ - 手机不显示工作区状态、改动统计。原因:
+ - 手机拿到的工作区数据里有状态 id,但没有状态名称和颜色,要显示就得多读一次主机的界面设置;
+ - 也没有改动统计,要显示就得在执行主机上新增一次 git 读取。
+ - 按「Orca 已读取过才显示」的规则,这两项不显示。
+
+ 连不上桌面 Orca 时,任务入口显示「mbp5 的 Orca 未连接」这类提示(只用已读到的机器名;没有机器名时不显示 hostId);配对失效单独提示重新配对。桌面 Orca 版本过旧、不支持本地任务时,手机上不显示任务入口。主机断开时,「开始做」「开会话」置灰,并在旁边写明原因;恢复联系后不自动轮询,由用户点「Check connection」重读。
- 本机场景:手机连接桌面 Orca,操作本机工作区上的任务。
- SSH 场景:手机连接桌面 Orca,任务的工作区在 SSH 主机上;状态显示与桌面一致,主机断开时同样显示「无法确认」。
@@ -348,47 +435,341 @@ flowchart TB
### REQ-018 数据归属与跨机器
-- 目标级别:P0;当前状态:部分实现(2026-10-26「只存桌面」版本已实现并验证,见 TC-013~TC-016、TC-025、TC-038;2026-10-28 改为任务库是每台 Orca 自己的资源,待实现,D-008)。
+- 目标级别:P0;当前状态:部分实现(2026-10-26「只存桌面」版本已实现并验证,见 TC-013~TC-016、TC-025、TC-038;2026-10-28 改为任务库是每台 Orca 自己的资源(D-008),已随 PR #71 实现:桌面、`orca serve`、orcad 各开自己的库,SSH 真机证据在本机 `.docs/local-tasks-ui-validation/2026-10-30/ui-final/ssh/`,测试用例文档尚未补对应条目)。
- 描述:任务库和 Orca 的项目一样,是每台 Orca 自己的资源:桌面 Orca 存它自己项目的任务,配对服务器(`orca serve`)和 SSH 主机上托管的 orcad 服务器存它们自己项目的任务,每个 profile 一份。任务与工作区、会话的关联存在拥有任务的那台主机上。客户端(另一台桌面、局域网网页版、手机)用 Orca 现有的远程连接读写,和管理那台主机的项目、会话一样;只在主机在联系时读,不重连、不轮询。同一个 Orca 项目在多台主机上各有一份时,任务也各存各的,不合并、不同步(D-008)。
- 同一次写入在 24 小时回执窗口内使用同一操作号、同一方法与参数重试时不重复执行;窗口内发生超过 1000 次其他写入、桌面重启后也保持去重。不同方法/参数或损坏回执明确失败且不重做;写入时按时间清理旧回执(D-006)。
- 本机场景:任务数据和工作区都在本机。
-- SSH 场景:SSH 主机上托管的 orcad 服务器拥有该主机上项目的任务库,数据文件在 SSH 主机的 Orca profile 目录里;桌面经现有连接读写,主机不在线时这台主机的任务显示为「无法连接」,不读、不重连;恢复联系后重新读取。
-- 验收标准:桌面项目的任务只在桌面的库里;SSH 主机上项目的任务只在 SSH 主机的库里,桌面和手机都能看到并编辑;SSH 断开时这些任务显示无法连接,恢复后自动对上;两台桌面连同一台 SSH 主机时,看到的是同一份 SSH 主机任务。
+- SSH 场景:SSH 主机上托管的 orcad 服务器拥有该主机上项目的任务库,数据文件在 SSH 主机的 Orca profile 目录里;桌面经现有连接读写,主机不在线时这台主机的任务显示为「未连接」,不读、不重连;恢复联系后重新读取。
+ - 直连 SSH(经 relay,主机上没有 orcad)的项目,任务存在发起连接的桌面库里(2026-10-30 补记)。两台桌面经 relay 连同一台主机时,各存一份,互不可见;要共享,需改用 orcad 托管。
+ - 2026-11-05 实施验证修订:当前 SSH 项目由主机上的 orcad 持有任务库;真实 SSH 终端里的 `orca task current` 和 `progress` 能访问这份库,桌面收到变化后更新。2026-11-02 草稿假设的 legacy relay 写回桌面路径不成立:旧 relay 不放行 task CLI,不将它视为支持回写;此类旧任务仍使用轮次末句兜底。新增 CLI 能力按 `local-tasks.v2` 探测,事件仍复用已有表;旧客户端遇到不认识的内部轮次事件要忽略。
+- 验收标准:桌面项目的任务只在桌面的库里;SSH 主机上 orcad 托管项目的任务只在 SSH 主机的库里,桌面和手机都能看到并编辑;SSH 断开时这些任务显示未连接,恢复后自动对上;两台桌面连同一台 orcad 托管的 SSH 主机时,看到的是同一份 SSH 主机任务。
### REQ-019 与 Orca 其他功能的衔接
- 目标级别:P2;当前状态:部分实现(2026-10-30 已接入,单元测试覆盖,真实界面未逐项验收)。
- 描述:本地任务像上游工作项一样,参与 Orca 的其他功能。
- - **PR 描述**:AI 生成 PR 描述时,带上工作区所属任务的标题、描述和完成标准,作为不可信的上下文,不当作 issue 引用。只在执行主机上读它自己的任务库,不跨机器。直连 SSH 的 PR 生成上游本来就不支持,这里也不支持。
+ - **PR 描述**:AI 生成 PR 描述时,带上工作区所属任务的标题和描述,作为不可信的上下文,不当作 issue 引用。只在执行主机上读它自己的任务库,不跨机器。直连 SSH 的 PR 生成上游本来就不支持,这里也不支持。
- **工作区清理**:「工单来源」维度新增「本地任务」;挂了任务的工作区不再算「完全空」。来源隐藏或没有项目时不提供这个筛选项。
- **CLI**:新增 `orca task` 命令组,包括 `list`、`show REF`、`current`(当前工作区所属的任务)、`comment REF 内容`、`status REF 状态名`。
- 输出以标题为主;写入带操作号,改状态时带版本号,防止覆盖别人刚做的修改。
- 主机没有任务库时,提示升级那台主机上的 Orca。
+ - 2026-11-02 起新增(切片 3a~3c):`create 标题 --parent REF`、`link REF --worktree 选择器`、`progress REF --file 文件`(REQ-022)、`update REF --title 标题`、`dispatch propose --file 提案文件`(REQ-030)。多行正文一律从文件读,参数里内联的多行文本会被 shell 转义弄坏,Windows PowerShell 还会把中文变成问号。
+ - 来自 CLI 的写入不能进入已完成类别:`status REF 某个已完成类别的状态` 被拒绝,并写明「完成要在界面上点」(原则 5)。现在的实现接受它,要改。
+ - `current` 先按会话关联找任务(终端里带 Orca 的会话标识时),找不到再按工作区;现在只按工作区找,在子任务的会话里会返回父任务。
+ - 报错写给智能体看:状态被人改过时说明是谁、改成了什么,并要求这一轮不再改状态、改为留评论;状态名不对时列出这个项目可用的状态(借鉴 Multica)。
- 本机场景与 SSH 场景:PR 描述和 CLI 都由拥有任务库的那台 Orca 执行;配对服务器和 orcad 主机上的工作区,读它们自己的库。
- 验收标准:
- 挂了任务的工作区生成 PR 描述时,能看到任务内容;
- 清理弹窗能按「本地任务」筛选;
- 在工作区终端里运行 `orca task current`,能看到所属任务。
+### 从提出到验收:工作流迭代(2026-10-30 提出,2026-11-01 用户确认)
+
+背景:用户反馈「从提出一个 issue 到完成,整条动线不流畅」,决策见 journal D-009。这次开发本身就暴露了几个断点:
+
+- 反馈散落在聊天和截图里,没有逐条记录;
+- 范围只在聊天里定,后来说「做完了」时对不上账;
+- 进展用户看不到;
+- 「完成」的标准只在执行方心里;
+- 合入以后,用户的 App 里还看不到;
+- 没有「用户验收」这一步;
+- 中途发现的新问题没有记下来。
+
+下面 REQ-020~REQ-025 补齐这条动线。2026-11-01 用户回复「都按建议」确认;2026-11-02 按方案评审修订了 REQ-022、REQ-024,并新增 REQ-026~REQ-030;同日用户定不要「完成标准」,REQ-021 取消(D-014)。分期见 §7 切片 3a~3d;代码等原型评审后再动。
+
+```mermaid
+flowchart LR
+ A["提出(REQ-020)"] --> B["澄清:把描述写清(REQ-020)"]
+ B --> C["拆解与定范围(REQ-023)"]
+ C --> D["开始做(REQ-009)"]
+ D --> E["执行与回写进展(REQ-022)"]
+ E --> F["自检:交付那一轮写清验证了什么(REQ-022)"]
+ F --> G["PR、CI、合入(Orca 现有)"]
+ G --> H["待你验收(REQ-024)"]
+ H -->|"通过"| I["已完成"]
+ H -->|"打回"| E
+ E -->|"发现新问题"| J["记成任务(REQ-025)"]
+ J --> A
+```
+
+原则 2「只做连接,不做流程」依然成立:任务这边仍然不替用户判断「做完没有」「做得对不对」。新增的是让每一步看得见、能对账的位置,结论都由用户点。
+
+### REQ-020 提出:截图附件与随手记成任务
+
+- 目标级别:P0;当前状态:未实现(2026-10-30 提出,2026-11-01 确认,排在切片 3d;`orca task create --parent` 提前到 3b)。
+- 描述:
+ - 任务描述和评论可以带图片:粘贴截图、拖入文件,手机上可以拍照或从相册选。图片存在拥有任务库的那台 Orca 上(REQ-018),按任务删除时一起删。单张和每个任务的总量都有上限,超出时说明原因。
+ - 随手记成任务:
+ - 在终端里选中文字或在聊天消息上右键,选「记成任务…」;
+ - 在 PR 评论、任务评论上同样可以;
+ - 标题和描述预填选中的内容,附上来源(哪个会话、哪条消息);
+ - 来源会话属于某个任务时,默认记成它的子任务,可以改。
+ - CLI 新增 `orca task create`,可以带 `--parent`,供智能体把「发现但这次不处理」的问题记下来(见 REQ-025)。
+- 本机场景:图片存在本机任务库旁边,界面直接显示。
+- SSH 场景:
+ - 图片随任务库存在 SSH 主机(orcad)或配对服务器上,按需读取,主机不在联系时显示占位,不重试。
+ - 旧版本的主机不支持附件时,不提供上传入口,只提示升级那台主机。新增的字段都是可选的,旧客户端忽略。
+- 验收标准:
+ - 在桌面粘贴一张截图到任务描述,手机上能看到;
+ - 在终端里选中一句报错,记成任务后,标题和来源都对,而且挂在当前任务下。
+
+### REQ-021 完成标准(已取消)
+
+- 目标级别:—;当前状态:已取消(2026-11-02 用户定:不要「完成标准」这个东西,D-014)。
+- 原因:任务分三类,只有可验证的交付能事先定标准,而且开工时往往定不准;探索类的标准只能是「给出结论」,零碎事写标准比做还费劲。强求就是填表。
+- 取消的内容:勾选、证据、智能体提议、项目模板、CLI `criteria check`、与 Goal 打通(Goal 的验收文档在 Goal 里单独写)。
+- 替代:验收看结果(REQ-024):待验收卡显示合入位置、改动和 PR、最新进展里智能体的自检结论、在哪验收;任务说明(REQ-022)要求智能体在交付那一轮写清楚验证了什么、怎么验的。
+- 已有数据:REQ-004 的「完成标准」字段去掉,升级时已有条目追加到描述末尾。
+
+### REQ-022 执行中回写进展与最新进展
+
+- 目标级别:P0;当前状态:部分实现(2026-11-05:进展回写、轮次末句持久化与父子汇总已实现,见 TC-301、TC-302、TC-309;生成摘要属 3d,暂缓)。
+- 描述:
+ - **任务说明**:从任务发起的每个会话(开始做、开会话、派活、「+」),第一句指令末尾附一段任务说明,用户可以在设置里关掉;说明做成 skill 按需加载。内容:
+ - 任务编号,以及「需要最新内容时运行 `orca task show 编号`」;指令里的标题和描述只是开工那一刻的快照;
+ - 由对话派出的任务,开工后用 `orca task update --title` 改一个不超过 30 字的短标题;
+ - 每一轮结束时用 `orca task progress 编号 --file 摘要文件` 写一条结果,分三段:做了什么、结论或产出、下一步或需要你什么;中途进展不用写,终端里说的话用户看不到(借鉴 Multica:每轮只发一条结果);
+ - 卡住时写明原因,然后结束这一轮;
+ - 回写失败(主机不在联系,例如直连 SSH 的桌面关了)就把摘要写到工作区里的文件,继续干活,不重试、不等待;
+ - 状态真的变化了才写;只回答问题时不改状态;交付最多到「待验收」,「完成」由人在界面上点;
+ - 发现这次不处理的问题,用 `orca task create --parent 编号` 记下(REQ-025)。
+ - **最新进展**:任务卡、任务模式的悬停卡和详情顶部显示「最新进展」,按优先级取值:
+ 1. 最近一条智能体回写的进展,写明来自哪个会话、什么时间,以及「需要你什么」;
+ 2. 没有回写时,取「轮次结束」记录:拥有会话的主机在会话状态变成「做完一轮」时,把 hook 上报的最后一句话落进任务库,每个会话只保留最近一条,卡上标注「最后一句话」。现在这句话只在主机内存里,重启、离线后就没了,所以要落库;
+ 3. 都没有时只显示会话状态,比如「工作中 · 12 分钟」;
+ 4. 父任务没有自己的进展时,取子孙里最近一条,并标明来自哪个子任务(REQ-012)。
+ - **生成摘要**(切片 3d):详情进展卡上的按钮,用户点了才生成,复用 Orca 生成 commit 信息和 PR 描述的那条管线,读这个任务下的会话记录,结果是一条来源为「Orca 生成」的进展。默认不自动生成。
+ - 只显示智能体写的和它自己的最后一句话,不从会话内容推断(原则 4)。
+- 本机场景与 SSH 场景:回写走 `orca task`,由拥有任务库的那台 Orca 执行,与 REQ-019 一致;直连 SSH 的回写经 relay 回桌面,桌面不在时失败并按上面的规则处理(REQ-018);轮次结束记录由拥有会话的主机写入自己的库。
+- 验收标准:开始做后,智能体每轮结束在任务上留下一条三段式进展,桌面和手机的任务卡都显示它;关掉回写的智能体做完一轮后,卡上显示标注为「最后一句话」的轮次结束记录,重启 Orca 后仍在;直连 SSH 的桌面关闭期间,智能体把摘要写进文件且没有重试。
+
+### REQ-023 拆解时写明每一项的去向
+
+- 目标级别:P1;当前状态:未实现(2026-10-30 提出,2026-11-01 确认,切片 3c;「等你决定」归入「任务在等你」分组,REQ-014)。
+- 描述:
+ - 子任务(以及从走查、评审里拆出来的条目)都有明确的去向:
+ - 做(默认);
+ - 不做:放进已取消,必须写一句理由;
+ - 暂缓:放进待规划,写明等什么;
+ - 等你决定。
+ - 父任务详情显示范围汇总:做 N、不做 N、暂缓 N、等你决定 N,点开看清单和理由。
+ - 「等你决定」的条目进入「需要你」,作为新的一类「等你决定」(待确认)。这一类由任务字段判定,不是智能体状态,所以不受 REQ-014「只看会话」的限制。
+- 本机场景与 SSH 场景:一致。
+- 验收标准:走查拆出 30 条后,父任务上能一眼看出每条的去向;没有去向不明的条目。
+
+### REQ-024 合入后进入「待验收」,通过与打回
+
+- 目标级别:P0;当前状态:部分实现(2026-11-05:待验收、通过打回及 PR 合入规则三态已实现,见 TC-306;真实 PR 服务联动未在本轮运行)。
+- 描述:
+ - 修订 REQ-013 的规则 2:PR 合并后任务移到哪个状态,在项目设置里三选一:「待验收」(默认)、已完成类别的第一个状态、不动(借鉴 Multica)。「待验收」是评审类别下新增的默认状态,老项目也补上。
+ - 智能体也可以把任务推到「待验收」(回写「可以验收」并改状态),但不能推到已完成(原则 5)。
+ - 待验收的任务进入「需要你」的「任务在等你」分组(REQ-014)。验收条放在概览「现在」卡的最上面(REQ-026),上面写明:
+ - 合入位置:目标分支和提交;
+ - 改动和 PR;
+ - 最新进展里智能体的自检结论(REQ-022 的任务说明要求交付那一轮写清楚验证了什么、怎么验的);没有「完成标准」(REQ-021 已取消,D-014),验收看结果;
+ - 自检的来源(哪个会话、第几次回写)可以点,打开那个会话的速览(REQ-028),验收时可以直接追问。追问不改任务状态:验收条留着,会话工作时验收条顶部显示「某智能体工作中」,它再回写时自检一行跟着更新,「通过」「打回」随时可点(2026-11-03 补);
+ - 「需要你」里的待验收条目有「速览」和「去验收」:速览打开写了最新进展的那个会话(2026-11-03 补)。
+ - 用户要去哪里看:项目可以配置一句验收说明,例如「更新内测包后在任务页查看」;
+ - 规则当前的判定,例如「PR #231 合并后按项目设置进入待验收」。
+ - 操作:
+ - 「通过」:移到已完成类别的第一个状态,时间线记「你点了通过」;
+ - 「打回」:回到进行中类别的第一个状态,必须写原因。原因记成评论;有「做完一轮」的会话时作为下一句指令发过去(多个都发,REQ-015 的回复规则);没有能回复的会话时,一键用打回原因加任务说明开一个新会话(REQ-010),不只是提示。
+ - 规则 2 只由桌面窗口读到 PR 状态时执行(REQ-013):只用手机的用户要等桌面打开,待验收卡上写明。
+ - 手机上同样可以通过或打回。
+- 本机场景与 SSH 场景:PR 状态的读取与 REQ-013 一致;主机不在联系时不触发,恢复后读到「已合并」再移到待验收,不重复触发;打回要开新会话时,执行主机不在联系就置灰并写原因。
+- 验收标准:PR 合并后,任务出现在「需要你 → 任务在等你 → 待验收」;打回后做完一轮的会话收到原因、任务回到进行中;没有可回复会话时打回开出一个新会话,第一句就是原因。
+
+### REQ-025 中途发现的问题当场记下
+
+- 目标级别:P1;当前状态:未实现(2026-10-30 提出,2026-11-01 确认,切片 3c)。
+- 描述:
+ - 执行和评审中发现、但这次不处理的问题,当场记成任务,挂在原任务下,并写明来源。来源可以是:
+ - 会话:REQ-020 的「记成任务…」;
+ - PR 评论;
+ - 智能体运行 `orca task create --parent`;
+ - 任务评论。
+ - 原任务详情列出「衍生问题」,进入待验收时一并显示,避免只留在聊天里。
+ - 「进展回写说明」(REQ-022)里同时要求智能体这样做。
+- 本机场景与 SSH 场景:一致;写入拥有原任务的那台 Orca 的库。
+- 验收标准:智能体在执行中发现一个上游缺陷,任务下多出一个子任务,来源指向那个会话。
+
+### 以任务为主的工作流(2026-11-02 按方案评审定稿)
+
+下面 REQ-026~REQ-030 来自 `interaction/任务为主的工作流产品方案.md` 和它的评审稿。用户 2026-11-02 全部采纳评审建议,只改掉一处:任务模式里仍用速览抽屉看会话,任务要一直是主体,会话是从属,任务视图不能长成工作区的样子(D-012)。
+
+### REQ-026 任务首页与任务模式
+
+- 目标级别:P0;当前状态:部分实现(2026-11-05:任务首页、侧栏任务树与概览/会话导航已实现,见 TC-301、TC-303;Q1 按本轮用户确认采用浮层与原标签)。
+- 描述:
+ - **任务首页**:桌面默认打开任务页的「本地」来源。实现上沿用上游「恢复上次视图」,只加一个设置「启动时打开任务页」,默认开,关掉回到上游行为。首页自上而下:派活输入框(REQ-029)、「需要你」(会话在等你、任务在等你两组,REQ-014)、按状态分组的任务卡(沿用列表和看板)。任务卡以标题、状态、会话状态点、最新进展(REQ-012、REQ-022)为主,工作区和主机是次要信息,主机不在联系时写「未连接」。
+ - **任务模式**:侧栏「项目」那一行加一个「按任务显示」图标按钮,放在铃铛前面;点了标题变「任务」、按钮高亮,再点切回;选择记在本功能的偏好里,不碰上游只在内存的侧栏视图状态。任务模式下「搜索、任务、自动化、手机端」入口保留,「项目」区换成任务树:
+ - 树的内容是任务页那一列原样搬过来:需要你、全部、进行中、待规划、已完成、已取消,以及「项目」下的全部项目和各个项目;每行可展开;选中某行就是筛选,主区按它显示,左右始终一致。
+ - 展开视图或项目看到任务,「需要你」下的任务带原因;展开任务看到它的会话(状态点加一句话),子任务嵌在父任务下;每深一层缩进 20px 并有竖向引导线;工作区不作为树的一层,只出现在悬停卡和详情的工作区卡里。
+ - 树的最下面是「未关联任务的会话」组:没挂任务的工作区里的会话,每行有「记成任务…」(REQ-011)。
+ - 主机未连接时任务标「未连接」,它下面的会话置灰。
+ - 原稿要求复用上游虚拟列表。2026-11-05 本轮采用技术方案的独立任务树主体,复用侧栏外壳、样式和宽度;虚拟化尚未实现,大规模任务树性能未纳入本轮验收。
+ - **任务视图**(任务模式下选中任务):任务是主体,会话从属。
+ - 2026-11-05 Q1 定稿:任务直接进入概览,会话用右侧浮层;「全屏」进入工作区已有的会话标签,不新增概览伪标签和任务专用标签栏。
+ - 点任务(树或列表)一律打开「概览」,不自动跳到会话标签;有会话在等你时,「现在」卡先显示「在等你什么」,带「速览」。
+ - 点侧栏里的会话:打开所属任务的概览,并打开这个会话的速览浮层(REQ-028);浮层里「展开」是完整会话,「全屏」才切到它的标签。
+ - **概览的内容**(2026-11-02 用户看原型后定:只回答「现在要我干什么、做到哪了、标准是什么、怎么进去」,其余减掉):
+ - 头部:标题;属性 chips 一行(状态可点改;优先级、标签、上级任务设了才显示);工作区一行(名称、主机、改动统计、PR,没有的项不显示,点开有「打开工作区」「查看更改」,没有工作区时写「还没有工作区」);一个主按钮按状态变(没有工作区 → 开始做;有工作区 → 开会话;待验收 → 验收;已完成或已取消 → 没有);「…」菜单:设为目标(聊天型置灰并写原因)、取消任务、删除、复制链接、改标题。右栏取消,创建和更新时间放到动态底部。
+ - 「现在」卡:唯一一张卡。待验收的验收条在最上面(REQ-024);在等你什么(有才显示,带速览);最新进展(有才显示;「全部进展 N」只在有记录时显示,「生成摘要」只在有会话内容时显示);每个会话一行(智能体、终端或聊天、所属子任务、状态、一句话、速览)。没有会话也没有进展时只有一行「还没有会话」。
+ - 描述、子任务:有内容才显示,为空时只有「添加」小链接。没有「完成标准」块(D-014)。
+ - 动态:按 REQ-005 的 2026-11-02 规则,系统事件折叠。
+ - 界面里不出现需求编号和机制说明。
+ - 关闭会话标签就是关闭会话,和工作区模式一样;两种模式下是同一个标签,一边关了另一边也没了。
+ - 会话所在主机未连接时标签置灰,悬停写明原因。
+ - 实现:概览复用任务详情,浮层复用终端 portal 与聊天组件,窄宽切换不重新挂载;全屏采用原工作区标签。任务没有工作区时仍可查看概览,头部提供开始做。
+ - 任务模式下任务首页没有二级导航列,派活输入框、列表、看板占满宽度。
+ - 本地任务来源在设置里隐藏时,任务模式按钮、首页设置、派活输入框都不出现(REQ-008);「按任务分组」的去留见 REQ-016。
+- 本机场景与 SSH 场景:树和任务视图读各台主机自己的库(REQ-018);SSH 工作区的会话标签和速览与本机一样;主机不在联系时置灰并写明原因,不重试,恢复联系后只重读一次。
+- 验收标准:切到任务模式后点「需要你」,主区只剩等你的任务;展开 ORC-41 看到它的会话,点会话打开概览加速览浮层,点「全屏」切到标签;关闭标签后工作区模式里它也没了;切回工作区模式与改动前一致。
+
+### REQ-027 新建任务一次流程
+
+- 目标级别:P0;当前状态:部分实现(2026-11-05:四块概览、属性收纳、最新进展与折叠动态已实现,见 TC-301;生成摘要等 3d 能力暂缓)。
+- 描述:一次流程、两屏,不重做上游的新建工作区弹窗。
+ - **第一屏**:标题(必填)、描述、状态等属性(没有完成标准,D-014),加「在哪做」三选一:
+ - 已有工作区:当场选(可搜索,列出所有在联系的主机),下面出现「开工」段:智能体(默认你的默认智能体,可以选「先不开」)、第一句指令(默认「按任务说明开始」)、发送方式(默认直接发送);主按钮「创建并开工」,建好任务、关联工作区、开会话,停在任务视图;
+ - 新建工作区:主按钮「创建,下一步新建工作区」;
+ - 稍后再定:主按钮「创建」。
+ - **第二屏**(选新建工作区时):Orca 现有的新建工作区弹窗,「创建自」预选这个任务(REQ-009),智能体和第一句指令在那里填;发送方式按设置「从任务发起的会话」执行,弹窗里不加选项。创建完成后关联并停在任务视图;创建失败或取消时任务保留、不关联。
+ - 从「记成任务…」进入时,标题和工作区预填(REQ-011)。
+- 本机场景与 SSH 场景:第二屏的运行位置可以选 SSH 主机,与 REQ-009 一致;主机未连接时「已有工作区」里它的工作区置灰并写原因。
+- 验收标准:建一个任务并选新建工作区,两屏走完后任务视图里有工作区卡和一个会话;选稍后再定时任务视图只有「概览」和「开始做」。
+
+### REQ-028 会话速览
+
+- 目标级别:P0;当前状态:部分实现(2026-11-05:桌面可输入浮层、全屏原标签、手机聊天抽屉已实现,见 TC-304、TC-305;手机终端按 Q2 进入现有会话页)。
+- 描述:速览就是对话的小边框模式(2026-11-02 用户定,D-014):同一个会话挂在右侧浮层里,窄一点,什么都能做。首页列表、任务概览、任务模式侧栏都从这里看会话;任务模式里点会话先开速览,不直接切标签;只有明确要全屏才变成独立标签(D-012、D-013)。
+ - 桌面是一个浮层两档宽度,加全屏;同一个终端或聊天实例只换宽度,不重新挂载:
+ - 速览:点会话、点「速览」、侧栏树点会话进入;窄,约 420px;主区往左让开、在剩余宽度里重新排版,任务内容(含验收条和「通过」「打回」)不被遮住,验收时可以一边看会话一边点通过(2026-11-03 补);真实终端或聊天挂进来,**完整可操作**:终端有输入行(打字、方向键选菜单、批准),聊天有完整 composer(选项按钮、输入);「停止」;头部有「加宽」「全屏」「关闭」。
+ - 加宽:速览里点「加宽」;宽,约主区三分之二,盖在任务上;同一个实例,只是更宽;头部「收窄」「全屏」「关闭」。
+ - 全屏:任一级点「全屏」;任务模式下切到任务视图里这个会话的标签,工作区模式下跳到工作区的那个标签;标签顶部一条「收回侧边」回到浮层。
+ - 浮层里没有单独的回复框,也不受 REQ-015「只有做完一轮能回复」的限制:那条说的是一次性发送一句话的通道,浮层里挂的是会话本身,输入走会话自己的通道。首页「需要你」行的「回复」就是打开速览并把光标放进输入;待验收行也有「速览」(REQ-024)。
+ - 任务有不止一个会话时(含子任务的),浮层头部会话名旁有下拉,可以在这些会话之间切换;只有一个会话时不显示(2026-11-03 补)。
+ - 聊天型会话复用结构化会话视图;终端型会话把真实终端挂进浮层(Activity 页 portal 终端的做法),不是文本快照,颜色和滚动都在。显示状态与「在等你什么」。窄态下终端按浮层宽度重排,和拖分屏一样。
+ - 手机是底部抽屉,里面挂真实的会话视图,半屏就能回答;上拉到全屏就是会话页。
+ - 读取规则:浮层打开且主机在联系时才读,有推送跟随推送;主机不在联系时显示上次读到的内容并写「未连接」,不重试。
+- 本机场景与 SSH 场景:一致;SSH 主机断开时浮层保留旧内容并写明,不发读请求,恢复联系后只重读一次;输入走会话原有的通道,主机不在联系时和标签页里一样不可输入。
+- 验收标准:从首页「需要你」速览 ORC-38,在窄态里直接选 Codex 的选项并送达;速览工作中的终端会话能打字;全屏后标签顶部能收回侧边;SSH 断开时浮层写「未连接」且没有读请求。
+
+### REQ-029 派活输入框、@ 提及与直接派活
+
+- 目标级别:P0;当前状态:部分实现(2026-11-05:明确目标直接派活与撤销已实现,见 TC-307、TC-308;只问与调度暂缓)。
+- 描述:
+ - **输入框**:在任务首页顶部(手机在本地任务页顶部,REQ-017),占位「要做什么?@ 一个工作区或仓库,说一句话」,回车派活;右侧「展开对话」进入通用对话(REQ-030);「派活 | 只问」两段开关,默认派活。Cmd+J 加「派活…」动作(REQ-007)。
+ - **@ 提及**:候选按类型分组:工作区、仓库、任务、主机;来自所有在联系的主机,不在联系的置灰并写原因;选中成 chip,chip 存确定的 id,不是文字。含义:
+ - @工作区:在这里派活;
+ - @仓库:为这个任务新建工作区再派活(名称来自编号和标题,复用「开始做」);文件夹项目只能选已有的文件夹工作区;
+ - @任务:在它下面建子任务,会话开在它的工作区;它没有工作区时先走开始做;
+ - @主机:不是派活目标,选中后候选只剩这台主机的仓库和工作区,输入框旁出现可去掉的筛选标签。
+ - **只问**:不建任务,把 chip 里的工作区作为上下文交给调度会话(REQ-030)只读回答。
+ - **直接派活**(提及了明确的工作区或仓库,Orca 直接执行,不经过额外的模型):
+ - 项目归属:工作区所在仓库只挂在一个项目下就用它;挂在多个项目下用最近派过活的那个,卡上可改;没有项目时卡片先出「为仓库 X 建一个项目?」,确认后建项目再派(与 REQ-001 的推荐一致)。
+ - 工作区不属于任务:建任务(标题取正文第一句、截 60 字,原句进描述;Orca 不另起模型调用起名,由智能体按任务说明改短标题),关联工作区,用默认智能体开会话,第一句指令是正文加任务说明(REQ-022)。
+ - 工作区属于任务 T:T 下有「做完一轮」的会话时,把这句话发给它当下一句指令,不建任务,T 的时间线记「由对话追加指令」,卡上给「改为建子任务另开会话」;没有空闲会话(都在工作中、等你批准、等你回答、被卡住,或没有会话)时在 T 下建子任务、另开会话,卡上写明同一工作区里还有会话在工作。
+ - 多个工作区:建父任务,每个工作区一个子任务,各开会话;工作区要在同一台任务库上,否则走调度会话。
+ - 已派活卡:任务、会话、速览、10 秒撤销。撤销停会话、删任务;新建的工作区保留并提示可删;已改的文件不回滚,卡上写明;编号不复用(REQ-002);要在拥有库的那台 Orca 删任务、在执行主机停会话,主机不在联系时写明「没有撤销」,不排队。
+ - 主机不在联系时派活按钮置灰并写明原因;不排队、不重试。
+- 本机场景与 SSH 场景:目标主机由 chip 决定,任务写进拥有工作区的那台 Orca 的库(直连 SSH 是桌面的库,REQ-018),会话在那台主机上启动;SSH 主机断开时它的候选置灰。
+- 验收标准:「@orca/docs-typo 把 README 里过期的链接修一下」建出任务并开会话,10 秒内撤销后任务消失、会话停止;「@orca/perf-startup 顺便看看首屏渲染」发给了做完一轮的 Claude,没有新任务;没有项目的仓库派活时先建项目;「只问」不留任务。
+
+### REQ-030 调度会话、提案与对话历史
+
+- 目标级别:P1;当前状态:部分实现(2026-11-02 提出,切片 3c)。2026-11-05 已做:在「需要你」视图顶部打开或显示总管(Claude 以终端启动),标签关掉后重开沿用原对话;`orca task propose --body-file 提案.json` 提交六类提案项;提案卡逐项保留或去掉、确认或不要,确认后由桌面执行并逐项回报结果;手机可列出和决定提案,执行在桌面。同日第二轮:总管默认以聊天视图打开(叠在同一个终端会话上,可切回终端);任务页导航最上方加「总管」台账页,列出等你确认的提案、它盯着的任务、它派出的任务和还没处理的动态;提案执行前由桌面认领,不会执行两次。同日第三轮(跨任务库):派到 SSH 主机或配对服务器工作区的任务建在工作区所在主机的库里,按编号找任务、追加和停止会话都能跨库;主机不在联系时追加和停止回报「无法确认」。SSH 场景 2026-11-05 真机验证通过(见测试记录的 SSH 一节)。未做:对话历史、时间线「由总管提案、你确认」、逐项修改、直接执行。
+- 描述:
+ - 没有提及在哪做、或要拆解、比较、查找的请求,交给调度会话:桌面 Orca 浮动伪工作区里的一个普通智能体会话,用默认智能体,附「调度说明」skill:理解请求、提方案、用 orca CLI 只读查询,不自己干活。
+ - 要新建的东西(任务、工作区、会话)先用 `orca dispatch propose --file 提案文件` 提交结构化提案,对话里渲染成提案卡(每项可改、可去掉);用户确认后由 Orca 直接建任务、关联、开会话,不再经过调度会话,也不多一轮模型调用;设置里可改成「直接执行」。不让智能体输出约定格式的文字再解析。
+ - 调度会话只在桌面有;手机经配对桌面;局域网网页客户端没有(上游不允许在浮动工作区里开会话),只能直接派活。
+ - 对话历史按天分组,每段对话能看到它派出的任务卡;由对话派出的任务,时间线第一条「由对话派出」能跳回那段对话;只聊天没派活的对话不留任务。上游没有会话级元数据,「这段对话派出了哪些任务」存在本功能的库里。派出到别的库(orcad 主机、配对服务器)时,任务在那台库里,对话在桌面,对方库里只带对话 id。
+- 2026-11-06 用户确认的并发收尾规则(D-018):提案认领绑定服务端确认的执行连接;运行窗口仍在时,其他窗口不能人工收尾。每项执行结果先持久化,再进行下一项;执行连接退出或退休后才可收尾,保留已知结果,不重新认领或重跑。未确认的在途副作用不能算作未执行;失联主机不能算作执行进程已退出。旧主机不能报告认领状态时不开放人工收尾。
+- 本机场景与 SSH 场景:调度会话跑在桌面;派活到 SSH 主机或配对服务器时用 CLI 的 `--host`、`--environment` 指定目标,任务写进拥有工作区的那台 Orca 的库。
+- 验收标准:「看看 orca 里哪些工作区的 PR 还没合,各开一个任务跟进」得到提案卡,去掉一项后确认,剩下的各建一个任务并开会话;对话历史里能回到这段对话;「只问」得到回答且没有任务。
+- 2026-11-05 增补:调度会话即总管(D-015)。
+ - **常驻、唯一**:同一 profile 只有一个调度会话(总管),对话历史是它的各段对话。会话坏了先按会话号续接,续接不了再新开。
+ - **启动方式**:Claude 以终端启动,对话页用聊天视图显示同一个终端会话;不走经 Claude Agent SDK 的结构化聊天,否则用不了 claude.ai 订阅。Codex 可以用结构化聊天。
+ - **对话标识**:按总管的面板映射,不依赖只有结构化会话才有的会话 id。
+ - **提案覆盖总管的所有写入**:
+ - 建任务并开会话;
+ - 建子任务并在同一工作区开会话;
+ - 给做完一轮的会话追加一句;
+ - 停会话(永远要确认);
+ - 把已有工作区挂到任务;
+ - 把没挂任务的会话记成任务。
+
+ 逐项可改、可去掉,确认后由 Orca 执行,时间线记「由总管提案、你确认」。
+
+ - **只问**遇到要读代码的问题,总管提一份只读调查任务,而不是自己去读:它在桌面的伪工作区里,读不到 SSH 主机上的仓库,也不在工作区里动手。
+ - 验收补充:
+ - Claude 用户的总管会话是终端,订阅可用;
+ - 「把 ORC-40 换成 codex 重来」得到一份含「停会话」与「建子任务」两项的提案,确认后照做;
+ - 旧版任务库收到它不认识的提案项时,整份拒绝。
+
+### REQ-031 总管的感知与唤醒
+
+- 目标级别:P1;当前状态:部分实现(2026-11-05 提出,切片 3c)。2026-11-05 已做:盯着的任务(提案执行出的任务自动加入,`orca task watch` / `unwatch`,确认已读时移出完成或取消的任务);`orca task inbox [--ack 游标]` 与 `orca task needs-you`;总管做完一轮、收件箱有新内容时写一行指针,每个空闲期最多一次,没看过的旧收件箱不重复写;光标在总管终端里时不写,离开后补发;「需要你」视图里有唤醒开关,默认开。同日第三轮(跨任务库):总管盯着 SSH 主机、配对服务器上的任务,桌面按主机联系状态把那些任务的新动态转进收件箱,主机失去或恢复联系时各记一条;台账页同样列出这些任务。SSH 场景 2026-11-05 真机验证通过。未做:`task list --all-hosts`、`task show --events N`;配对服务器只有单测,未真机验证。
+- 描述:
+ - **盯着的任务**:总管提案建出的任务确认执行后自动加入;用户说「帮我盯着 ORC-39」时,总管把它加入;任务完成或取消后自动移出。
+ - **收件箱**:盯着的任务上新发生的、确定的事。不从会话内容推断(原则 4)。包括:
+ - 智能体回写了进展;
+ - 会话做完一轮;
+ - 状态变化(含进入待验收);
+ - 会话进入或离开「等你」;
+ - 会话结束;
+ - 提案被确认或去掉。
+ - **唤醒**:盯着的任务有新动态、且总管是「做完一轮」时,Orca 往总管会话里写一行指针(「有 N 条任务动态……运行 `orca task inbox`」),每个空闲期最多一次。总管在工作、等批准、等回答,或你正在它的输入框里打字时不写。设置里可以关,默认开。
+ - **被唤醒后**:总管读收件箱,在对话里只说需要你知道或决定的,必要时附一份提案(例如「让 ORC-39 继续写复现测试」);它这一轮说完时,现有的「做完一轮」通知照常提醒你。
+ - **不自己开工**:唤醒只带来汇报和提案,不会让总管自行开会话或追加指令(原则 2)。
+ - **给总管的接口**:
+ - `orca task needs-you`:会话在等你与任务在等你,合在一份;
+ - `orca task inbox`:读收件箱并确认已读;
+ - `orca task watch` / `unwatch`:盯或不盯一个任务;
+ - `orca task list --all-hosts`:合并所有在联系的任务库;
+ - `orca task show --events N`:动态限量。
+
+ 都在 `orca task` 命令组里,输出 JSON。
+- 本机场景与 SSH 场景:
+ - 盯着的任务与已读游标存在任务所在的库里;orcad 主机和配对服务器的库只带桌面的对话 id。
+ - 总管读这些库只在主机在联系时;不在联系的库标「无法确认」,恢复联系后只重读一次,不定时重试。
+ - 唤醒由桌面窗口执行。
+- 验收标准:
+ - 总管派出的任务回写进展后,总管在空闲时被叫醒一次,并在对话里说出这条进展;
+ - 总管正在工作时不被叫醒,它下一次空闲时一并收到;
+ - 一天后在新的总管对话里问「ORC-39 做到哪了」,回答与任务记录一致;
+ - orcad 主机断开期间,`task list --all-hosts` 把它标为「无法确认」,且没有对它发起重试。
+
## 6. 边界情况
-| 情况 | 处理 |
-| ------------------------------ | -------------------------------------------------------------------- |
-| 关联的工作区被删除 | 任务上显示「工作区已删除」,可以重新开始做 |
-| 会话关闭了 | 会话行显示「已结束」,可以再开一个 |
-| Orca 重启后找不到记录的会话 | 显示「找不到这个会话」,提供「打开工作区」 |
-| 删除任务 | 只删任务记录,工作区和会话都不动 |
-| 一个工作区想关联第二个顶层任务 | 不允许,提示它已属于哪个任务 |
-| 任务移到别的项目 | 已关联工作区的任务不能移,先取消工作区关联再移 |
-| 项目对应的仓库从 Orca 里移除 | 项目和任务保留,「开始做」不可用并提示仓库已移除 |
-| 执行主机断开 | 任务照常管理;会话和 PR 显示「无法确认」;「开始做」「开会话」不可用 |
+| 情况 | 处理 |
+| ---------------------------- | --------------------------------------------------------------------------------------------- |
+| 关联的工作区被删除 | 任务上显示「工作区已删除」,可以重新开始做 |
+| 会话关闭了 | 会话行显示「已结束」,可以再开一个 |
+| Orca 重启后找不到记录的会话 | 显示「找不到这个会话」,提供「打开工作区」 |
+| 删除任务 | 只删任务记录,工作区和会话都不动 |
+| 一个工作区想关联第二个任务 | 不允许,提示它已属于哪个任务(写任务标题) |
+| 任务移到别的项目 | 整棵子树一起移,工作区关联保留,旧编号仍能打开(REQ-004) |
+| 项目对应的仓库从 Orca 里移除 | 项目和任务保留,「开始做」不可用并提示仓库已移除 |
+| 执行主机断开 | 任务照常管理;会话和 PR 显示「无法确认」;「开始做」「开会话」不可用 |
+| 派活到正在工作的工作区 | 建子任务另开会话,卡上写明同一工作区里还有会话在工作;有做完一轮的会话时改为发给它(REQ-029) |
+| 派活的仓库没有项目 | 卡片先出「为仓库 X 建一个项目?」,确认后再派(REQ-029) |
+| 撤销派活 | 停会话、删任务,工作区保留,已改文件不回滚,编号不复用;主机不在联系时写明没有撤销 |
+| 取消或删除有在跑会话的任务 | 确认框里给「同时停止 N 个会话」勾选,默认不勾(REQ-004) |
+| 直连 SSH 的桌面关闭 | 智能体回写失败,写文件继续、不重试;手机看不到这台桌面库里的任务,提示写明(REQ-018) |
## 7. 交付切片
-| 切片 | 内容(桌面和手机一起交付) | 覆盖的需求 |
-| -------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
-| 1 任务库 | 项目、编号、状态、任务、子任务、列表、看板、详情、评论与动态、键盘、Cmd+J、设置、任务页入口、手机对应页面 | REQ-001~REQ-008、REQ-017 的任务部分、REQ-018 |
-| 2 和工作区、会话结合 | 开始做、开会话、关联、进展显示、自动规则、需要你、侧栏按任务分组、卡片编号、会话 chip、手机对应功能 | REQ-009~REQ-016、REQ-017 的其余部分 |
+| 切片 | 内容(桌面和手机一起交付) | 覆盖的需求 |
+| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
+| 1 任务库 | 项目、编号、状态、任务、子任务、列表、看板、详情、评论与动态、键盘、Cmd+J、设置、任务页入口、手机对应页面 | REQ-001~REQ-008、REQ-017 的任务部分、REQ-018 |
+| 2 和工作区、会话结合 | 开始做、开会话、关联、进展显示、自动规则、需要你、侧栏按任务分组、卡片编号、会话 chip、手机对应功能 | REQ-009~REQ-016、REQ-017 的其余部分 |
+| 3a 任务为主的骨架 | 任务首页与任务模式、任务视图;新建任务两屏;任务说明与 `orca task progress`(库保持 v5,复用评论与事件表、能力位 `local-tasks.v2`);轮次结束记录;最新进展;会话速览 | REQ-010、REQ-012、REQ-019、REQ-022、REQ-026~REQ-028 |
+| 3b 直接派活 | 派活输入框、@ 提及、只问;直接派活(含空闲会话、项目归属、多工作区);撤销;`task create`、`task link`、`task update --title`;Cmd+J 动作 | REQ-007、REQ-029 |
+| 3c 调度与验收 | 调度会话、提案、对话历史;待验收、通过与打回;规则 2 三选一;范围去向;衍生问题 | REQ-023~REQ-025、REQ-030 |
+| 3d 后续 | 生成摘要;截图附件与随手记;「设为目标」;定时派活 | REQ-020、REQ-022 的生成摘要 |
+
+切片 3a 开工前验证两项:旧版本桌面和手机收到不认识的事件类型要忽略而不是报错;会话浮层窄宽切换时终端不发生重新挂载,全屏使用既有标签。每个切片都要覆盖本机、SSH、配对服务器、手机和文件夹工作区。
## 8. 待确认问题
@@ -397,9 +778,24 @@ flowchart TB
1. 文件夹项目的「开始做」:只关联已有的文件夹工作区,不新建(已写进 REQ-009)。文件夹没有分支和 PR;替用户新建目录等于做「任务目录」,本版不做。
2. 第一次进入任务页:只推荐项目,用户确认后才建(已写进 REQ-001)。一个仓库只能对应一个项目,自动建会把不想管的仓库也塞进来。
3. 「做完一轮,你还没看」的未读标记:代码调研确认桌面上能按单个会话读取;SSH 和配对服务器上的会话是否都有,列为开工前验证(技术方案 §13)。读不到的主机上这条规则不生效,不去猜。
-4. 桌面 Orca 关闭时手机是否显示缓存:首次加载前不显示;已加载后允许遮盖旧内容并禁写,但不卸载页面、导航或草稿,只提示无法连接(已写进 REQ-017)。缓存里的「需要你」一定是过期的。
+4. 桌面 Orca 关闭时手机是否显示缓存:首次加载前不显示;已加载后允许遮盖旧内容并禁写,但不卸载页面、导航或草稿,只提示未连接(已写进 REQ-017)。缓存里的「需要你」一定是过期的。
5. 删除带子任务的任务:子任务一起删除,确认框写明数量(已写进 REQ-004)。子任务没有自己的工作区,提升为顶层任务后会出现「顶层任务没有工作区、会话却在别人的工作区里」的情况。
+### 2026-11-02 方案评审定稿
+
+评审稿 `interaction/任务为主的工作流方案评审.md` 用户全部采纳,一处改掉:任务模式里仍用速览抽屉看会话,不改成「点会话就是标签」(REQ-026、REQ-028)。同时定下两条:从任务发起的会话默认直接发送(REQ-009、REQ-010);「按任务分组」在任务模式上线后评估下线(REQ-016)。
+
+### 2026-10-30 「从提出到验收」待确认问题
+
+2026-11-01 用户回复「都按建议」,以下 6 条全部按默认做法定稿(D-011)。
+
+1. **修订 D-1 与原则 2 的边界**:接不接受「待你验收」「完成标准勾选与证据」这类帮用户对账的能力?默认做法:接受,但仍然不自动判断,结论都由用户点。
+2. **规则 2 的默认值**:PR 合入后,新老项目都改成进「待验收」,还是只有新项目这样、老项目保持「合并即完成」?默认做法:都改,可以在项目设置里改回。
+3. **「需要你」新增两类**:「等你决定」「等你验收」由任务字段判定,不是智能体状态。默认做法:加,但在视图里和会话类的条目分组显示。
+4. **与 Goal 目标模式打通的深度**:只把完成标准导出成验收文档,还是让 Goal 守卫的结论回写成证据?默认做法:两者都做,回写只作为证据,不改任务状态。
+5. **附件**:上限多少,手机是否离线缓存?默认做法:单张 10 MiB、每个任务 50 MiB,手机不缓存。
+6. **「可用」这一步**(合入以后,用户什么时候能在 App 里用上):通用的 Orca 不知道用户项目怎么发布。默认做法:只做 REQ-024 里的「验收说明」文本;对接各自的发布流水线不做。
+
### 2026-10-26 独立审查后的修复确认
用户要求修复独立审查记录的问题,并要求先提交旧实现。旧实现已保存为 `6971551474b3`。本轮明确旧编号在原项目删除后的命名空间保护、手机多行子任务的字段与部分保存语义、手机删除项目及延迟重投去重;未推进切片 2。已有会话的子任务更换父任务后如何处置会话与工作区关联,仍是切片 2 的待确认问题。
@@ -409,3 +805,11 @@ flowchart TB
- 用户确认 D-006:回执绑定方法及去掉 clientOperationId 后的稳定参数指纹,24 小时窗口内同号同请求回放;不同方法或参数、损坏回执明确拒绝,绝不删除回执重做。每次写入顺带按时间清理。
- 已成功加载的数据在后续重读失败时保留,桌面与手机只显示手动重试提示;动态失败只重读对应任务动态,变更流收到 end 才提示重新连接。手机断线或 checking 不卸载库页面,写操作禁用。
- 上述契约同时适用于本地与 SSH 工作区;数据库和服务仍归桌面宿主,SSH 失联不改变任务数据归属,不触发本功能自行重连。
+
+### 2026-11-05 本轮实现边界
+
+用户明确暂缓管理任务的超级智能体,先开发任务界面和功能(Journal D-016)。本轮纳入任务首页/树/概览、会话查看、回写、明确目标直接派活,以及不依赖总管的待验收操作;总管、只问、提案与自动唤醒暂缓。Q1/Q2 已由用户在本轮确认按技术方案实现;具体交互与原型差异见技术方案 §2.3。对应长期用例为 [TC-301~TC-309](../tests/cases/任务为主的工作流测试.md)。
+
+### 2026-11-06 全量统一与重新验证
+
+用户明确要求合并全部任务分支并包括 #81 已有总管实现(D-017)。本轮合并后同时保留普通任务 UI/直接派活/验收和 REQ-030、REQ-031 总管;此前 2026-11-05 UI 分支的暂缓范围为历史记录,已由本轮决定取代。完整库版本采用 v7,总管提案必须人工确认,跨库只跟随共享联系状态;保留完成标准移除、旧内容折描述以及已确认的 Q1/Q2 导航。所有结论按本轮合并后候选重新检查,不把旧 v5 结果计为本轮验证。
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/solutions/\344\273\273\345\212\241\344\270\272\344\270\273\347\232\204\345\267\245\344\275\234\346\265\201\346\212\200\346\234\257\346\226\271\346\241\210.md" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/solutions/\344\273\273\345\212\241\344\270\272\344\270\273\347\232\204\345\267\245\344\275\234\346\265\201\346\212\200\346\234\257\346\226\271\346\241\210.md"
new file mode 100644
index 000000000000..76f83ba922c5
--- /dev/null
+++ "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/solutions/\344\273\273\345\212\241\344\270\272\344\270\273\347\232\204\345\267\245\344\275\234\346\265\201\346\212\200\346\234\257\346\226\271\346\241\210.md"
@@ -0,0 +1,989 @@
+---
+title: 任务为主的工作流技术方案
+document_type: solution
+status: draft
+created: 2026-11-03
+updated: 2026-11-05
+---
+
+# 任务为主的工作流技术方案
+
+- 需求:[本地任务管理](../requirements/本地任务管理.md) REQ-022、REQ-024、REQ-026~REQ-030,以及它们修订的 REQ-004、REQ-005、REQ-007、REQ-009~REQ-019、REQ-021(已取消)。
+- 产品方案:[任务为主的工作流产品方案](../interaction/任务为主的工作流产品方案.md);评审:[方案评审](../interaction/任务为主的工作流方案评审.md);决策 D-011~D-014 见 [journal](../journal.md)。
+- 交互原型:[interaction/prototype-task-first](../interaction/prototype-task-first/README.md)(浏览器打开其中的 `index.html`)。
+- 前一版技术方案:[本地任务管理技术方案](本地任务管理技术方案.md)(切片 1、2,含 §17 主机资源化)。本文只写增量,那篇里已经定下的数据层、RPC、推送、能力协商、标识规则直接沿用,不重写。
+- 代码基线:`feat/local-tasks-walkthrough-followups` @ `7319adf1d2`,它的上游基线是 `2eae220b2e`(2026-10-21)。文中 `文件:行号` 都按这个提交。
+- 上游镜像 `origin/main` @ `35accd210b`(2026-11-03),比基线多 3302 个提交。文中「上游近三月改动 N 次」是对单个文件执行 `git log --since=2026-08-01 --oneline origin/main -- 文件` 的条数,用来估冲突风险。
+- 路径缩写:`R/` = `src/renderer/src/`,`S/` = `src/shared/`,`M/` = `src/main/`,`CLI/` = `src/cli/local-tasks/`,`MO/` = `mobile/src/local-tasks/`。
+
+## 0. 摘要
+
+产品方案要把 Orca 的工作流从「先进工作区开会话」改成「先有任务,会话挂在任务上」:任务首页和侧栏任务模式、一句话派活、智能体回写进展、会话浮层、待验收。用户在评审中定了两条硬要求:**尽量复用已有能力,尽量少碰上游文件,必须新增的才新增**。
+
+本方案按这两条做了三路代码调研,结论是:大部分能力在上游或 fork 里已经有现成实现,缺的多半只是「在现有函数上加一点」。
+
+- **任务库和 CLI**:3a、3b 不升库版本,不加 RPC 方法。回写进展沿用现有的评论接口加一个标记;轮次结束记录沿用事件表加一种类型;待验收沿用现有的「评审」类别。
+- **派活**:执行统一走手机和 CLI 已经在用的 `agent.launch`。
+- **会话浮层**:终端借用 Activity 页现成的挂载方式,聊天直接挂现有的聊天视图。
+
+全部四个切片只新开 5 处上游接缝,3a 占 4 处、3d 占 1 处。
+
+有两处偏离产品方案,需要用户拍板(§1、§2.3):
+
+- 任务视图不做「概览 + 会话标签」那排标签栏。它要改上游标签栏 5 个改动频繁的文件,而会话浮层已经覆盖了它的用途。
+- 手机上终端会话不能在底部抽屉里直接回答,只有聊天会话可以。
+
+还有一个前置条件:本分支落后上游 3302 个提交。3a 开工前要先跑 `pnpm sync:upstream`,再按同步后的代码核对本文列出的接缝行。
+
+读者重点看 §1 的结论表、§2.3 的待确认问题、§3.2 的复用评估、§5.4 的改动总览,以及 §6.3 的开工前验证。
+
+## 1. 状态与结论
+
+| 项 | 结论 |
+| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 当前状态 | 方案草稿,待用户确认 §2.3 的 5 个问题 |
+| 需求目标 | 切片 3a 任务为主的骨架、3b 直接派活、3c 调度与验收、3d 后续(需求 §7) |
+| 推荐方案 | 复用优先:库版本 3a/3b 不升;新增 RPC 方法只在 3c(提案);上游接缝 3a 新开 4 处、3d 新开 1 处 |
+| 新开的上游接缝 | `R/app-shell/AppWorkspaceShell.tsx`(浮层槽位,11 次)、`R/components/use-terminal-workspace-store-bindings.ts`(终端挂载开关,3 次)、`R/components/sidebar/index.tsx`(任务模式主体,15 次)、`R/components/sidebar/SidebarHeader.tsx`(模式按钮,9 次);3d 另加 `M/text-generation/source-control-text-generation-types.ts`(10 次) |
+| 明确不碰的上游 | 标签栏(`tab-bar-surface.tsx` 20 次等 5 个文件)、聊天消息渲染(`NativeChatMarkdown.tsx` 15 次)、聊天补全菜单(`NativeChatAutocompleteMenus.tsx` 22 次)、建工作区流程(`worktree-creation-flow.ts` 15 次)、右侧栏 |
+| 关键风险 | ① 落后上游 3302 个提交,接缝行号会漂移;② SSH 会话里的 CLI 可能连不到桌面库(§6.3 验证 1);③ 终端挂进浮层的实测(§6.3 验证 3) |
+
+## 2. 需求调研
+
+### 2.1 目标与范围
+
+| 切片 | 内容 | 需求 |
+| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
+| 3a 骨架 | 侧栏任务模式与任务树;任务首页与概览(D-013 的四块);会话浮层(D-013、D-014);任务说明;回写进展与轮次结束记录;最新进展;CLI 扩展;去掉完成标准(D-014);设置;手机首页卡、详情、速览抽屉 | REQ-004、005、010、012、019、021、022、026~028 |
+| 3b 直接派活 | 派活输入框与 @ 候选;直接派活(已有工作区、新建工作区、发给空闲会话、多工作区、项目归属);10 秒撤销;Cmd+J「派活…」;手机派活输入框 | REQ-007、029 |
+| 3c 调度与验收 | 调度会话与对话历史;提案(`orca task propose`);「只问」;规则 2 三选一;「任务在等你」;验收条、通过与打回;范围去向;衍生问题 | REQ-013、014、023~025、030 |
+| 3d 后续 | 生成摘要;截图附件与随手记;「设为目标」;定时派活 | REQ-020、022 的生成摘要 |
+
+范围外:一个任务多个工作区、编排、Automations 改造、上游标签栏与聊天渲染的任何改动。
+
+### 2.2 来源
+
+- 需求、产品方案、评审、原型(见文首)。
+- 三路代码调研(2026-11-03,只读):任务库与 RPC 与 CLI、桌面侧栏与标签与浮层、派活与调度与手机。结论已并入 §3,下文引用的 `文件:行号` 都来自这三份调研,关键几处已二次核对。
+- Multica 调研:本机 `.docs/本地任务管理调研/2026-10-30-multica/README.md`(回写约定、CLI 报错写给智能体看)。
+
+### 2.3 未决问题
+
+| # | 问题 | 建议 | 状态 | 负责人 | 下一步 |
+| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | ------ | -------------------------- |
+| Q1 | **任务视图要不要「概览 + 会话」标签栏**(REQ-026)。产品方案里,任务模式下点任务,主区顶部是一排标签:「概览」加上这个任务的各个会话。要实现它,得改上游标签栏 5 个文件,上游近三月合计改了 43 次。D-013 之后,看会话、操作会话都在浮层里完成,这排标签只剩「全屏」一个用途 | 不做。点任务进概览;会话都开在浮层里;「全屏」直接切到工作区原有的标签;在会话头部现有的任务 chip 里加「回到任务」(§5.6 M8、§3.4 对比) | 已确认(2026-11-05) | 用户 | 本轮按此实现,原型保留历史 |
+| Q2 | **手机的终端会话能不能在抽屉里直接回答**(REQ-028)。手机会话页整套依赖路由参数,终端视图带 WebView 和键盘抬升,嵌不进抽屉。聊天视图可以嵌 | 聊天会话在半屏抽屉里回答;终端会话点开直接进会话页 | 已确认(2026-11-05) | 用户 | 本轮按此实现,原型保留历史 |
+| Q3 | **「待验收」状态怎么来**(REQ-024 写的是「新增默认状态,老项目也补上」) | 不新增状态,评审类别的第一个状态就是待验收;新项目的默认状态名从「评审中」改成「待验收」;老项目的状态名是用户数据,不改 | 待用户定 | 用户 | 定了再改 REQ-024 |
+| Q4 | **待验收条目要不要「稍后提醒」**。现有的「看过、稍后提醒」只能挂在会话上(表 `local_task_attention_state` 按会话外键),挂在任务上要改表 | 3c 不做;待验收条目在通过或打回后自然消失 | 待用户定 | 用户 | 定了再改原型 d1 |
+| Q5 | **SSH 会话里能不能回写**。旧 relay 时代,SSH 主机上的终端里调不了 `orca task`(`M/ssh/legacy-relay/legacy-relay-cli-server.ts:39-59`)。orcad 时代,那台主机终端里的 CLI 很可能连的是 orcad 自己的库,而不是存着这个任务的桌面库 | 先做 §6.3 验证 1。要是连不到,SSH 会话只靠轮次结束记录(最后一句话)显示进展,任务说明要求每轮最后一句写成三段式结论 | 待验证 | 实现者 | 开工前验证 1 |
+
+### 2.4 已确认结论
+
+- D-012~D-014 的全部产品决定(任务模式里用速览浮层;概览只剩四块;速览就是对话的小边框模式;不要完成标准)。
+- 从任务发起的会话,默认直接发送(D-012),默认值在设置里。
+- 调研中按「复用优先、少碰上游」自行定下的实现决定(不改变产品行为):
+ - CLI 正文参数用上游惯例 `--body` / `--body-file`(`src/cli/command-body-input.ts:23-49`),不另造 `--file`。
+ - 提案命令放进现有的 `task` 命令组,叫 `orca task propose`,不新开顶层 `dispatch` 命令:上游已有 `orchestration dispatch`,容易混淆;而且放进 `task` 组,`M/startup/cli-command-names.ts` 不用改。
+ - 「调度说明」不做按会话注入的 skill:上游的 skill 注入对所有结构化聊天全局生效。改用 fork 命令 `orca task guide`,任务说明里让智能体运行它。
+
+## 3. 技术调研
+
+### 3.1 现状摘要
+
+前一版方案和 §17 已经说明,这里只列和本方案直接相关的事实:
+
+- **任务库**:
+ - 每台 Orca 一份 SQLite 库,schema 版本 5(`M/local-tasks/local-tasks-schema.ts:7`)。
+ - 库的版本比程序新时只读打开(`M/local-tasks/local-tasks-database.ts:49-56`),所以升版本会让降级的旧 Orca 变成只读。
+- **RPC**:33 个方法;参数是 zod schema(`S/local-tasks/local-tasks-rpc-params.ts`);写入带 `clientOperationId` 幂等。
+ - 写入来源由传输层判定:`M/local-tasks/local-tasks-rpc-caller.ts:7-14`。远程 CLI 走 websocket,身份是 `paired-device`,会被记成 `desktop`。
+- **变更推送**:`scopes` 是严格枚举(`S/local-tasks/local-task-result-schemas.ts:212`),新加 scope 会让旧客户端整条消息解析失败;`failureSchema` 也是严格枚举(`:160`)。
+- **事件**:
+ - 共 15 种 kind(`S/local-tasks/local-task-types.ts:63-78`)。
+ - 客户端用 `salvagingArray` 解析事件列表,遇到不认识的 kind **静默丢掉那一条**(`S/local-tasks/local-task-read-schemas.ts:117,145-147`),桌面和手机一样。
+ - 只有 `comment` 能编辑、删除。
+- **轮次结束的数据**:
+ - 主机侧的 `LocalTaskAttentionMonitor` 订阅智能体状态,在 `judgeSession`(`M/local-tasks/local-task-attention-monitor.ts:200-268`)里判定「做完一轮」;
+ - 最后一句话只记在内存(`M/local-tasks/local-task-pane-memory.ts`),重启就没了;
+ - `lastCompletedAssistantMessage` 只在渲染层有,主机侧只有 `lastAssistantMessage` 和 `lastAssistantMessageIsToolOutput`。
+- **CLI**:
+ - 5 个子命令(`CLI/local-task-command-specs.ts`);新命令要同时登记到 `CLI/local-task-handler-group.ts:5` 的 `keys`。
+ - `task status` 接受已完成类别(`CLI/local-task-cli-handlers.ts:146-168`)。
+ - `task current` 只按当前目录找工作区(`:71-134`)。
+ - 终端里可以拿到 `ORCA_PANE_KEY`、`ORCA_TAB_ID`、`ORCA_WORKTREE_ID`(`M/ipc/pty/spawn-pane-env.ts:41-72`),聊天型会话还有 `ORCA_AGENT_SESSION_ID`(`M/runtime/structured-session-child-env.ts:122-133`)。
+- **prompt**:唯一生成函数是 `buildLocalTaskAgentPrompt`(`S/local-tasks/local-task-agent-prompt.ts:13-36`)。桌面开会话、开始做,手机开始做、开会话,共 5 条路径都调它。
+- **规则 2**:项目字段 `rules.prMergedCompletes` 存在整数列 `rule_pr_merged_completes` 里;只由桌面渲染层触发(`R/local-tasks/local-task-link-maintenance.ts:110-139`)。
+- **桌面主区**:
+ - `AppWorkspaceShell.tsx` 的内容行里,左边是工作台(`TerminalWorkbenchContainer`)加 `ActivePage`;右侧栏是它的兄弟节点,在 `tasks` 视图下整个卸载(`AppWorkspaceShell.tsx:231`)。
+ - 切 `activeView` 不卸载工作台,但隐藏的工作区 30 秒后会冷停放;挂进 portal 的工作区不停放(`R/components/terminal-parking-pass-candidates.ts`)。
+- **侧栏**:`R/components/sidebar/index.tsx:181-209` 在 `SidebarAgentsList` 和 `WorktreeList` 之间二选一;虚拟列表的行是封闭联合,键盘导航、拖拽、定位都围绕工作区写。
+
+### 3.2 已有能力与复用评估
+
+每条按「直接复用 → 在现有函数上加一点 → 必须新增」判断。选「必须新增」的,写明现有候选为什么不行。
+
+| 需求点 | 已检查的候选(路径) | 结论 | 理由 |
+| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 回写进展 | `events.addComment`(`M/local-tasks/local-tasks-service.ts:242-246`,`M/local-tasks/local-task-event-store.ts:80-94`) | 加一点 | 参数加可选标记,写进现成的 `payload` 列,kind 还是 `comment`。不加 RPC、不加 kind:旧客户端会把它显示成普通评论,不会丢;旧主机会剥掉标记,记成普通评论,降级无害 |
+| 轮次结束记录 | 事件表 `appendLocalTaskEvent`;`local_task_sessions` 加列 | 加一点(事件表) | 新增一种 kind `turn-ended`,旧客户端静默丢掉;不升版本。加列方案要升 v6,旧 Orca 会变只读,不选 |
+| 重启后最后一句话还在 | `judgeSession` 的内存 `prose` | 加一点 | 内存为空时读库里的轮次结束记录,`attention.get` 的 `lastMessage` 自然跨重启,客户端不改 |
+| 任务卡上的最新进展 | `tasks.list` 行;`attention.get` 快照 | 加一点 | `tasks.list` 每行加可选字段 `latestProgress`。列表本来就按 `tasks` scope 重读,旧客户端会剥掉这个字段 |
+| 待验收 | 评审类别(`S/local-tasks/local-task-model.ts:40`) | 直接复用 | 只改新项目的默认状态名(Q3) |
+| 需要你里的「任务在等你」 | `buildLocalTaskNeedsYouRows`(`R/local-tasks/local-task-needs-you-rows.ts:49`)、手机 `readLocalTaskHomeAttention`(`MO/local-task-home-attention.ts:38`) | 加一点 | 在 `S/` 写一个纯函数,按评审类别推导,两端调用;主机不改 |
+| CLI 拒绝写已完成 | `CLI/local-task-cli-handlers.ts:150`;`M/local-tasks/local-task-update.ts:79` | 加一点 | CLI 侧必拦,所有调用方都管用;主机侧对 `source==='cli'` 再兜底一次,只拦得住本机 CLI |
+| `task current` 先按会话找 | `findLocalTaskSessionForPane`(`R/components/local-tasks/LocalTaskSessionChip.tsx:22-37`) | 加一点(挪到 `S/`) | CLI 读到会话关联后,用环境变量匹配 |
+| `create --parent`、`link`、`update --title` | `tasks.create`、`links.linkWorkspace`、`tasks.update` RPC | 直接复用 | 只加 CLI 命令 |
+| 正文从文件读 | `readCommandBody`(`src/cli/command-body-input.ts:13-49`) | 直接复用 | 参数沿用 `--body-file` |
+| 任务说明 | `buildLocalTaskAgentPrompt` | 加一点 | 加第三个可选参数,一处改动覆盖 5 条路径 |
+| 调度说明的 skill | 原生聊天 visuals 插件、`agentArgs`、`skills install` | 不复用,改用 CLI 命令 | 前者对所有结构化聊天全局生效,按会话注入要改上游 `structured-agent-launch-composition.ts`(13 次);`agentArgs` 只对终端生效;`skills install` 装进全局目录 |
+| 能力位 v2 | `M/local-tasks/local-tasks-runtime-capability.ts:9-11` | 加一点 | 只改 fork 文件,上游 `orca-runtime-get-status.ts:153` 那行不动 |
+| 侧栏任务模式 | fork「按任务分组」(`R/local-tasks/local-task-sidebar-groups.ts`);`SidebarAgentsList` 外壳 | 必须新增主体 | 按任务分组的子行只能是工作区卡片,显示不了视图行、没有工作区的任务、会话行和「未关联任务的会话」;虚拟列表的行是封闭联合。改为新开第三种侧栏主体,内容复用 `LocalTaskNav` 的视图和计数 |
+| 侧栏模式按钮 | 选项菜单里现有的接缝 `workspace-options-menu-items.tsx:94` | 新开接缝 | 用户明确要放在标题行(2026-11-02) |
+| 任务视图 | 方案 B:标签栏伪标签,先例是 `clientHostedBrowserRows`;方案 C:任务页加浮层 | 推荐 C(Q1) | 见 §3.4 |
+| 概览 | `R/components/local-tasks/LocalTaskDetail.tsx`(fork 文件) | 加一点(改布局) | 删右栏 `LocalTaskDetailProperties.tsx`;`LocalTaskWorkspaceCard.tsx` 压成头部一行 |
+| 浮层容器 | 右侧栏 `right-sidebar/`;Sheet `ui/sheet.tsx`;`AgentDashboardDrawer` | 必须新增(1 处接缝) | 右侧栏只能推开、不能覆盖,`tasks` 视图下被卸载,切到别的标签页还会卸载面板;Sheet 只能覆盖,关闭就卸载,终端会重挂 |
+| 浮层里的终端 | `activity-terminal-portal.ts`(`setActivityTerminalPortals`)、`TerminalOverlaySlot`;`requestBackgroundTerminalWorktreeMount`(`R/components/terminal/background-terminal-worktree-mount.ts:36`) | 直接复用 + 1 行接缝 | Activity 页已经这样把别处的终端挂进来,能输入;只是启用条件写死了 `activeView === 'activity'`(`R/components/use-terminal-workspace-store-bindings.ts:64-66`) |
+| 浮层里的聊天 | `NativeChatView mode="structured"`(`R/components/native-chat/NativeChatView.tsx:13-32`) | 直接复用 | 不依赖祖先 Context;会话记录的读取按会话共享 |
+| 全屏 | `revealLocalTaskSession`(`R/local-tasks/local-task-session-reveal.ts`) | 直接复用 | 就是现在「打开会话」的行为 |
+| 派活输入框 | 聊天补全菜单(只能列文件,选项组件没导出)、PR 评论的 `MentionTextarea`(绑定 GitHub 用户)、新建工作区的智能输入(只认 URL) | 必须新增(fork) | 没有通用的 @ 组件。复用 `ui/command.tsx`(cmdk)和 `findMentionQuery`(`R/components/pull-request-page/mentions/query.ts:3`),不复制上游私有组件 |
+| @ 候选数据 | `listLocalTaskWorkspaceChoices`(`LocalTaskWorkspacePicker.tsx:51`)、`reposForLocalTaskProject`、`isLocalTaskHostInContact`、`buildLocalTaskPaletteItems`、`useSidebarHostScopeOptions` | 加一点 | 把按项目取改成跨项目汇总,再加一层在联系过滤 |
+| 派活执行 | `launchLocalTaskSession`;composer 执行器 `runBackgroundWorktreeCreation`;Automations 的 `createWorktree` + `launchAgentBackgroundSession`;`agent.launch` RPC | 直接复用 `agent.launch` | 手机和 CLI 都用它;已有工作区和新建工作区共用;返回 `sessionId`/`paneKey`。`launchLocalTaskSession` 传不了第一句、会跳转、只返回布尔值;composer 执行器会强制打开面板,要改上游;Automations 只能开终端 |
+| 发给空闲会话 | `sendLocalTaskReply` / `sendMessageToAgent` | 直接复用 | — |
+| 撤销 | `closeUnifiedTab`、`closeStructuredAgentSession`、`cancelStructuredAgentLaunch`、`tasks.remove`;toast 写法 `activity-clear-completed.ts:183` | 直接复用 | 只是组合起来再加计时 |
+| 调度会话 | `launchAgentInNewTab` + `FLOATING_TERMINAL_WORKTREE_ID` | 直接复用 | 结构化聊天能开在浮动伪工作区;子进程的 CLI 默认连本桌面(`structured-session-child-env.ts:136-160`) |
+| 提案卡 | 聊天的 markdown 扩展位(写死了 visuals)、`::orca-visual`(沙箱 iframe)、工具结果渲染 | 必须新增(聊天视图外) | 聊天里没有可注册的卡片位,iframe 里做不了「确认」 |
+| 提案存储 | 库偏好、事件表 | 必须新增(v6 表) | 提案不属于某个任务;偏好的 key 规则放不下 id |
+| 生成摘要 | `runGenerationRequest`;Jira 摘要模板(`jira-issue-summary-requests.ts:73-100`) | 加一点 | `operation` 联合要加一个值(上游接缝);不新增设置键 |
+| 默认首页 | `M/active-view-preference.ts`;`ui-slice-hydration-sanitizers.ts:172`;`openLocalTaskPage` | 加一点(fork) | 在 `LocalTasksSyncGate` 里加一个启动绑定,不碰上游 |
+| 设置 | `LocalTasksSettingsPane.tsx`、库偏好 | 加一点(fork) | 手机要读的项经 `localTasks.status` 暴露,做法同 `sourceHidden` |
+| 手机抽屉 | `MobileNativeChatOverlay`、`useMobileNativeChatController`、`BottomDrawer`;会话页整套 | 聊天加一点;终端不嵌(Q2) | 会话页依赖路由参数(`mobile/src/session/use-mobile-session-foundation.ts:21-34`) |
+
+### 3.3 与产品方案前提不符的事实
+
+1. **SSH 会话回写**:见 Q5。
+2. **远程 CLI 的写入来源**:通过 `--environment` 或 `--pairing-code` 连过去的 CLI,主机记成 `desktop`(`M/runtime/rpc/rpc-caller-identity.ts:38-45`)。所以「拒绝 CLI 写已完成」必须在 CLI 侧拦截,主机侧只能兜底。
+3. **升库版本的代价**:旧 Orca 打开会变只读,旧 CLI 写入会被拒(`CLI/local-task-cli-library.ts:102-107`)。所以 3a、3b 不升版本,3c 的提案表才升 v6。
+4. **不能加新的变更 scope,也不能给桌面返回新的 failure code**:两者都是严格枚举。新写入都复用现有 scope;需要通知「提案变了」时发 `scopes: null`,表示全部重读(前一版方案 §5 已支持)。
+5. **任务视图标签栏的代价**:Q1。
+6. **手机终端抽屉**:Q2。
+7. **浮层与 Activity 页共用一个模块级的 portal 列表**:两个视图互斥。切到 Activity 视图时浮层关闭,并清空自己发布的目标。
+
+### 3.4 候选思路对比
+
+**任务视图(Q1)**
+
+| 方案 | 做法 | 上游接缝 | 终端重挂 | 产品差异 |
+| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- | ----------------------------------------------------------------------------- |
+| B(产品原稿) | `terminal` 视图下激活任务的工作区;标签栏按任务过滤;最前面插一个「概览」伪标签,盖在标签组主体上 | `tab-bar-surface.tsx`(20)、`TabBar.tsx`(9)、`use-tab-bar-item-actions.ts`(6)、`WorkspacePaneOverlayLayers.tsx`(3)、`use-tab-bar-item-projection.ts`(5),合计 43 次 | 不重挂 | 无。另有已知瑕疵:⌘1-9、Ctrl+Tab 仍会激活被隐藏的标签 |
+| **C(推荐)** | 点任务进 `tasks` 视图的概览;会话开在浮层(速览 → 加宽);「全屏」= 现有的 `revealLocalTaskSession`,切到工作区原有的标签;会话头部的任务 chip 加「回到任务」 | 0(浮层槽位和终端开关是浮层本来就要的) | 全屏时重挂一次,B 方案也一样 | 全屏后看到的是工作区完整的标签栏,不按任务过滤;「+」改成概览头部的「开会话」 |
+
+**会话浮层容器**:右侧栏不能覆盖,`tasks` 视图下会被卸载;Sheet 关闭即卸载,终端会重挂;只剩新开一个槽位。槽位里同一个 DOM 节点,窄态是 flex 子元素(把主区推开),加宽态改成绝对定位(覆盖)。portal 的目标节点不变,所以宽度切换不重挂。
+
+**派活执行**:选 `agent.launch`,理由见 §3.2。
+
+### 3.5 关键约束(来自仓库规则)
+
+- `AGENTS.md`「Remote Host Reads」:不重连、不重试,主机不在联系就不读。派活、撤销、浮层读取都只在主机在联系时进行;浮层不在联系时显示上次的内容,写「未连接」。
+- `AGENTS.md`「Remote Wire Compatibility」:新字段可选;不加 stream opcode;主机发布内容的变化要考虑旧客户端。见 §3.3 第 3、4 条。
+- `AGENTS.md`「SSH Use Case」与 `docs/reference/fork-maintenance.md` 第 7 条:每台机器拥有什么写清楚(§5.2 机器归属);SSH 场景要真机验证。
+- `AGENTS.md`「Fork Maintenance」:新代码放在功能自有路径;上游文件只在登记过的接缝改;用到的上游模块登记进 `dependsOn`。
+- `AGENTS.md`「Folder Workspace Use Case」:派活到文件夹项目,只能选已有的文件夹工作区,CLI 建不了文件夹工作区。
+- `AGENTS.md`「Design System」:浮层、输入框用 `ui/` 下的原语和 `main.css` 的 token。
+- `AGENTS.md`「Tests」:判断智能体状态的规则要用真实采集的状态样本。
+
+### 3.6 未知项处置
+
+| 未知项 | 处置 |
+| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| SSH 会话里 CLI 连哪个库 | 开工前验证 1;结果决定 Q5 |
+| SSH、配对服务器上 done 那一行的 `lastAssistantMessage` 是否送达 | 开工前验证 2;前一版方案开工前验证 4 只覆盖了等待原因 |
+| 从 `tasks` 视图把从没激活过的工作区终端挂进浮层 | 开工前验证 3;用 `requestBackgroundTerminalWorktreeMount` 先挂载 |
+| `NativeChatView` 在标签组外挂载(含远程主机) | 开工前验证 4 |
+| 桌面渲染层第一次调用 `agent.launch` | 开工前验证 5;手机和 CLI 已在用,桌面已声明 `agent.launch.v2`(`M/ipc/desktop-renderer-runtime-capabilities.ts:64`) |
+| 同步上游后接缝是否还在原处 | 前置条件:先 `pnpm sync:upstream` |
+
+## 4. 交互链路
+
+### 4.1 系统交互图
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant UI as 桌面渲染层(任务页、侧栏任务树、浮层)
+ participant Lib as 任务库所在的 Orca 主进程(local-tasks 服务)
+ participant Mon as 主机侧需要你监视器
+ participant Exec as 执行主机(本机、SSH、配对服务器)
+ participant Agent as 智能体会话
+ participant CLI as 会话里的 orca CLI
+
+ UI->>Lib: tasks.create(派活:标题取第一句)
+ alt 已有工作区
+ UI->>Lib: links.linkWorkspace
+ UI->>Exec: agent.launch(target existing,prompt 为第一句加任务说明)
+ else 选了仓库
+ UI->>Exec: agent.launch(target create-worktree)
+ UI->>Lib: links.linkWorkspace(返回的 worktreeId)
+ else 工作区里有做完一轮的会话
+ UI->>Agent: sendMessageToAgent(不建任务)
+ UI->>Lib: events.addComment(标记:由对话追加)
+ end
+ Exec-->>UI: outcome(sessionId 或 paneKey)
+ UI->>Lib: links.recordSession
+ Note over UI: 已派活卡,10 秒撤销:关标签或会话,tasks.remove
+ Agent->>CLI: 一轮结束
+ CLI->>Lib: events.addComment(progress 标记,正文来自 --body-file)
+ Note over CLI,Lib: 连不到库时只写文件,不重试(任务说明约定)
+ Exec-->>Mon: 状态变成 done(hook)
+ Mon->>Lib: 同一事务写 turn-ended 事件(每个会话保留一条)
+ Lib-->>UI: subscribeChanges(scope:events、tasks)
+ UI->>Lib: tasks.list(每行带 latestProgress)
+ Note over UI: 任务卡和现在卡显示最新进展;主机不在联系时显示上次的内容
+```
+
+图覆盖派活三种分岔、回写、轮次结束、推送与重读。读图重点:
+
+- 所有写入都落在任务库所在的那台 Orca;会话在执行主机上启动。
+- 回写有两条来源:智能体主动用 CLI 写,以及主机在一轮结束时记下最后一句话。两条都汇到任务行的 `latestProgress`。
+- 观测点:任务时间线上的事件(派出、回写、轮次结束、撤销),以及 `subscribeChanges` 的 scope。
+
+### 4.2 用户动线图
+
+```mermaid
+flowchart TD
+ A["打开 Orca:任务首页"] --> B{"要做什么"}
+ B -->|"一句话加 @"| C["派活"]
+ B -->|"新建任务"| D["第一屏:标题、描述、在哪做"]
+ D -->|"新建工作区"| E["Orca 的新建工作区弹窗"]
+ D -->|"已有工作区"| F["建好并开工"]
+ E --> F
+ C --> G["已派活卡:10 秒内可撤销"]
+ G -->|"撤销"| H["任务删除,会话停止"]
+ G --> I["任务卡出现最新进展"]
+ F --> I
+ I --> J{"需要你?"}
+ J -->|"在等你回答"| K["速览:在浮层里直接回答"]
+ K -->|"要看全貌"| L["加宽"]
+ L -->|"要整屏"| M["全屏:工作区的会话标签"]
+ M -->|"会话头部:回到任务"| L
+ J -->|"待验收"| N["概览:验收条"]
+ N -->|"看看它怎么验的"| K
+ N -->|"通过"| O["已完成"]
+ N -->|"打回并写原因"| P["原因发给会话,回到进行中"]
+ P --> I
+```
+
+覆盖派活、新建、速览、加宽、全屏、验收、撤销、打回这几条路径。验收关注点:任何一步都不离开任务;全屏之后能回到任务。
+
+### 4.3 浮层状态
+
+```mermaid
+stateDiagram-v2
+ [*] --> Closed
+ Closed --> Narrow: 点会话、速览、侧栏会话
+ Narrow --> Wide: 加宽
+ Wide --> Narrow: 收窄
+ Narrow --> Fullscreen: 全屏
+ Wide --> Fullscreen: 全屏
+ Fullscreen --> Wide: 会话头部回到任务
+ Narrow --> Closed: 关闭或 Esc
+ Wide --> Closed: 关闭或 Esc
+ Narrow --> Narrow: 切换会话(下拉)
+ Closed --> Closed: 切到 Activity 视图
+ Narrow --> Closed: 切到 Activity 视图
+ Wide --> Closed: 切到 Activity 视图
+```
+
+状态由 fork 的浮层 store 维护(§5.6 M8)。
+
+- Narrow 和 Wide 共用同一个 DOM 目标,宽度切换不重挂。
+- 进入 Fullscreen 时撤掉 portal,终端回到自己的标签,重挂一次。
+- 主机不在联系时状态不变,内容区显示上次的内容和「未连接」。
+
+## 5. 方案设计
+
+### 5.1 设计原则
+
+1. **复用优先**:先找上游或 fork 已有的;能加一点就不新写;新写的只放在功能自有路径。
+2. **少碰上游**:上游接缝每处只放一个挂载点或一个条件;业务逻辑都在 fork 文件里;不扩展上游的联合类型(前一版方案 §1 第 5 条)。
+3. **混合版本安全**:3a、3b 不升库版本,不加 RPC 方法、scope 和 failure code;新字段都是可选的;新事件 kind 只给「旧客户端丢了也不影响」的记录用。
+4. **主机拥有执行**:写入落在任务库所在的那台;会话的启动、停止、读取在执行主机上;主机不在联系时只显示,不重试。
+5. **一处生成**:prompt、需要你判定、待验收推导、会话匹配都放在 `S/`,桌面、手机、CLI 共用。
+
+### 5.2 仓库规范、既有逻辑与机器归属
+
+规范来源:`AGENTS.md`;`docs/reference/fork-maintenance.md`;`config/fork-features.jsonc` 里的 `local-tasks` 条目(126 条接缝,`ownedPaths` 共 14 项);`config/architecture-policies.jsonc` 的 `local-tasks` 策略;前一版方案 §4.1~§4.3(RPC 写法、Bun 下的 sqlite 测试)、§12(接缝登记)。
+
+| 东西 | 在哪台机器 |
+| -------------------------------------------------------------------- | ----------------------------------------------------------- |
+| 任务、事件(含 progress 评论和 turn-ended) | 任务库所在的 Orca:桌面、`orca serve` 或 SSH 主机上的 orcad |
+| 提案、对话列表(3c,v6 表) | 桌面的任务库(调度会话只在桌面) |
+| 设置偏好(启动打开任务页、任务说明、发送方式、调度先确认、侧栏模式) | 本机任务库的 `preferences` 表 |
+| 浮层状态、侧栏展开状态 | 桌面渲染层内存 |
+| 调度会话 | 桌面主进程启动的结构化聊天,目录是 `floatingTerminalCwd` |
+| 智能体会话、终端、hooks | 执行主机,不改 |
+
+不新增 `127.0.0.1` 端口、环境变量、执行主机上的文件或服务。
+
+### 5.3 复用与扩展策略
+
+- **任务库**:复用评论接口、事件表、`tasks.list`、`localTasks.status`、评审类别、规则 2 的整数列;只在 3c 加 v6 表。
+- **CLI**:复用 `task` 命令组、`readTaskLibrary`、`write`、`readCommandBody`、`resolveCurrentWorktreeSelector`;加 6 个子命令(`progress`、`create`、`link`、`update`、`guide`,3c 加 `propose`)。
+- **桌面**:
+ - 侧栏主体加第三种,内容复用 `LocalTaskNav` 的视图、项目、计数,`LocalTaskProgress` 的状态点,以及 `buildWorktreeAgentRows`(未关联会话);
+ - 浮层终端复用 portal 和后台挂载,聊天复用 `NativeChatView`;
+ - 全屏复用 `revealLocalTaskSession`;
+ - 派活复用 `agent.launch` 和关联写入;
+ - 撤销复用关标签、删任务和 toast 写法。
+- **手机**:复用 `startLocalTaskInNewWorkspace`、`openLocalTaskSession`、`sendLocalTaskReply`、`MobileNativeChatOverlay`、`BottomDrawer`。
+- **不做的平行实现**:不另写聊天 UI、终端渲染、标签系统、新建工作区表单、@ 弹层组件、文本生成管线。
+
+### 5.4 仓库改动总览
+
+标注:`[新增]` 新文件;`[修改]` 改既有文件;`[复用]` 只调用、不改;`[删除]` 删文件。带「上游接缝」的是 `ownedPaths` 之外的文件。
+
+```text
+src/
+├── main/
+│ ├── local-tasks/
+│ │ ├── local-task-attention-monitor.ts [修改] 收集轮次结束,在 refreshNow 的事务里落库;内存为空时读库(3a)
+│ │ ├── local-task-turn-records.ts [新增] 轮次结束记录:每个会话删旧写新、按会话读取(3a)
+│ │ ├── local-task-pane-memory.ts [复用]
+│ │ ├── local-task-event-store.ts [修改] kind 白名单加 turn-ended;评论 payload 带 progress 标记;不允许编辑或删除 progress 评论(3a)
+│ │ ├── local-task-latest-progress.ts [新增] 按任务取最近一条 progress 评论或 turn-ended(3a)
+│ │ ├── local-task-store.ts [修改] 行带 latestProgress;不再读 criteria(3a)
+│ │ ├── local-task-mutations.ts [修改] criteria 只写 [];CLI 来源不能进已完成类别(兜底)(3a)
+│ │ ├── local-task-update.ts [修改] 同上(3a)
+│ │ ├── local-task-criteria-fold.ts [新增] 打开库时把已有 criteria 追加到描述末尾,用 meta 标记保证只做一次(3a)
+│ │ ├── local-tasks-service.ts [修改] addComment 透传标记;status 加 taskBrief、sessionSend(3a)
+│ │ ├── local-tasks-runtime-capability.ts [修改] 同时通告 v1 和 v2(3a)
+│ │ ├── local-task-pull-request-context.ts [修改] 去掉 criteria(3a)
+│ │ ├── local-task-rules.ts [修改] 规则 2 三选一(3c)
+│ │ ├── local-task-project-store.ts [修改] 读出三态(3c)
+│ │ ├── local-task-project-mutations.ts [修改] 写入三态(3c)
+│ │ ├── local-tasks-schema.ts [修改] v6:提案表、对话表(含总管面板与收件箱游标)、盯着的任务表(3c)
+│ │ ├── local-task-proposals.ts [新增] 提案的增、列、决定;提案项按 kind 校验(3c)
+│ │ ├── local-task-dispatcher-inbox.ts [新增] 总管收件箱:盯着的任务、按游标取事件、需要你变化(3c)
+│ │ └── local-task-summary-generation.ts [新增] 生成摘要(3d)
+│ ├── runtime/rpc/methods/local-tasks.ts [修改] proposals.*、dispatcher.inbox/watch/unwatch 方法(3c)
+│ ├── runtime/runtime-rpc/fork-mobile-rpc-method-allowlist.ts [修改] proposals.*(3c)
+│ └── text-generation/source-control-text-generation-types.ts [修改](上游接缝)operation 加一个值(3d)
+├── shared/
+│ ├── local-tasks/
+│ │ ├── local-task-types.ts [修改] EventKind 加 turn-ended;LocalTask 加可选 latestProgress、去掉 criteria;status 加两字段;rules 三态(3c)
+│ │ ├── local-tasks-rpc-params.ts [修改] addComment 加可选 progress;create 和 update 去掉 criteria;rules 三态(3c);proposals 参数(3c)
+│ │ ├── local-task-read-schemas.ts [修改] 同上,客户端读取
+│ │ ├── local-task-result-schemas.ts [修改] 同上,主机结果;对外仍带 criteria: []
+│ │ ├── local-task-agent-prompt.ts [修改] 第三参数:任务说明;去掉 criteria(3a)
+│ │ ├── local-task-brief.ts [新增] 任务说明正文与 orca task guide 全文(3a)
+│ │ ├── local-task-session-match.ts [新增] 从渲染层挪来的会话匹配,CLI 共用(3a)
+│ │ ├── local-task-review-items.ts [新增] 按评审类别推导「任务在等你」(3c)
+│ │ ├── local-task-model.ts [修改] 新项目的评审类别默认状态名(3c,Q3)
+│ │ └── local-tasks-capability.ts [修改] 加 v2 常量(3a)
+│ └── rpc-contract/rpc-params-catalog.generated.ts [修改] 重新生成(3c;3a 按校验结果决定)
+├── cli/
+│ ├── local-tasks/
+│ │ ├── local-task-command-specs.ts [修改] progress、create、link、update、guide(3a);propose(3c)
+│ │ ├── local-task-cli-handlers.ts [修改] 拒绝已完成;current 先按会话找(3a)
+│ │ ├── local-task-cli-write-handlers.ts [新增] 新增的写命令(3a)
+│ │ ├── local-task-cli-library.ts [修改] 探测 v2(3a)
+│ │ ├── local-task-cli-format.ts [修改] 去掉 criteria(3a)
+│ │ └── local-task-handler-group.ts [修改] keys 加新命令(3a)
+│ └── command-body-input.ts [复用]
+└── renderer/src/
+ ├── app-shell/AppWorkspaceShell.tsx [修改](上游接缝)内容行里加浮层槽位(3a)
+ ├── components/use-terminal-workspace-store-bindings.ts [修改](上游接缝)portal 启用条件或上浮层(3a)
+ ├── components/sidebar/index.tsx [修改](上游接缝)任务模式主体、定位改道条件(3a)
+ ├── components/sidebar/SidebarHeader.tsx [修改](上游接缝)模式按钮、标题(3a)
+ ├── components/activity/activity-terminal-portal.ts [复用]
+ ├── components/terminal/background-terminal-worktree-mount.ts [复用]
+ ├── components/native-chat/NativeChatView.tsx [复用]
+ ├── components/ui/command.tsx [复用]
+ ├── components/pull-request-page/mentions/query.ts [复用]
+ ├── local-tasks/
+ │ ├── local-task-sidebar-mode.ts [新增] 侧栏模式状态与偏好(3a)
+ │ ├── local-task-session-panel-store.ts [新增] 浮层状态机(3a)
+ │ ├── local-task-session-panel-portal.ts [新增] 发布和清理终端 portal 目标(3a)
+ │ ├── local-task-session-reveal.ts [修改] 打开会话默认进浮层;全屏走原逻辑(3a)
+ │ ├── local-task-session-launch.ts [修改] 加 activate 参数:开会话后在浮层打开(3a)
+ │ ├── local-task-startup-page.ts [新增] 启动时打开任务页(3a)
+ │ ├── local-task-preference-keys.ts [修改] 新偏好键(3a)
+ │ ├── local-task-event-copy.ts [修改] turn-ended、progress、派活事件的文案;系统事件分类(3a)
+ │ ├── local-task-dispatch-candidates.ts [新增] @ 候选汇总(3b)
+ │ ├── local-task-dispatch-launch.ts [新增] 派活执行(3b)
+ │ ├── local-task-dispatch-undo.ts [新增] 撤销(3b)
+ │ ├── local-task-dispatcher-session.ts [新增] 调度会话即总管:常驻唯一、Claude 以终端启动、面板映射对话(3c)
+ │ ├── local-task-dispatcher-wake.ts [新增] 盯着的任务有动态且总管空闲时写一行指针(3c)
+ │ └── local-task-acceptance.ts [新增] 通过与打回(3c)
+ ├── components/local-tasks/
+ │ ├── LocalTaskSidebarTree.tsx [新增] 任务模式主体(3a)
+ │ ├── LocalTaskSidebarModeButton.tsx [新增](3a)
+ │ ├── LocalTaskNav.tsx [修改] 抽出视图与计数模型供侧栏复用;任务模式下页面不渲染这一列(3a)
+ │ ├── LocalTaskPage.tsx [修改] 顶部派活输入框(3b);任务模式下隐藏左列(3a)
+ │ ├── LocalTaskDetail.tsx [修改] 四块布局(3a)
+ │ ├── LocalTaskNowCard.tsx [新增] 现在卡(3a)
+ │ ├── LocalTaskDetailProperties.tsx [删除](3a)
+ │ ├── LocalTaskWorkspaceCard.tsx [修改] 压成头部一行(3a)
+ │ ├── LocalTaskCriteriaEditor.tsx [删除](3a)
+ │ ├── LocalTaskDetailBody.tsx [修改] 去掉完成标准(3a)
+ │ ├── LocalTaskCreateDialog.tsx [修改] 两屏流程第一屏;去掉完成标准(3a)
+ │ ├── LocalTaskSessionList.tsx [修改] 行点击进浮层(3a)
+ │ ├── LocalTaskSessionChipCard.tsx [修改] 加「回到任务」(3a)
+ │ ├── LocalTaskSessionPanel.tsx [新增] 浮层外壳与头部(3a)
+ │ ├── LocalTaskSessionPanelSlot.tsx [新增] 挂在上游槽位上的组件(3a)
+ │ ├── LocalTaskSessionPanelChat.tsx [新增] 浮层里的聊天(3a)
+ │ ├── LocalTaskDispatchBox.tsx [新增] 派活输入框(3b)
+ │ ├── LocalTaskDispatchReceipt.tsx [新增] 已派活卡(3b)
+ │ ├── LocalTaskAcceptanceStrip.tsx [新增] 验收条(3c)
+ │ ├── LocalTaskDispatcherPage.tsx [新增] 对话页(3c)
+ │ ├── LocalTaskProposalCard.tsx [新增] 提案卡(3c)
+ │ └── settings/LocalTaskWorkflowSettings.tsx [新增] 设置分区(3a);LocalTaskProjectRules.tsx [修改] 三选一(3c)
+ ├── components/cmd-j/local-task-quick-actions.ts [修改]「派活…」(3b)
+ └── i18n/locales/en.json、zh.json [修改](现有接缝,localTasks 命名空间)
+mobile/src/local-tasks/
+├── MobileHomeLocalTasksCard.tsx [修改] 最新进展一行(3a)
+├── local-task-detail-view.tsx 等详情文件 [修改] 四块布局,去掉完成标准(3a)
+├── local-task-form-*.ts(x)、use-local-task-subtask-batch.ts [修改] 去掉完成标准(3a)
+├── local-task-session-drawer.tsx [新增] 聊天抽屉(3a)
+├── local-task-dispatch-input.tsx [新增] 派活输入框(3b)
+├── local-tasks-library-screen.tsx [修改] 挂输入框(3b)
+└── local-task-acceptance-*.tsx [新增] 验收(3c)
+config/
+├── fork-features.jsonc [修改] 新接缝、新 dependsOn、新测试
+└── architecture-policies.jsonc [修改] 新接缝文件加入 local-tasks 允许名单
+```
+
+### 5.5 模块总览
+
+| 模块 | 职责 | 落点 | 改动类型 | 复用基础 | 扩展方式 | 切片 |
+| -------------------- | -------------------------------------------- | ----------------------------------------------------------------- | --------- | ------------------------------------------------------------------ | --------------------- | ----- |
+| M1 任务说明与 prompt | 每个从任务开的会话带约定 | `S/local-tasks/local-task-agent-prompt.ts`、`local-task-brief.ts` | 修改+新增 | `buildLocalTaskAgentPrompt` | 第三个可选参数 | 3a |
+| M2 回写与轮次结束 | 进展落库、最后一句话不丢 | `M/local-tasks/*` | 修改+新增 | 评论接口、事件表、监视器的事务 | payload 标记、新 kind | 3a |
+| M3 CLI | 智能体读写任务 | `CLI/*` | 修改+新增 | `task` 组、`readCommandBody`、现有 RPC | 新子命令 | 3a |
+| M4 兼容与能力位 | 混合版本 | `M/`、`S/` 能力文件 | 修改 | v1 通告写法 | 加 v2 | 3a |
+| M5 去掉完成标准 | D-014 | 全层 | 修改+删除 | — | 字段对外保持 `[]` | 3a |
+| M6 侧栏任务模式 | 任务树 | 上游 2 处接缝 + fork | 新增 | `LocalTaskNav` 模型、状态点、`buildWorktreeAgentRows` | 第三种侧栏主体 | 3a |
+| M7 任务页与概览 | 首页、概览四块 | `R/components/local-tasks/*` | 修改 | `LocalTaskDetail` 等 | 改布局 | 3a |
+| M8 会话浮层 | 速览、加宽、全屏 | 上游 2 处接缝 + fork | 新增 | portal、后台挂载、`NativeChatView`、reveal | 新槽位 | 3a |
+| M9 设置、首页、Cmd+J | 偏好与入口 | fork | 修改+新增 | 设置分区、库偏好、quick actions | 加项 | 3a/3b |
+| M10 手机 | 首页卡、详情、抽屉、派活 | `MO/*` | 修改+新增 | 手机启动、发送、聊天组件 | — | 3a/3b |
+| M11 派活 | 输入、候选、执行、撤销 | `R/local-tasks/local-task-dispatch-*` | 新增 | `agent.launch`、关联、发送、关标签 | — | 3b |
+| M12 验收 | 规则 2、任务在等你、通过与打回 | `M/`、`S/`、`R/` | 修改+新增 | 评审类别、规则 2 整数列、回复发送 | 三态 | 3c |
+| M13 调度会话与提案 | 复杂请求、只问;常驻的总管:分配、管理、感知 | `R/`、`M/`、`CLI/` | 新增 | 浮动伪工作区、终端与结构化会话、`NativeChatView`、提案执行复用 M11 | v6 表、收件箱 | 3c |
+| M14 生成摘要等 | 3d | `M/local-tasks/`、上游 1 处接缝 | 新增 | `runGenerationRequest` | operation 值 | 3d |
+
+### 5.6 模块详细设计
+
+#### M1 任务说明与 prompt(3a)
+
+- **落点**:`S/local-tasks/local-task-agent-prompt.ts`(修改)、`S/local-tasks/local-task-brief.ts`(新增)。
+- **做法**:
+ - `buildLocalTaskAgentPrompt(task, ancestors, options)`,`options.brief: boolean`。为 true 时,在上级摘要之后追加一段短说明。短说明的内容:
+ - 任务编号;
+ - 「需要最新内容时运行 `orca task show 编号`」;
+ - 「完整约定运行 `orca task guide`」;
+ - 每轮结束用 `orca task progress 编号 --body-file 文件` 写三段式结论;
+ - 写不进去就把摘要写到工作区文件里,继续干活,不重试;
+ - 「完成」由人点。
+ - 完整约定(产品方案 §7.1 全文)是 `local-task-brief.ts` 里的常量,由 `orca task guide` 输出。
+ - 派活任务(3b)还要多一句:「开工后用 `orca task update 编号 --title` 改一个不超过 30 字的短标题」。
+- **调用方不改签名**:
+ - 桌面 `localTaskPromptFor`(`R/local-tasks/local-task-session-launch.ts:37-43`)、`use-local-task-composer-source.ts:130`;
+ - 手机 `MO/use-local-task-launch.ts:140`、`MO/local-task-create-source-start.ts:86`;
+ - 这几处都从 `localTasks.status` 读 `taskBrief` 后传入。
+- **开关**:库偏好 `taskBrief`,默认开;经 `localTasks.status` 暴露给手机(`preferences.*` 不在手机允许名单里)。
+- **失败处理**:读不到 status 时按默认开处理。
+
+#### M2 回写与轮次结束记录(3a)
+
+- **落点**:`M/local-tasks/local-task-event-store.ts`、`local-task-attention-monitor.ts`、`local-task-turn-records.ts`(新增)、`local-task-latest-progress.ts`(新增)、`local-task-store.ts`、`local-tasks-service.ts`。
+- **回写**:
+ - `events.addComment` 收到 `progress` 后,把 `{ progress: true, sessionLinkId?, needsYou? }` 写进 payload,kind 仍是 `comment`。
+ - `requireComment` 对带 progress 标记的评论拒绝编辑和删除,返回现有的 `invalid_argument`。不加 failure code。
+ - 事务里同时 touch `events` 和 `tasks`,因为任务行的 `latestProgress` 变了。
+- **轮次结束**:
+ - 在 `judgeSession` 的 244-246 行,把「做完一轮」的会话收进 `Judgment.turnEnds`:会话关联 id、任务 id、`localTaskAttentionKey(paneKey, stateStartedAt)`、`localTaskRowWords(row, prose)`。
+ - 在 `refreshNow` 152-163 行已有的写库事务里写入,规则是:
+ - 同一个会话先删旧的 turn-ended,再插新的;
+ - 同一个 key 不重复写;
+ - 只读库跳过;
+ - touch `events` 和 `tasks`,不 touch `links`,避免再触发一轮 `followLinks`。
+ - 不在 `judge()` 里写:`snapshot()` 和 `markTarget()` 的读路径也会调它。
+- **重启后**:`judgeSession` 207 行的 `prose` 为空时,读这个会话的 turn-ended 正文。这样 `attention.get` 的 `lastMessage` 跨重启保留。
+- **latestProgress**:`tasks.list` 每行附带最近一条 progress 评论或 turn-ended:`{ kind, body, at, sessionLinkId, needsYou? }`。父任务的汇总由客户端在子孙行里取最新一条,主机不算。
+- **接口约定**(zod 是本仓库 RPC 的契约语言;`S/local-tasks/local-tasks-rpc-params.ts`、`S/local-tasks/local-task-read-schemas.ts`):
+
+```ts
+// S/local-tasks/local-tasks-rpc-params.ts
+export const LocalTasksEventsAddCommentParams = z.object({
+ clientOperationId, // [复用]
+ taskId: id, // [复用]
+ body: z.string().trim().min(1).max(LOCAL_TASK_COMMENT_MAX), // [复用]
+ // [新增] 智能体回写;旧主机会剥掉这个字段,记成普通评论
+ progress: z
+ .object({
+ sessionLinkId: id.optional(), // [新增] 回写来自哪个会话(CLI 按环境变量匹配)
+ needsYou: z.string().trim().max(500).optional() // [新增] 需要你什么
+ })
+ .optional()
+})
+
+// S/local-tasks/local-task-types.ts
+export type LocalTaskEventKind =
+ | 'comment' // [复用] progress 标记放在 payload 里
+ | 'turn-ended' // [新增] 每个会话保留最近一条;旧客户端解析时静默丢掉
+// 其余 14 种 [复用]
+
+export type LocalTaskLatestProgress = {
+ // [新增]
+ kind: 'progress' | 'turn-ended'
+ body: string
+ at: number
+ sessionLinkId: string | null
+ needsYou?: string
+}
+
+export type LocalTask = {
+ // ……其余字段 [复用]
+ latestProgress?: LocalTaskLatestProgress // [新增] 可选;旧主机不带
+ // criteria 不再出现在客户端类型里 [修改];主机结果对外仍带 criteria: [](M5)
+}
+```
+
+- **影响落点**:
+ - 生成的参数目录:按 `verify:rpc-params-catalog` 的结果决定要不要重新生成;
+ - 服务实现:`M/local-tasks/local-tasks-service.ts:242`;
+ - 调用方:CLI `task progress`(M3)、桌面和手机的读取(M7、M10)。
+
+#### M3 CLI(3a;`propose` 在 3c)
+
+- **落点**:`CLI/local-task-command-specs.ts`、`local-task-cli-handlers.ts`、`local-task-cli-write-handlers.ts`(新增)、`local-task-handler-group.ts`、`local-task-cli-library.ts`、`local-task-cli-format.ts`。
+
+| 命令 | 调用的 RPC | 说明 |
+| ------------------------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
+| `task progress REF --body-file 文件 [--needs-you 文本]` | `events.addComment` + progress | 正文用 `readCommandBody`(`-` 读 stdin);会话关联按环境变量匹配;主机没有 v2 时照样写,并输出「主机较旧,记成了普通评论」 |
+| `task create 标题 [--parent REF] [--body-file 文件]` | `tasks.create` | 衍生问题(REQ-025) |
+| `task link REF [--worktree 选择器]` | `links.linkWorkspace` | 工作区解析复用 `resolveCurrentWorktreeSelector` |
+| `task update REF --title 标题` | `tasks.update` | 带 `expectedRevision` |
+| `task guide` | 无 | 输出完整约定 |
+| `task propose --body-file 文件`(3c) | `proposals.create` | 见 M13 |
+| `task needs-you [--json]`(3c) | `attention.get` + 评审类别推导 | 「会话在等你」与「任务在等你」合在一份结果里,给总管读;只是 CLI,判定沿用 REQ-014 与 M12 |
+| `task inbox [--json] [--ack 游标]`(3c) | `dispatcher.inbox` | 总管的收件箱,见 M13「感知」;桌面按在联系的库逐个读再合并 |
+| `task watch REF` / `task unwatch REF`(3c) | `dispatcher.watch/unwatch` | 用户让总管盯一个已有任务 |
+| `task list --all-hosts` / `--watched`(3c) | 各库 `tasks.list` | 合并所有在联系的任务库,每行带 `hostId`;不在联系的库列为「无法确认」 |
+| `task show REF --events N`(3c) | `tasks.get` + `events.list` | 动态限量,给总管读 |
+
+- **拒绝已完成**:`task status` 在 `CLI/local-task-cli-handlers.ts:150` 之后检查 `status.category === 'completed'`,抛出写给智能体看的错误,提示可用的状态、「完成要在界面上点」,以及「这一轮不要再改状态,留一条评论」。主机侧在 `M/local-tasks/local-task-update.ts:79` 和 `local-task-mutations.ts:135` 对 `source==='cli'` 兜底。
+- **current 先按会话找**:`findLocalTaskSessionForPane` 挪到 `S/local-tasks/local-task-session-match.ts`,渲染层和手机改为 import 这里。CLI 用 `ORCA_PANE_KEY`、`ORCA_AGENT_SESSION_ID`、`ORCA_TAB_ID` 去匹配 `readTaskLibrary(..., {links:true})` 拿到的会话关联;找不到时退回按工作区找。
+- **依赖登记**:`dependsOn` 加 `src/cli/command-body-input.ts`、`src/shared/agent-session-caller-env.ts`。
+- **失败**:主机不在联系、没有任务库,沿用现有报错;不重试。
+
+#### M4 兼容与能力位(3a)
+
+- 在 `S/local-tasks/local-tasks-capability.ts` 加 `LOCAL_TASKS_V2_RUNTIME_CAPABILITY = 'local-tasks.v2'`。`M/local-tasks/local-tasks-runtime-capability.ts` 同时返回 v1 和 v2,并同步更新 `local-tasks.test.ts:114,160` 的断言。
+- v2 的含义:认识 progress 标记和 turn-ended、行带 latestProgress、status 带 `taskBrief` 和 `sessionSend`。3c 的规则三态和提案不另立版本,客户端按字段是否存在判断。
+- 客户端:桌面对配对环境用 `runtimeEnvironmentSupportsCapability` 检查;手机用 `hostAdvertisesLocalTasks` 的同一写法;CLI 照 `src/cli/reference-input.ts:95-100` 读 `status.get` 的 capabilities。
+- 混合版本表:
+
+| 组合 | 表现 |
+| ----------------- | ------------------------------------------------------------------------------------------------------ |
+| 新客户端 + 旧主机 | 没有 latestProgress,卡片退回显示会话的最后一句话;CLI 的 progress 记成普通评论;不显示规则三选一 |
+| 旧客户端 + 新主机 | turn-ended 被丢掉;progress 显示成普通评论;latestProgress、新的 status 字段被剥掉;criteria 收到 `[]` |
+| 旧 Orca 打开新库 | 3a、3b 版本没变,正常读写;3c 升 v6 之后旧版只读 |
+
+#### M5 去掉完成标准(3a)
+
+- **库**:列保留,新写入只写 `'[]'`。不删列:删了之后,旧版只读打开时 `SELECT t.criteria` 会报错。
+- **数据**:打开库时执行一次 `local-task-criteria-fold.ts`,把已有条目以「完成标准」小节追加到描述末尾,把列清成 `[]`,并在 `meta` 写标记保证只做一次。这不是 schema 迁移,所以 `user_version` 不变。
+- **对外**:主机结果仍带 `criteria: []`,照顾旧客户端;客户端读取本来就用 `withDefault([])`(`S/local-tasks/local-task-read-schemas.ts:102`)。
+- **其余触点**按调研清单删掉:
+ - 桌面 `LocalTaskCriteriaEditor.tsx`,以及 `LocalTaskDetailBody.tsx`、`LocalTaskCreateDialog.tsx`、`LocalTaskPreview.tsx` 里的相关部分;
+ - 手机的表单、详情、多行子任务;
+ - prompt、PR 描述上下文、CLI 输出;
+ - i18n 各 7 个键。
+- 历史事件 kind `criteria` 和它的文案保留,旧时间线还能读。
+- **测试**:桌面约 20 个、手机约 17 个测试文件里的 criteria 构造要清理。
+
+#### M6 侧栏任务模式(3a)
+
+- **上游接缝**:
+ - `R/components/sidebar/index.tsx:181`:三元表达式外再包一层 `{localTaskSidebarBody ?? (…原逻辑…)}`。fork hook 在任务模式下返回任务树,否则返回 null。
+ - `R/components/sidebar/index.tsx:153`:`useWorkspaceRevealBodyRedirect(sidebarOpen && (sidebarBody === 'agents' || localTaskSidebarBodyShowing))`。
+ - `R/components/sidebar/SidebarHeader.tsx:39-58`:标题用 `useLocalTaskSidebarTitle() ?? 原标题`;按钮区开头(`:60`)插 ``。
+- **状态**:
+ - `R/local-tasks/local-task-sidebar-mode.ts` 记录是否任务模式,存库偏好 `sidebarTaskMode`,写法照 `local-task-sidebar-grouping.ts:31-40`。
+ - 不扩展上游 `sidebarBody` 联合(它只在内存,上游近三月 16 次)。
+ - 任务树监听 `SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT`:收到定位工作区的请求时退出任务模式,上游的改道 hook 随后重放这个请求。
+- **内容**:
+ - 顶层是 `LocalTaskNav` 原有的视图和项目,抽出它的模型供两处共用;
+ - 展开视图或项目,列出任务,排序与列表一致;子任务按深度缩进,用 `SIDEBAR_TREE_INDENT` 和 `getProjectGroupHeaderPaddingLeft`;
+ - 展开任务,列出会话行:`LocalTaskSessionDot` 加一句话;
+ - 最底部是「未关联任务的会话」:没关联任务的工作区,用 `buildWorktreeAgentRows` 取会话。
+- **交互**:
+ - 选中视图或项目行:设页面 store 的 view 和 scope,并切到 `tasks` 视图;
+ - 点任务:进概览(M7);
+ - 点会话:概览加浮层(M8);
+ - 「记成任务…」:打开新建任务第一屏并预填。
+- **渲染**:任务数量小,不做虚拟化;外壳照 `SidebarAgentsList.tsx:127`;行高 28;选中样式用 `bg-worktree-sidebar-accent`。键盘支持 ↑↓、← →(展开或收起)、Enter。
+- **主机**:主机不在联系时,任务标「未连接」,会话行置灰,不发读请求。
+
+#### M7 任务页与概览(3a)
+
+- **首页**:
+ - `LocalTaskPage.tsx` 在任务模式下不渲染 `LocalTaskNav` 这一列;
+ - 顶部派活输入框在 3b 加;
+ - 列表行和看板卡显示 latestProgress 一行,父任务加子孙汇总行(REQ-012)。
+- **概览**:在 `LocalTaskDetail.tsx` 上改成四块:
+ - 头部:标题;属性 chips(复用 `LocalTaskFieldPickers.tsx`,没设的不显示);工作区一行(`LocalTaskWorkspaceCard.tsx` 里的 `ChangeStats`、PR 徽标压成一行,点开有「打开工作区」「查看更改」);一个按状态变的主按钮;「…」菜单。
+ - 「现在」卡(新增 `LocalTaskNowCard.tsx`):
+ - 3c 起,验收条在最上面;
+ - 有「需要你」条目时,显示「在等你什么」和速览;
+ - latestProgress,`needsYou` 有才显示;
+ - 每个会话一行,取自 `LocalTaskSessionList.tsx`,点击进浮层;
+ - 空状态只有一行。
+ - 描述、子任务:有内容才显示。
+ - 动态:系统事件折叠成「系统记录 N」。分类在 `local-task-event-copy.ts`:评论、progress 评论、派活或追加指令、验收记录是一类,其余是系统记录;turn-ended 不进时间线。
+- **删除**:`LocalTaskDetailProperties.tsx` 删掉,右栏取消;创建时间、更新时间放到动态底部。
+- **界面文案**:不出现需求编号和机制说明。
+
+#### M8 会话浮层(3a)
+
+- **上游接缝**:
+ - `R/app-shell/AppWorkspaceShell.tsx` 内容行(`relative flex flex-1 …`,基线约 `:167`)里,在主列之后放 ``。
+ - `R/components/use-terminal-workspace-store-bindings.ts:64-66`:`useActivityTerminalPortals(activeView === 'activity' || localTaskSessionPanelHasTerminal())`。
+- **状态机**:§4.3;store 在 `R/local-tasks/local-task-session-panel-store.ts`,字段有 `mode`(closed、narrow、wide)、`sessionLinkId`、`taskId`、`requestToken`。
+- **布局**:槽位组件只渲染一个容器节点。窄态是 flex 子元素,宽 420px,把主区推开;加宽态改成 `absolute inset-y-0 right-0`,宽为主区的 2/3,盖在上面。节点本身不换,portal 不重挂。宽度变化后派发 `SYNC_FIT_PANES_EVENT`。
+- **终端会话**:
+ 1. 先 `requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tabId] })`,保证从没激活过的工作区也挂上终端;
+ 2. 再 `setActivityTerminalPortals([{ slotId, requestToken, target, worktreeId, tabId, paneKey, active: true }])`;
+ 3. 关闭时发布 `[]`。
+ - 挂进 portal 的工作区不会被冷停放。
+- **聊天会话**:按 `StructuredAgentSessionPaneOverlayLayer.tsx:36-57` 的取参方式,挂 `NativeChatView mode="structured"`。草稿按会话共享。
+- **防重复**:如果这个会话的标签正是主区里可见的激活标签,就不开浮层,直接聚焦那个标签,避免同一个终端挂两处、出现两个输入框。
+- **全屏**:调现有的 `revealLocalTaskSession`,同时关闭浮层。会话头部的任务 chip(`LocalTaskSessionChipCard.tsx`,现有接缝)加「回到任务」:打开概览,浮层用加宽态显示这个会话。
+- **会话切换**:头部下拉列出这个任务和子任务的会话,切换时更新 `sessionLinkId` 和 `requestToken`。
+- **主机不在联系**:保留上次的内容,显示「未连接」,输入行不显示;不发请求。
+- **与 Activity 页**:`activeView` 变成 `activity` 时浮层关闭,并清空 portal。
+
+#### M9 设置、默认首页、Cmd+J
+
+- **设置**(3a):新增 `settings/LocalTaskWorkflowSettings.tsx`,插在 `LocalTasksSettingsPane.tsx:131` 前。偏好键加进 `LOCAL_TASK_PREFERENCE_KEYS`:
+ - `openTasksOnLaunch`(默认 1)
+ - `taskBrief`(默认 1)
+ - `sessionSend`(`submit` 或 `draft`,默认 `submit`)
+ - `dispatchConfirm`(3c)
+ - `sidebarTaskMode`
+ - 其中 `taskBrief` 和 `sessionSend` 经 `localTasks.status` 暴露。
+- **默认首页**(3a):`R/local-tasks/local-task-startup-page.ts` 挂在 `LocalTasksSyncGate`(`:273-289`)里。`persistedUIReady` 之后,如果偏好开着,调用一次 `openLocalTaskPage()`。偏好要等库读完才知道,为了不闪一下终端视图,同步镜像一份到 localStorage。不改主进程和 `active-view.json`。
+- **发送方式**:开会话对话框(`LocalTaskSessionDialog.tsx:72`)的默认值读 `sessionSend`。开始做走上游弹窗那条路,按前一版方案 §17.6,同步上游时这处接缝要重做成 `localTaskStart`,届时按设置决定提示词是直接发送还是放草稿。
+- **Cmd+J**(3b):在 `local-task-quick-actions.ts:34-62` 加「派活…」,跳到首页并聚焦输入框。
+
+#### M10 手机(3a、3b)
+
+- **首页卡**(3a):`MobileHomeLocalTasksCard.tsx` 和 `local-task-home-attention.ts` 已经读了 `progress` 和 `tasks`,在此基础上加一行最新进展(取 `latestProgress`;旧主机没有时用 `localTaskSessionViews` 的 `lastMessage`)。
+- **详情**(3a):按 M7 的四块精简 `local-task-detail-view.tsx`、`local-task-detail-sections.tsx`、`local-task-work-section.tsx`;去掉完成标准(M5)。
+- **速览抽屉**(3a):新增 `local-task-session-drawer.tsx`,外壳用 `BottomDrawer`。
+ - 聊天会话挂 `MobileNativeChatOverlay`,配 `useMobileNativeChatController`,参照 `use-mobile-session-native-chat-dictation.ts:70` 接好 props 和键盘;
+ - 终端会话走现有的 `openLinkedLocalTaskSession`,进会话页(Q2)。
+- **派活输入框**(3b):新增 `local-task-dispatch-input.tsx`,插在 `local-tasks-library-screen.tsx:262` 之后。
+ - 候选数据:`useLocalTaskWorkspaceCandidates`、`useLocalTaskProjectCatalog`、`library.tasks`;
+ - 执行复用 `startLocalTaskInNewWorkspace` 和 `openLocalTaskSession`;
+ - 发给空闲会话复用 `sendLocalTaskReply`;
+ - 输入框下面写明「由 某桌面 执行」。
+- **页面运行环境**:手机任务页和会话页都在主机下发的 web 包里,桌面更新后跟着更新。
+
+#### M11 派活(3b)
+
+- **候选**:新增 `R/local-tasks/local-task-dispatch-candidates.ts`。
+ - 工作区:把 `listLocalTaskWorkspaceChoices` 拆出不依赖项目的部分,汇总所有项目;
+ - 仓库:`reposForLocalTaskProject` 同样处理;
+ - 任务:`buildLocalTaskPaletteItems`;
+ - 主机:`useSidebarHostScopeOptions().hostOptions`;
+ - 不在联系的条目置灰并写原因(`isLocalTaskHostInContact`);
+ - 工作区属于哪个任务:`findLocalTaskLinkForWorkspace`。
+- **输入框**:新增 `LocalTaskDispatchBox.tsx`。
+ - 用 `findMentionQuery` 找 @ 片段,候选弹层用 `ui/command.tsx`;
+ - chip 里存确定的 id;
+ - @主机只作为筛选标签,不进 chip;
+ - 「派活 | 只问」开关在 3c 才出现,因为「只问」要调度会话。
+- **执行**:新增 `local-task-dispatch-launch.ts`,流程见 §4.1,补充规则如下:
+ - 项目归属:仓库挂在唯一一个项目下就用它;挂在多个项目下,用偏好 `dispatchLastProject` 记住的那个;一个都没有时,卡片先出「为仓库 X 建一个项目?」,确认后调现有的 `projects.create`。
+ - 标题取正文第一句(截 60 字),原句放进描述。
+ - 新建工作区:名称用 `suggestLocalTaskWorkspaceName`,重名时按 `getClientWorktreeCreateCandidate` 重试,写法照手机 `createWorktreeWithNameRetry`。
+ - `agent.launch` 的目标:本机和 SSH 用 `{kind:'local'}` 加 `repoHostId`;配对服务器用 `{kind:'environment'}`。参数照手机 `startLocalTaskInNewWorkspace`(`MO/local-task-agent-launch.ts:132`);`presentation` 为 `background`;`launchSource` 用手机现在用的那个值。
+ - 工作区属于任务 T、T 下有「做完一轮」的会话(`progress.sessions` 里 state 为 done)时:调 `sendLocalTaskReply` 发过去,再调 `events.addComment`,payload 写 `{ dispatch: 'append' }`。卡片上给「改为建子任务另开会话」。
+ - 多个工作区:建父任务,每个工作区一个子任务。这些工作区要在同一台任务库上,否则提示走调度会话(3c)。
+ - 失败:启动失败时任务保留,卡片写原因,给「重试」和「删除任务」;不自动重试。
+- **撤销**:新增 `local-task-dispatch-undo.ts`,toast 写法照 `activity-clear-completed.ts:183-243`,10 秒内有效。
+ - 关会话:标签已经镜像回来时用 `closeUnifiedTab`;聊天还在启动中用 `cancelStructuredAgentLaunch`;还没镜像回来时,按 handle 直接调 `terminal.close` 或 `agentSession.close`。
+ - 删任务:`tasks.remove`。
+ - 主机不在联系时写「某主机未连接,没有撤销」,不排队。
+ - 新建的工作区保留;已经改过的文件不回滚。
+- **时间线**:`created` 事件的 payload 加 `{ via: 'dispatch' }`。旧客户端忽略 payload 里不认识的内容。
+
+#### M12 验收(3c)
+
+- **规则 2 三选一**:沿用 `rule_pr_merged_completes` 列,存 0(不动)、1(已完成)、2(待验收)。旧版只认 `=== 1`,读到 2 就当规则关闭,降级是安全的。
+ - 共享类型新增 `prMergedMode`,同时保留旧的布尔字段给旧客户端。
+ - 要改的地方:`M/local-tasks/local-task-rules.ts:56,72-73`、`local-task-project-store.ts:99`、`local-task-project-mutations.ts:45,85,104,119-125`、`S/` 里的参数和两个 schema、`R/local-tasks/local-task-link-maintenance.ts:122`、`LocalTaskProjectRules.tsx`、手机 `local-task-rules-section.tsx`,以及规则触发的文案。
+- **任务在等你**:新增 `S/local-tasks/local-task-review-items.ts`,按评审类别推导条目。桌面 `buildLocalTaskNeedsYouRows`、手机 `readLocalTaskHomeAttention` 都调它。条目有「速览」(打开写最新进展的那个会话)和「去验收」,没有「稍后提醒」(Q4)。
+- **验收条**:新增 `LocalTaskAcceptanceStrip.tsx`,内容是合入位置、改动、自检(取 latestProgress,来源可以点,点了开浮层)、在哪验收、规则当前的判定。
+- **通过与打回**:新增 `local-task-acceptance.ts`。
+ - 通过:`tasks.update` 改到已完成类别的第一个状态。
+ - 打回:先改到进行中类别的第一个状态,再用 `events.addComment` 记下原因。原因发给每个「做完一轮」的会话(`sendLocalTaskReply`);一个都没有时,用 `launchLocalTaskSession` 开新会话,prompt 是原因加任务说明。
+ - 追问:会话一工作,验收条顶部就显示「某智能体工作中」,任务状态不变。
+- **范围去向**(REQ-023):在 v6 给任务表加 `disposition`、`disposition_reason` 两列,界面在父任务上做汇总。细节等 3c 开工前单独补一页。
+
+#### M13 调度会话与提案(3c)
+
+- **启动**:`launchAgentInNewTab({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, agent, prompt, promptDelivery: 'auto-submit', activate: false })`。prompt 是用户原话加调度说明,调度说明的完整内容由 `orca task guide --dispatcher` 输出。子进程里的 CLI 默认连本桌面,不需要任何配置。
+ - **Claude 固定以终端启动**(2026-11-05,D-015):`launchAgentInNewTab` 走结构化聊天还是终端取决于用户设置(`S/structured-native-chat-launch-route.ts:58` 的 `nativeChatPreferenceFor`)。Claude 的结构化聊天经 Claude Agent SDK,Anthropic 文档规定未经批准的第三方产品不能让它使用 claude.ai 登录与订阅额度,所以调度会话对 Claude 强制走终端路由。对话页照样嵌 `NativeChatView`,它接受终端标签(`terminalTabId`、`paneKey`)。Codex 可以走结构化(`codex app-server` 是 OpenAI 官方接口)。
+ - **常驻、唯一**:同一 profile 只有一个调度会话(下称总管);对话历史里的每段对话都是它的一段,不为每次请求另开会话。会话坏了或被关掉时,按记录的会话号续接(`getAgentResumeArgv`),续接不了再新开,并在第一句里附「先 `orca task inbox` 再继续」。
+- **只问**:同样启动调度会话,但 prompt 里写明只读回答、不提案。
+- **提案**:
+ - `orca task propose --body-file 提案.json` 调 `localTasks.proposals.create`。**对话标识按面板映射**(2026-11-05 修订):`ORCA_AGENT_SESSION_ID` 只注入结构化会话(`M/runtime/structured-session-child-env.ts:69`),终端里没有;CLI 改为传 `ORCA_PANE_KEY`,主机按启动总管时记在 `local_task_conversations` 里的面板找到对话。结构化会话也有面板标识,两种形态走同一条路。
+ - 写入 v6 的 `local_task_proposals` 表,推送用 `scopes: null`。
+ - 对话页(`LocalTaskDispatcherPage.tsx`)一边嵌 `NativeChatView`,一边渲染提案卡。
+ - 用户确认后,由 M11 的执行模块逐项执行,不再经过调度会话。
+- **对话历史**:v6 的 `local_task_conversations` 表,主键是调度会话的会话 id,按天分组。
+- **机器**:调度会话只在桌面上开。局域网网页客户端没有调度会话(上游不允许它在浮动工作区开会话)。手机上可以看提案、点确认;手机发起调度要新增 `proposals.*` 以外的启动方法,3c 开工前再定。
+
+```ts
+// S/local-tasks/local-tasks-rpc-params.ts(3c)
+export const LocalTasksProposalItem = z.object({
+ // [新增]
+ title: z.string().trim().min(1).max(200),
+ target: z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('worktree'), hostId: id, worktreeId: id }),
+ z.object({ kind: z.literal('repo'), hostId: id, repoId: id }),
+ z.object({ kind: z.literal('task'), taskId: id })
+ ]),
+ agent: z.string().min(1).max(64),
+ prompt: z.string().trim().min(1).max(8000)
+})
+
+export const LocalTasksProposalsCreateParams = z.object({
+ // [新增]
+ clientOperationId,
+ conversationId: id, // 调度会话的会话 id
+ summary: z.string().trim().max(2000).optional(),
+ items: z.array(LocalTasksProposalItem).min(1).max(20)
+})
+
+export const LocalTasksProposalsListParams = z.object({ conversationId: id.optional() }) // [新增]
+
+export const LocalTasksProposalsDecideParams = z.object({
+ // [新增]
+ clientOperationId,
+ proposalId: id,
+ decision: z.enum(['confirmed', 'dismissed']),
+ keepItemIndexes: z.array(z.number().int().nonnegative()).max(20).optional() // 用户去掉了几项
+})
+```
+
+- **影响落点**:
+ - 方法:`M/runtime/rpc/methods/local-tasks.ts`;
+ - 参数目录:重新生成 `rpc-params-catalog.generated.ts`;
+ - 手机允许名单:加 `proposals.list` 和 `proposals.decide`;
+ - CLI `task propose`;
+ - 桌面对话页。
+- 「决定」只记状态;真正的执行由客户端调 M11 的模块完成,主机不代为执行。
+
+**调度会话即总管:分配、管理、感知**(2026-11-05 增补,D-015)
+
+调度会话在 3c 就是一个常驻的总管:它分配任务与会话、管理它们、感知它们的变化。它只用 CLI,读不需要确认;凡是新建或驱动,都走提案,确认后由 M11 执行(原则 2)。完整的接口与理由见本节,产品口径见产品方案 §16。
+
+- **提案项类型**:在 `LocalTasksProposalItem` 上加 `kind`。没有 `kind` 的旧提案按 `start` 处理;`stop` 永远要确认,「直接执行」也不跳过。
+
+```ts
+// S/local-tasks/local-tasks-rpc-params.ts(3c)
+export const LocalTasksProposalItem = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('start'), title, target, agent, prompt }), // 建任务、绑定、开会话(原有)
+ z.object({ kind: z.literal('subtask'), parentTaskId: id, title, agent, prompt }), // 子任务,会话开在同一工作区
+ z.object({ kind: z.literal('append'), taskId: id, sessionLinkId: id, text: prompt }), // 发给做完一轮的会话
+ z.object({ kind: z.literal('stop'), taskId: id, sessionLinkId: id, reason: z.string().max(500) }), // 停会话
+ z.object({ kind: z.literal('link'), taskId: id, target: worktreeTarget }), // 把已有工作区挂到任务
+ z.object({ kind: z.literal('record'), title, target: worktreeTarget }) // 把没挂任务的会话记成任务
+])
+```
+
+- **提案的执行**:执行逐项复用 M11。
+ - `start`、`subtask` → `local-task-dispatch-launch.ts`;
+ - `append` → `sendLocalTaskReply`,只发给做完一轮的会话;
+ - `stop` → 关标签,或按 handle 调 `terminal.close` / `agentSession.close`;
+ - `link` → `links.linkWorkspace`;
+ - `record` → `tasks.create` + `links.linkWorkspace`。
+
+ 每项的结果写回提案行,对应任务的时间线记一条「由总管提案、你确认」。
+
+- **盯着的任务**:
+ - 总管提案建出的任务确认执行后自动加入;
+ - 用户说「帮我盯着 ORC-39」时,总管用 `task watch` 加入;
+ - 任务进入已完成或已取消类别后自动移出。
+ - v6 表 `local_task_conversation_watches(conversation_id, task_id, added_at, added_by)` 存桌面库自己的任务。
+ - **其他库的任务**(2026-11-05 修订,实现与原设想不同):SSH 主机、配对服务器上的任务记在桌面库的 v7 表 `local_task_conversation_remote_watches`,键是客户端 id `runtime:{环境}/{任务 id}`;那些库不知道总管的存在。
+ - 原设想是把盯着的记录和游标存进任务所在的库。没这么做,因为:那要给每台主机加总管接口,旧版主机用不了;收件箱要逐库读、逐库存游标;而桌面渲染层本来就按主机联系状态同步每个库的事件(`createRuntimeClientEventsSync`)。
+ - 转发:渲染层的 `local-task-dispatcher-forwarding.ts` 只读已同步的数据,把盯着的远端任务的新事件按收件箱同一套文案转成「转发的动态」,经桌面接口 `localTasks.dispatcher.recordFacts` 写进 v7 表 `local_task_conversation_facts`(按来源 id 去重)。它从不自己去问主机;主机不在联系时什么也不转发。
+ - 主机失去联系、恢复联系时各记一条 `host` 动态(「无法确认」/「恢复联系」)。只在这个库同步过之后才判断,没打开过的库不算失联。
+ - 收件箱把转发的动态并进同一份结果,游标多一项 `factRowid`;确认已读后,最新一条已结束且已读的远端任务自动移出。
+- **收件箱**:不新增事件表。收件箱就是盯着的任务上 `events.id > 游标` 的事件,加上需要你判定的变化(按判定键去重)。事件包括:
+ - 带 progress 标记的评论(M2);
+ - `turn-ended`(M2);
+ - 状态变化(进入评审类别即待验收);
+ - 会话结束;
+ - 提案被确认或去掉。
+
+ 游标是 `local_task_conversations.inbox_cursor`,只有桌面库这一份。RPC:
+ - `localTasks.dispatcher.inbox { conversationId, cursor?, limit }` → `{ items: [{ at, taskRef, kind, summary, sessionLinkId?, needsYou? }], cursor }`;
+ - `localTasks.dispatcher.watch` / `unwatch { conversationId, taskId }`。
+
+ `task inbox` 读桌面库就拿到全部动态(其他库的由渲染层转发进来);不在联系的库以 `host` 动态标「无法确认」,恢复联系后由那次联系带来的同步继续转发,不定时重试。
+
+- **唤醒**(`R/local-tasks/local-task-dispatcher-wake.ts`):
+ - 桌面渲染层本来就订阅各库的 `localTasks.subscribeChanges`;
+ - 盯着的任务有变化时,2 秒内合并,**只在总管是「做完一轮」时**写一行指针,例如「有 3 条任务动态:ORC-39 回写了进展,ORC-41 待验收。运行 `orca task inbox --json`」;
+ - 终端会话走现有的终端发送,结构化会话走 `agentSession.send`;
+ - 每个空闲期最多一次;总管在工作、等批准、等回答,或用户正在它的输入框里打字时都不写;
+ - 设置「有动态时叫醒总管」,默认开。
+ - 语义照编排的指针投递(只在空闲时、最多一次),但不依赖编排:这条工作流不经编排,总管也不是编排协调者。
+- **会话怎么看**:会话不单独开接口,一律挂在任务上看。
+ - `task show --json` 的会话段给出智能体、终端或聊天、状态、判定(`live` / `unverifiable` / `exited`)、最后一句话、在等你什么。
+ - 没挂任务的会话用现有的 `orca worktree ps --json`。
+ - 用户问「卡在哪」时才用 `orca terminal read --tail 30`。
+ - 总管不读会话的终端过程:省 token,也避免「会话说了什么」与「任务记的是什么」对不上。
+- **不提供**:替用户批准权限请求、替用户回答会话的提问;这些由用户在速览里处理(§5.4 产品方案)。
+- **只问遇到要读代码的问题**:能用 Orca 的记录回答的由总管答;要读代码的,提一份只读调查任务(`start`,prompt 写明只读)。总管在浮动伪工作区里,读不到 SSH 主机上的仓库,也不应在工作区里动手。
+- **工作说明**(`orca task guide --dispatcher` 的要点):
+ 1. 你理解、分配、跟进、汇报;不写代码,不在工作区里动手。
+ 2. 被问到任务和进度,先查记录(`task list` / `show` / `inbox`),不凭对话记忆;回答写明记录时间。
+ 3. 新建与驱动一律 `task propose`,一件事的几步放进一份提案。
+ 4. 完成由人点,你最多推进到待验收。
+ 5. `unverifiable` 不是失败也不是结束;主机连不上时如实说,不重试。
+ 6. 被唤醒后先 `task inbox --json`,只汇报需要用户知道或决定的,最后 `--ack`。
+ 7. 不等待、不 sleep、不轮询;没有事就结束本轮。
+- **能力位**:除 `local-tasks.v2` 外再通告 `local-tasks.dispatcher-inbox`。不认识新提案项类型的旧库拒绝整份提案,而不是只执行认识的部分。
+
+#### M14 生成摘要等(3d)
+
+- **生成摘要**:新增 `M/local-tasks/local-task-summary-generation.ts`,照 Jira 摘要的写法。
+ - 设置借用现有的 `'branchName'` 键,不新增设置键;
+ - 结构化会话读 `journalSnapshot`,终端读 `terminal.read`;
+ - 结果写成一条 progress 评论,payload 标 `generated: true`;
+ - 唯一的上游接缝:`M/text-generation/source-control-text-generation-types.ts` 的 operation 联合加一个值,避免和别的生成请求共用同一条 lane,互相取消。
+- 截图附件与随手记(REQ-020)、设为目标、定时派活:3d 开工前单独写方案。
+
+### 5.7 实施工作包
+
+| 顺序 | 工作包 | 内容 | 前置 |
+| ---- | ----------------------- | -------------------------------------------------------------- | ------------------- |
+| 0 | 同步上游 | `pnpm sync:upstream`,核对本文 4 个接缝、§17.6 的待办 | 用户同意合并 PR #76 |
+| 1 | 开工前验证 | §6.3 的 1~6,证据放 `.docs/local-tasks-ui-validation/{日期}/` | 0 |
+| 2 | 3a-1 主机与 CLI | M1~M5(不含界面) | 1 的验证 1、2 |
+| 3 | 3a-2 概览与浮层 | M7、M8、M9 | 1 的验证 3、4 |
+| 4 | 3a-3 侧栏任务模式与首页 | M6、M9 首页 | 3 |
+| 5 | 3a-4 手机 | M10 的 3a 部分 | 2 |
+| 6 | 3b | M11、M9 Cmd+J、M10 的派活部分 | 1 的验证 5 |
+| 7 | 3c | M12、M13 | 3b |
+| 8 | 3d | M14 | 3c |
+
+每个工作包一个 PR,标题 `feat(local-tasks): …`,合入 `fork/integration`。每个 PR 都要更新 `config/fork-features.jsonc`(接缝的 `mustContain`、`dependsOn`、tests)和 `config/architecture-policies.jsonc` 的允许名单,并在 journal 加开发记录。
+
+## 6. 监控、风险与测试
+
+### 6.1 观测点
+
+本功能没有遥测,靠这几处判断是否正常:
+
+- **任务时间线**:派出、追加指令、回写、轮次结束(不显示在时间线上,可在开发工具里看事件列表)、验收、撤销,每一步都有一条事件,记下来源。
+- **变更推送**:每次写入后,`subscribeChanges` 带出的 scope 应该是 `events`、`tasks`;提案的变化是 `null`。
+- **验证脚本计数**:每轮验证脚本输出「派活 N 次、回写 N 次、轮次结束 N 次、远端读请求 N 次」。主机断开期间远端读请求必须是 0(REQ-028 验收)。
+- **异常信号**:
+ - CLI 被拒(已完成、没有任务库);
+ - `agent.launch` 失败的原因;
+ - 撤销时「主机未连接」;
+ - 浮层发布 portal 后终端没出现,开工前验证 3 要覆盖这一项。
+
+### 6.2 风险
+
+| 风险 | 等级 | 应对 |
+| ---------------------------------------------------- | ------ | ----------------------------------------------------------------------------------- |
+| 落后上游 3302 个提交,接缝文件已经变了 | 高 | 工作包 0 先同步;同步后重新核对 4 个接缝和 §17.6 的待办;`mustContain` 按新代码更新 |
+| SSH 会话里回写不了 | 中 | 验证 1;不行就走 Q5 的降级(轮次结束记录) |
+| portal 在 `tasks` 视图下拿不到从没激活过的工作区终端 | 中 | 先后台挂载;验证 3 不通过时,浮层里的终端会话退回成「全屏」行为 |
+| 浮层和 Activity 页共用 portal 列表 | 低 | 视图互斥;切到 Activity 时关闭浮层并清空 |
+| 同一个聊天在主区和浮层各有一个输入框 | 低 | 防重复规则(M8);草稿按会话共享 |
+| i18n 两个文件上游改动很频繁(en 1140 次、zh 708 次) | 中 | 只动 `localTasks` 子树;冲突在块内时按键合并 |
+| 删列会让旧版只读打开时报错 | 已规避 | 不删列(M5) |
+| 新 scope 或新 failure code 会让旧客户端解析失败 | 已规避 | 不新增;提案用 `scopes: null` |
+| 进展评论被误编辑 | 低 | 主机拒绝编辑或删除带 progress 标记的评论 |
+| 派活在主机断开时开不出会话,任务却已经建了 | 低 | 派活前先检查在联系;启动失败时卡片给「重试」和「删除任务」 |
+
+### 6.3 开工前验证
+
+用一个探针脚本,在本机、SSH(root 的 `127.0.0.1:2222` sshd,见 `docs/reference/ssh-real-app-validation.md`)、配对服务器三种场景各跑一遍,隔离实例用 `ORCA_BACKGROUND_LAUNCH=1`。智能体只用桩,按 e2e 实例里 HOME 的约定隔离。
+
+| # | 要验证的 | 不成立时 |
+| --- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
+| 1 | 桌面库任务开出的 SSH 会话里,`orca task show 编号` 能不能找到任务,`orca task progress` 能不能写进桌面库 | Q5 降级:SSH 工作区只靠轮次结束记录 |
+| 2 | 三种主机上,done 那一行带 `lastAssistantMessage`(Claude、Codex),用真实采集的状态样本 | 这类主机的会话卡片只显示状态,不显示最后一句话 |
+| 3 | 从 `tasks` 视图,把从没激活过的工作区终端挂进浮层;能输入;窄、宽切换不重挂;三种主机都试 | 浮层里的终端会话改为直接「全屏」 |
+| 4 | `NativeChatView` 在标签组外挂载(本机、配对服务器的聊天),能发送、能看到新消息 | 浮层里的聊天会话改为直接「全屏」 |
+| 5 | 桌面渲染层调 `agent.launch`(已有工作区、新建工作区,三种主机),返回 `sessionId` 或 `paneKey` | 3b 改走 `launchLocalTaskSession`,加 `prompt` 和 `activate` 参数 |
+| 6 | 旧客户端遇到 turn-ended 静默丢掉(补一个 shared schema 单测) | 不会不成立(代码已确认),单测是为了防回退 |
+| 7 | 浮动伪工作区里以终端启动 Claude 作为调度会话,对话页的 `NativeChatView` 能正常显示与输入(3c) | 对话页改为直接显示终端 |
+| 8 | 从浮动伪工作区的终端里调 `orca task`,能连到桌面库,并能用 `--host` 读写 orcad 主机的库(3c) | 总管只管桌面库里的任务,其他库只读 |
+| 9 | 向空闲的调度会话终端写一行指针:用户正在终端里打半行字时的表现、聊天视图下的显示(3c) | 唤醒改为对话页上的「有 N 条新动态」按钮,由用户点 |
+| 10 | 跨库收件箱:orcad 主机上的库在失联与恢复时游标与去重正确(3c) | 收件箱只覆盖桌面库,其他库的动态只在首页显示 |
+| 11 | 旧库收到带未知 `kind` 的提案时整份拒绝(3c) | 提案只用 `start`,其他意图改为文字建议 |
+
+### 6.4 自测
+
+| 场景 | 操作 | 期望 |
+| -------------- | --------------------------------------------------------- | ------------------------------------------------------------------------ |
+| 回写 | 桩智能体运行 `orca task progress ORC-41 --body-file p.md` | 任务卡和现在卡显示这条进展,来源是那个会话;时间线里是一条不能编辑的进展 |
+| 轮次结束跨重启 | 桩会话做完一轮后重启 Orca | 卡片仍显示最后一句话 |
+| CLI 拒绝完成 | `orca task status ORC-41 已完成` | 报错写明可用的状态和「完成要在界面上点」;任务状态不变 |
+| current | 在子任务的会话里运行 `orca task current` | 返回子任务,不是父任务 |
+| 去掉完成标准 | 用带 criteria 的旧库打开 | 描述末尾多一个「完成标准」小节;再打开不会重复追加 |
+| 浮层 | 速览 → 加宽 → 收窄 → 全屏 → 回到任务 | 终端内容一直连续;全屏时重挂一次;验收按钮在窄态下不被遮挡 |
+| 侧栏任务模式 | 切换、展开、点视图、点会话、Cmd+J 选一个工作区 | 主区跟着筛选;最后一步自动退出任务模式并定位到那个工作区 |
+| 派活三种分岔 | 空闲工作区;正忙的工作区;有做完一轮会话的工作区 | 分别是建任务开会话、建子任务另开会话、发给那个会话 |
+| 撤销 | 派活后 10 秒内撤销;断开主机后撤销 | 任务删除、会话停止;断开时写明「没有撤销」 |
+| SSH | 上面各项在 SSH 工作区上再跑一遍;断开后看浮层和卡片 | 显示「未连接」;断开期间远端读请求为 0;恢复后只重读一次 |
+| 手机 | 首页卡、聊天抽屉回答、终端点开进会话页、派活 | 和桌面一致 |
+| 混合版本 | 新桌面连旧的配对服务器 | 没有 latestProgress 时退回显示最后一句话;不出现规则三选一 |
+
+### 6.5 回归
+
+- 切片 1、2 的测试用例:`tests/cases/本地任务管理功能测试.md`(TC-001~TC-039、TC-101~TC-113)。
+- 上游能力:
+ - Activity 页的终端 portal;
+ - 侧栏的工作区和 agents 两种主体,以及定位请求;
+ - 右侧栏;
+ - 浮动终端;
+ - 新建工作区弹窗;
+ - 结构化聊天的发送与关闭;
+ - Cmd+J;
+ - 设置。
+- 门禁:`pnpm tc`、local-tasks 登记的 checks、三个本地化门禁、`pnpm check:architecture-policies -- --base Fartown/main`、`pnpm check:fork-features`、`pnpm check:fork-docs`,以及 pr.yml 的静态分析和 mobile.yml 的类型检查 ratchet。
+
+## 7. 附录与引用
+
+- 前一版技术方案 §1~§17:[本地任务管理技术方案](本地任务管理技术方案.md)。
+- 产品方案、评审、需求、journal:见文首。
+- 交互原型:[interaction/prototype-task-first](../interaction/prototype-task-first/README.md);截图脚本和点击测试在本机 `.docs/本地任务管理调研/2026-11-01/scripts/`。
+- Multica 调研:本机 `.docs/本地任务管理调研/2026-10-30-multica/README.md`。
+- SSH 真机验证方法:`docs/reference/ssh-real-app-validation.md`。
+
+## 8. 变更记录
+
+| 时间 | 变更原因 | 变更内容 | 影响范围 | 记录人 |
+| ---------- | -------------------------------- | -------- | ----------- | ------ |
+| 2026-11-03 | 用户要求为切片 3a~3d 写技术方案 | 初稿 | 切片 3a~3d | Claude |
+
+### 2026-11-05 实施范围与基线
+
+本轮按 Journal D-016 先实现 M1~M12 中的普通任务功能,总管、提案、只问和唤醒不在范围。实施分支从 fork/integration 切出,复用已解决的 #76/#81 合并增量与 7dbee81530 的回写基础;前文“落后 3302 提交”的状态是历史基线,不能作为当前开工阻塞。新增手机抽屉复用现有 session controller 时需要三个可选显式 scope 接缝,原路由默认不变,并按真实组件闭包补齐本地任务 route 的 native grants。测试范围见 [任务为主的工作流测试](../tests/cases/任务为主的工作流测试.md),执行证据位于本机 `.docs/local-tasks-ui-validation/2026-11-05/task-first/`。
+
+## 10. 2026-11-06 全量合并实施基线
+
+按 D-017 合并完整 v7 总管与普通任务界面,保留直接派活和确认提案两种入口;旧 UI 分支的 v5 与暂缓总管说明只描述 2026-11-05 的候选。核心合并必须保留五张总管表及 WSL 迁移登记、CLI 完成限制、claim/回执幂等和跨库断联边界;任务首页启动与总管绑定同时生效。审查、修复和验证结果见本轮开发记录及 `.docs/local-tasks-ui-validation/2026-11-06/consolidation/`。
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/tests/cases/\344\273\273\345\212\241\344\270\272\344\270\273\347\232\204\345\267\245\344\275\234\346\265\201\346\265\213\350\257\225.md" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/tests/cases/\344\273\273\345\212\241\344\270\272\344\270\273\347\232\204\345\267\245\344\275\234\346\265\201\346\265\213\350\257\225.md"
new file mode 100644
index 000000000000..1a7087f9ab65
--- /dev/null
+++ "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/tests/cases/\344\273\273\345\212\241\344\270\272\344\270\273\347\232\204\345\267\245\344\275\234\346\265\201\346\265\213\350\257\225.md"
@@ -0,0 +1,226 @@
+---
+title: 任务为主的工作流测试
+document_type: test-case-list
+status: ready
+created: 2026-11-05
+updated: 2026-11-06
+---
+
+# 任务为主的工作流测试
+
+- 需求:[本地任务管理](../../requirements/本地任务管理.md) REQ-004、005、007、009~019、022、024、026~031。
+- 方案:[任务为主的工作流技术方案](../../solutions/任务为主的工作流技术方案.md) M1~M13;统一分支范围见 Journal D-017。
+- 场景:桌面本机、独立 HOME 的真实 SSH 主机、配对手机;测试实例均后台运行。结果、截图、候选构建与原始日志写到 `.docs/local-tasks-ui-validation/2026-11-06/consolidation/`。
+- Q1/Q2 按用户本轮确认的技术方案实现,区别于原型的标签栏及手机终端抽屉。
+
+## 覆盖矩阵
+
+| 需求 | 用例 | 本轮边界 |
+| --------------------------- | -------------- | ---------------------------- |
+| REQ-004、005、012、022、027 | TC-301、TC-302 | 概览、进展、旧数据 |
+| REQ-007、016、026 | TC-303 | 侧栏与定位 |
+| REQ-009、010、015、017、028 | TC-304、TC-305 | 会话浮层与手机抽屉 |
+| REQ-013、014、024 | TC-306 | 待验收、合入规则 |
+| REQ-007、029 | TC-307、TC-308 | 明确目标直接派活及撤销 |
+| REQ-018、019、022 | TC-302、TC-309 | 主机归属、兼容、CLI |
+| REQ-030、031 | TC-310 | 总管提案、台账与感知 |
+| REQ-030 | TC-311 | 认领连接、逐项结果与中断收尾 |
+
+## 用例
+
+### TC-311 提案运行窗口退出后的人工收尾
+
+- 关联需求:REQ-030
+- 优先级:P1
+- 分级依据:跨窗口提前结案会丢失已发生的副作用及结果,重复执行可能造成重复任务或会话。
+- 预期来源:2026-11-06 用户确认的 D-018,Requirements REQ-030 并发收尾规则。
+- 输入实例:桌面窗口退出、桌面导航重载、服务重启、配对 socket 退休;提案目标分别为本机与 SSH 主机。
+
+```mermaid
+flowchart LR
+ A["前置:A、B 两个独立可信连接;提案有两项;第一项可完成,第二项可暂停"] --> B["1. A 确认并认领;第一项完成
1.1 结果持久化后才开始第二项;B 看到执行中,不能人工收尾"]
+ B --> C["2. B 直接请求人工收尾或冒写结果
2.1 服务端拒绝;A 的认领、结果和提案状态保留"]
+ C --> D["3. 让 A 的连接退休或重启服务
3.1 B 可人工收尾;现有结果保留,不重新执行;A 迟到结果不能覆盖已知结果"]
+ D --> E["4. 丢失第一项结果保存的确认
4.1 执行端不跑后项、不重复已执行项;未确认在途副作用不宣称未发生"]
+ E --> F["5. SSH 执行主机断联但 A 窗口仍在
5.1 不因主机失联释放窗口认领或声称进程退出;无主机确认的停止结果为 unverifiable"]
+```
+
+### TC-301 任务概览与进展汇总
+
+- 关联需求:REQ-004, REQ-005, REQ-012, REQ-022, REQ-027
+- 优先级:P1
+- 分级依据:任务当前结果及会话入口是主路径。
+- 预期来源:Requirements REQ-022、D-013、D-014。
+
+```mermaid
+flowchart LR
+ A["前置:父、子、孙任务;两条不同时刻的进展"] --> B["1. 打开列表、看板和父任务概览
1.1 看到子孙中最新进展及来源任务"]
+ B --> C["2. 编辑标题和描述;展开属性
2.1 保存按 revision 检查;空属性不挤占头部"]
+ C --> D["3. 查看动态
3.1 系统记录默认折叠;回写不能编辑;轮次记录不进入评论流"]
+```
+
+### TC-302 旧库折叠与轮次结束重启
+
+- 关联需求:REQ-004, REQ-018, REQ-022
+- 优先级:P1
+- 分级依据:升级必须保留既有内容且不能重复迁移。
+- 预期来源:D-014、技术方案 M2/M5。
+
+```mermaid
+flowchart LR
+ A["前置:schema v5 旧库含完成标准;挂接一个真实会话"] --> B["1. 打开两次库
1.1 标准仅折入描述一次;完整功能迁到 schema v7;旧 RPC 标准可替换和清空且不丢正文"]
+ B --> C["2. 会话完成一轮并刷新
2.1 同一 attention key 仅记录一次;同会话保留最新记录"]
+ C --> D["3. 重开库并读取
3.1 末句保留;只读查询不产生新写入"]
+```
+
+### TC-303 侧栏任务模式与工作区定位
+
+- 关联需求:REQ-007, REQ-016, REQ-026
+- 优先级:P1
+- 分级依据:任务和原工作区导航必须能往返。
+- 预期来源:REQ-026、技术方案 M6。
+
+```mermaid
+flowchart LR
+ A["前置:有无工作区的任务、深层子任务、未关联会话"] --> B["1. 切换任务模式,展开项目和任务
1.1 任务与会话均可见;方向键、Enter、Tab 可操作"]
+ B --> C["2. 点任务、会话、视图
2.1 分别进入概览、概览加浮层和正确筛选"]
+ C --> D["3. 定位一个工作区
3.1 退出任务主体并重放定位;原工作区列表仍可用"]
+```
+
+### TC-304 桌面会话速览、输入和已读
+
+- 关联需求:REQ-010, REQ-014, REQ-015, REQ-028
+- 优先级:P1
+- 分级依据:重复挂载或过早已读会丢失操作上下文。
+- 预期来源:D-013、D-014、原已读规则。
+- 后台终端回归前置:用当前构建 `terminal.create(presentation:background, activate:false, focus:false)` 新建真实终端,记录任务会话;先确认主端 session 与磁盘有该 tab/pane,当前窗口可尚无 tab。点击速览后须通过原 PTY 可输入;宽窄切换同实例,全屏与返回保留 tab、PTY、incarnation 和内容。对照主端/窗口布局模式、renderer tab/layout/cache 与 IPC 时间线;不得先调用导航型 `terminal.reveal` 补前置。失联不读取或补建,已删除的 pane 不得重新创建进程,迟到回包不能确认替换后的 incarnation。
+
+```mermaid
+flowchart LR
+ A["前置:本机和 SSH 各有终端;本机有聊天会话"] --> B["1. 从概览打开窄速览并输入
1.1 使用真实终端或聊天通道,可得到响应"]
+ B --> C["2. 加宽、收窄、全屏、回到任务
2.1 宽度切换保留同一实例;全屏走原会话标签"]
+ C --> D["3. 断开主机或遮盖面板
3.1 内容保留且不可写;不可见时不确认已读;真实可见后至多确认一次"]
+```
+
+### TC-305 手机聊天抽屉与终端导航
+
+- 关联需求:REQ-017, REQ-028
+- 优先级:P1
+- 分级依据:手机必须从任务进入对应会话并能回答。
+- 预期来源:REQ-017、技术方案 M10/Q2。
+
+```mermaid
+flowchart LR
+ A["前置:手机配对本候选,任务含聊天和终端"] --> B["1. 打开任务和聊天会话
1.1 底部抽屉显示正确工作区和聊天,可发送并收到结果"]
+ B --> C["2. 关闭抽屉再打开终端
2.1 返回任务保持上下文;终端进入原会话页"]
+ C --> D["3. 主机失联或抽屉不可见
3.1 不自动重连或读远端;不误确认已读;草稿保留"]
+```
+
+### TC-306 待验收与合入规则
+
+- 关联需求:REQ-013, REQ-014, REQ-024
+- 优先级:P1
+- 分级依据:任务完成必须由用户确认,反馈不能丢。
+- 预期来源:2026-11-01 已确认规则默认值、REQ-024、技术方案 M12。
+
+```mermaid
+flowchart LR
+ A["前置:旧默认项目、关闭规则项目;一个 review 类别任务"] --> B["1. 升级并重开,随后手动选合入即完成
1.1 旧默认仅迁移一次到待验收;关闭保留;用户改回后不重置"]
+ B --> C["2. 合入匹配 workspace head 的 PR
2.1 按 none、review、completed 三态执行;同一 PR 不重复触发"]
+ C --> D["3. 用户通过或填写打回原因
3.1 通过进入已完成;打回进入进行中并保存原因、发送至空闲会话;无会话则带原因开新会话"]
+```
+
+### TC-307 明确目标的直接派活
+
+- 关联需求:REQ-007, REQ-029
+- 优先级:P1
+- 分级依据:目标或任务归属错误会把工作发到错误位置。
+- 预期来源:REQ-029、技术方案 M11。
+
+```mermaid
+flowchart LR
+ A["前置:本机、SSH、配对主机,Git 和文件夹工作区"] --> B["1. 输入正文并选择 @工作区、仓库、任务或主机
1.1 主机与仓库筛选正确;失联目标置灰;文件夹仅选已有工作区"]
+ B --> C["2. 向空闲、正在工作、有做完一轮会话的目标派活
2.1 分别新建任务开会话、子任务另开、续发原会话;任务说明按偏好注入"]
+ C --> D["3. 仓库无项目或启动失败
3.1 先确认建项目;失败保留任务与正文,未知结果要求检查后再重试"]
+```
+
+### TC-308 派活撤销与不确定结果
+
+- 关联需求:REQ-029
+- 优先级:P1
+- 分级依据:撤销不能删除仍在执行或不属于本次派活的任务。
+- 预期来源:REQ-029、技术方案 M11。
+
+```mermaid
+flowchart LR
+ A["前置:已确认启动的单项或同库多工作区派活"] --> B["1. 十秒内撤销
1.1 先在执行主机确认全部本次会话停止,再删除本次任务;工作区和文件保留"]
+ B --> C["2. 失联、关闭拒绝或关闭结果未知时撤销
2.1 不删除任务,不排队,不报告已撤销"]
+ C --> D["3. 超过十秒或启动结果未知
3.1 不提供误导性的可撤销成功提示"]
+```
+
+### TC-309 CLI 与混合版本
+
+- 关联需求:REQ-018, REQ-019, REQ-022
+- 优先级:P1
+- 分级依据:智能体必须找到自己的任务且不能替人宣告完成。
+- 预期来源:REQ-019/022、技术方案 M3/M4。
+
+```mermaid
+flowchart LR
+ A["前置:终端、聊天各挂子任务;新旧版本主机"] --> B["1. 运行 current、create、update、link 和 progress
1.1 优先按 pane、session、tab 归属定位;正文文件与 stdin 可读"]
+ B --> C["2. CLI 尝试进入 completed
2.1 CLI 与本地主机均拒绝;界面仍可完成"]
+ C --> D["3. 新旧客户端交叉读取
3.1 v1 与 v2 并存;未知轮次事件被忽略;进展降级普通评论;不新增 failure code 或 change scope"]
+```
+
+### TC-310 总管提案与台账
+
+- 关联需求:REQ-030, REQ-031
+- 优先级:P1
+- 分级依据:人工确认和单次执行防止重复副作用。
+- 预期来源:Requirements REQ-030/031、Journal D-015/D-017。
+
+| ID | 前置 | 步骤 | 预期 | 预期来源 |
+| -------- | ----------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------- | --------------- |
+| TC-310.1 | 本机任务库、可用工作区 | 打开总管;生成 record/link 提案;取消一条、确认另一条 | 未确认不执行;取消不建任务;确认只运行保留项,结果进入台账 | REQ-030 |
+| TC-310.2 | 两窗口打开同一提案 | 两边尝试确认/认领 | 仅一个执行者成功,不重复建任务 | REQ-030 |
+| TC-310.3 | 本机与 SSH 库各有任务 | 总管 watch,写进展并读取 inbox/ack;SSH 断开再恢复 | 引用带主机命名空间;断开无功能自重连/读取;恢复跟随共享连接层 | REQ-018/031 |
+| TC-310.4 | 总管空闲且有未读事实 | 右键粘贴、拖放或 IME 留草稿后离焦;再触发事实 | 有草稿不自动粘贴和提交;空闲空输入时每轮至多一个唤醒指针 | REQ-031 |
+| TC-310.5 | 本机或 SSH 任务有终端会话 | 确认 stop;模拟主机关闭失败/未知 | 仅执行主机确认 PTY 结束才记成功;失联为 unverifiable,不宣称停止 | REQ-018/030 |
+| TC-310.6 | 库含旧标准A、超长合法工作区ID | legacy update B→清空;重开库;提案使用200字符ID | 标准精确替换/清空不损正文,重开幂等;提案允许4096内ID | REQ-004/018/030 |
+
+人工收尾与活动执行者的并发规则已由用户确认,见 D-018 与 TC-311;活动连接、逐项持久化和连接退休后的保留结果必须分别验收。
+
+## 原型还原补充用例(2026-11-10)
+
+原型指 `interaction/prototype-task-first/` 第 6 轮,用户在 2026-11-09 要求按它还原(含总管 d15~d19、m9)。预期来源写到需求编号和原型画面。
+
+| ID | 前置 | 步骤 | 预期 | 预期来源 |
+| -------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- |
+| TC-312.1 | 一个会话等你回答、一个做完一轮未看、一个父任务待验收含 2 子任务 | 打开任务首页「全部」 | 列表顶部「需要你」分「会话在等你」「待验收」两组;这些任务从下方状态组移出,组头写「另有 N 个在『需要你』」;已完成默认折叠 | REQ-014、REQ-024、d1 |
+| TC-312.2 | 同上 | 读导航「需要你」数字、侧栏「任务」徽标、首页分组头数字 | 三处相等,等于会话在等你行数加待验收行数,待验收任务的子任务不重复计 | REQ-014、d1 |
+| TC-312.3 | 总管有 1 个提案等你确认 | 看首页「需要你」;点提示行 | 出现「总管有 1 个提案等你确认」,不计入分组数字;点击进入总管页 | REQ-030、d16 |
+| TC-312.4 | 会话用 AskUserQuestion 提问 | 看该行第二行 | 写「智能体 问:问题原文」 | REQ-014、d1 |
+| TC-313.1 | 本机有空闲工作区、属于任务的工作区、无工作区的任务 | 派活框输入 @ | 按工作区、仓库、任务、主机分组;写仓库·主机;标「空闲 / 属于 编号」;任务行写状态、标题、编号、工作区·主机;无工作区写「还没有工作区」 | REQ-029、d2 |
+| TC-313.2 | 同上 | 选空闲工作区,输入一句话,回车 | 在工作区所属项目建任务(标题取首句),关联该工作区,用默认智能体开会话,第一句是正文加任务说明 | REQ-029 |
+| TC-313.3 | 同上 | 切「只问」,不 @,输入问题回车 | 打开(或复用)总管,把问题加只读前缀发给它;不建任务 | REQ-029、REQ-030 |
+| TC-314.1 | 工作区 W 属于 ORC-41 | 新建任务,在哪做选已有工作区 W,开工选默认智能体,创建并开工 | 新任务是 ORC-41 的子任务;不重复关联 W;会话在 W 中后台开起;停在任务视图 | REQ-027、REQ-011、d5 |
+| TC-314.2 | 同上 | 选 W 后切到「新建工作区」再切到「稍后再定」,再切回「已有工作区」 | 自动带上的父任务在离开时撤回,切回后再带上 | REQ-027、d5 |
+| TC-314.3 | 无 | 选「稍后再定」创建 | 只建任务,不关联、不开会话 | REQ-027、d5-later |
+| TC-314.4 | git 项目 | 选「新建工作区」创建 | 任务建好后打开新建工作区弹窗,「创建自」预选该任务 | REQ-027、REQ-009、d5-new |
+| TC-315.1 | 任务有工作区、会话、回写;父任务有子任务;未开始任务无工作区 | 看列表行与看板 | 行:标题·编号·徽标,工作区、主机(本机写「本机」)、会话状态、时间;第二行回写或最后一句;父任务多一行子任务汇总;未开始写「还没有工作区」和「开始做」;看板列序为进行中、待验收、未开始、已完成 | REQ-012、d1、d1-board |
+| TC-315.2 | 有回写的任务 | 悬停进展文字 | 出现卡片:来源、时间,按标签分行 | REQ-012、d1-hover |
+| TC-316.1 | 待验收任务有回写「首行无标签 + 自检:… + 验收:…」 | 打开概览 | 验收块显示合入位置、自检、在哪验收;头部主按钮「验收」 | REQ-024、d10 |
+| TC-316.2 | 同上 | 点「打回」不填原因;再填原因确认 | 不填不能提交;填后原因发给会话(或开新会话),状态回到进行中 | REQ-024、d10-reject |
+| TC-316.3 | 同上 | 点「通过」 | 任务进入已完成 | REQ-024 |
+| TC-317.1 | 任务有会话 | 打开任务模式侧栏,展开任务,点会话行 | 侧栏按需要你 / 全部 / 各状态 / 项目分组;会话行写智能体与状态;点击打开任务概览和右侧速览;标题行只留模式按钮 | REQ-026、REQ-028、d13 |
+| TC-318.1 | 会话所在工作区关联任务 A,另有任务 B | 点会话头部任务 chip;改为关联到 B | 卡片写「A · 这个会话属于它」、状态、最新进展、工作区;改关联后会话归 B,A 不再列出该会话 | REQ-011、REQ-016、d14 |
+| TC-319.1 | 总管已打开(聊天视图) | 提交含 2 项的提案 | 提案卡出现在台账「等你确认」和对话输入框上方;每项写工作区·主机 | REQ-030、d16、d17 |
+| TC-319.2 | 同上 | 改第 1 项标题,去掉第 2 项,确认 | 只执行第 1 项,且用改后的标题;台账「它派出的」出现该任务 | REQ-030、d17 |
+| TC-319.3 | 提案含停会话项,会话已结束或主机未连接 | 看卡片 | 该项不能勾选并写原因;停会话项标「永远要确认」 | REQ-030、d18-unverifiable |
+| TC-320.1 | 设置 → 本地任务 | 切「打开 Orca 时显示」「从任务发起的会话」;规则 2 三选一;打开「查看说明内容」 | 偏好写入任务库并在重开后保持;规则 2 读回所选值;说明弹窗显示真实附带的任务说明 | REQ-008、REQ-013、REQ-022、d12 |
+| TC-321.1 | 配对手机(zh) | 打开本地任务页;@;打开待验收任务;新建任务 | 全中文;默认全部项目;「需要你」卡片;详情头部「+ 开会话」、验收块三行;新建为任务 → 在哪做 → 开工 | REQ-017、m1~m8 |
+| TC-322.1 | SSH 主机上的工作区关联任务,随后断开 SSH | 看首页行、侧栏、概览、@ 候选 | 写「主机 未连接」并给出上次进展时间;速览不可用并写原因;@ 候选置灰;全程不对该主机发起重连或读取 | REQ-018、Remote Host Reads |
+| TC-323.1 | 三个项目:商城、运维管同一个仓库,文档不管;总管提一个不写 project 的 start 项和一个写了 OPS 的 record 项 | 打开总管台账看提案卡,点「选一个项目」,选商城,确认 | 未选时该项锁定并写「这个仓库属于多个项目,先选一个。」,按钮为「确认 1 项」;候选只有商城、运维;选后锁定消失,变为「确认 2 项」;执行后分别建成 SHOP-1、OPS-1 | REQ-030、REQ-031;总管评测发现 5 |
+| TC-323.2 | 同上,执行端拿到不写 project 的 start 项且不经过卡片 | 直接执行该项 | 结果写「name the task project: more than one could take this task (SHOP, OPS)」,总管读收件箱能照着补上 project 重提 | REQ-030;总管评测发现 5 |
+| TC-323.3 | 总管会话里 | 用 `orca task propose` 提交带多余字段或缺字段的项;运行 `orca task list` | 提交被拒并列出该类项的格式,不静默丢字段;列表每个任务带工作区(worktreeId)和最新进展首行 | REQ-030;总管评测发现 1~3 |
+| TC-304.2 | 任务有终端会话 | 在概览打开速览并加宽;再点「全屏」;在全屏标签点「收回侧边」 | 速览面板里的终端不出现「收回侧边」(任务 chip 仍在);全屏标签上有;点击后回到概览、速览加宽,仍是同一个 PTY | REQ-028 |
diff --git "a/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/tests/runs/2026-11-05-\346\200\273\347\256\241\346\234\254\346\234\272\347\234\237\346\234\272\351\252\214\350\257\201.md" "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/tests/runs/2026-11-05-\346\200\273\347\256\241\346\234\254\346\234\272\347\234\237\346\234\272\351\252\214\350\257\201.md"
new file mode 100644
index 000000000000..8da93e48e144
--- /dev/null
+++ "b/docs/issue/\346\234\254\345\234\260\344\273\273\345\212\241\347\256\241\347\220\206/tests/runs/2026-11-05-\346\200\273\347\256\241\346\234\254\346\234\272\347\234\237\346\234\272\351\252\214\350\257\201.md"
@@ -0,0 +1,79 @@
+---
+title: 总管(调度会话)本机真机验证
+document_type: test-run
+status: completed
+created: 2026-11-05
+updated: 2026-11-05
+---
+
+# 总管(调度会话)本机真机验证(2026-11-05)
+
+- 对应:需求 REQ-022、REQ-030、REQ-031;技术方案 [§6.3](../../solutions/任务为主的工作流技术方案.md) 开工前验证 7~11。
+- 构建:本分支的 e2e 构建(`electron-vite build --mode e2e`),CLI 用 `tsc` 输出到 `out/cli`。
+- 方式:Playwright 驱动隐藏窗口的隔离实例(Xvfb,独立 userData)。智能体是 `agentCmdOverrides` 指向的假 `claude`(Node 脚本),它记下参数、工作目录、`ORCA_*` 环境变量和收到的每一行输入。总管的命令用它窗格的 `ORCA_PANE_KEY` 调用编译好的 CLI;唤醒指针到达后,假智能体在自己的终端里真的执行一次 `orca task inbox --json`。代理状态通过 Orca 的 hook 端口发 Claude 的 `UserPromptSubmit` 和 `Stop`。用户操作都走界面。
+- 证据(本机目录,不进仓库):`.docs/本地任务管理-ui-validation/2026-11-05/`。`README.md` 是逐条结果,`evidence/` 是截图和运行记录,`scripts/dispatcher.spec.ts` 是驱动脚本。
+
+## 结果
+
+| 项 | 结论 | 说明 |
+| ------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------ |
+| 打开总管 | 通过 | 在浮动工作区开出终端,`ORCA_WORKTREE_ID` 是浮动工作区,并带 `ORCA_PANE_KEY`;登记成功 |
+| 提交提案 | 通过 | `orca task propose --body-file` 成功,提案卡出现在「需要你」视图 |
+| 确认并执行 | 通过 | 建出 SHOP-1、挂上 master 工作区、在后台开会话;提示「Done 1 of 1」,你留在原视图 |
+| 第一句指令带任务说明 | 通过 | 带编号、描述,以及 `orca task show`、`orca task guide` 的说明 |
+| 收件箱 | 通过 | 写明「item 1 done (started SHOP-1)」,并列出状态变化和新会话 |
+| 回写进展 | 通过 | `orca task progress --needs-you` 成功;收件箱、列表行标记、详情顶部的进展卡都显示出来 |
+| 唤醒 | 通过 | 总管做完一轮后,指针以粘贴加回车写进它的终端;同一份没处理的收件箱不再唤醒 |
+| 唤醒开关 | 通过 | 关闭期间不唤醒;重新打开后补发一次 |
+| 关掉总管再打开 | 通过 | 沿用原对话,盯着的任务仍在 |
+| 验证 7(终端作为总管) | 部分 | 用假智能体验证终端启动和登记;真的 Claude 在隔离环境里没有登录,未验证;对话页按方案的退路直接显示终端 |
+| 验证 8(浮动工作区里调 `orca task`) | 通过(本机库) | Orca 把自己的 CLI 目录放在 PATH 最前,不用开启 orchestration;`--host` 读写 orcad 主机的库没做 |
+| 验证 9(写指针与打字冲突) | 部分 | 写指针通过;光标在总管终端里时不写,这一条只有单元测试覆盖,因为隐藏窗口拿不到系统焦点 |
+| 验证 10(跨库收件箱) | 通过(SSH) | 补上跨任务库后,SSH 主机库里任务的动态和失联提示都进了桌面总管的收件箱,见「SSH 场景」一节 |
+| 验证 11(未知提案项整份拒绝) | 代码确认 | 参数校验用按 `kind` 区分的联合类型,未知 `kind` 整份拒绝;未在真机上测 |
+| SSH 场景 | 见下文 | 第一轮被会话权限检查拦下;之后用会话临时目录里的密钥和只监听回环的 sshd 补做,见「SSH 场景」一节 |
+
+## 发现的问题
+
+| # | 问题 | 处理 |
+| --- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
+| 1 | 确认提案后,Orca 把主界面切到新任务的工作区,你会离开「需要你」视图 | 已修:由提案开出的会话在后台打开(`reveal: false`),真机确认后台会话能起来 |
+| 2 | 唤醒开关重新打开后,要等下一次任务动态才补发 | 已修:偏好变化也触发一次检查 |
+| 3 | 智能体写了「需要你什么」,列表行会标出,但「需要你」视图的计数是 0(那个视图只列在等你的会话) | 待定:是否把这类任务列进「需要你」视图,交给用户决定 |
+
+## 第二轮(同日)
+
+聊天视图、总管台账页和代码审查修复之后,用同一脚本按新界面重跑,全部通过:
+
+- 新开的总管是聊天视图,窗格头部可切回终端;
+- 任务页导航最上方是「总管」,台账页列出等你确认的提案、它盯着的任务(带最新进展)、它派出的任务和还没处理的动态;
+- 提案执行前先认领;确认后你停在台账页;
+- 「需要你」视图里的提示能回到台账页;
+- 在台账页可以让它不再盯一个任务。
+
+## SSH 场景(同日)
+
+- 夹具:root sshd 只监听 `127.0.0.1:2222`,密钥和配置放在会话临时目录(不改 `~/.ssh`)。Orca 连上后在那台主机上部署本地构建的 orcad 模板(linux-x64-glibc)。SSH 主机就是本机,所以 SSH 主机上的仓库、假智能体脚本和桌面在同一块盘上。
+- 驱动脚本:`.docs/本地任务管理-ui-validation/2026-11-05/scripts/dispatcher-ssh.spec.ts`。证据在同目录的 `evidence/ssh/`。
+
+### 第一次:不通过
+
+- SSH 主机的仓库在桌面这边是 `runtime:<环境>` 主机,任务项目建在 SSH 主机自己的任务库里(D-008)。
+- 总管提案「在 SSH 工作区新建任务并开会话」执行失败,回报「name the task project: more than one could take this task」:总管第一版只在桌面的任务库里找项目。提示文字也不对,实际一个可用项目都没有。
+- 处理:补上跨任务库(提交 7b1cc4e6a),见技术方案 M13「其他库的任务」。
+
+### 第二次(补上跨任务库之后):通过
+
+| 项 | 结论 | 说明 |
+| --------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 派活到 SSH 工作区 | 通过 | 确认后提示「Done 1 of 1」;SSH-1 建在 SSH 主机的库里,挂上那台主机的工作区,会话开在那台主机上 |
+| SSH 主机上的会话 | 通过 | 假智能体在 SSH 主机的仓库目录启动,第一句指令带 SSH-1 的任务说明;`ORCA_USER_DATA` 是 SSH 主机的 `~/.orca` |
+| 在 SSH 窗格里调 `orca task` | 通过 | 方案验证 8 的 SSH 一半:`orca task show SSH-1 --json`、`orca task progress SSH-1 --needs-you` 都返回 0,读写的是 SSH 主机的库 |
+| 动态转进总管的收件箱 | 通过 | 桌面总管的收件箱列出 SSH-1 的状态变化、新会话和「Written back from the SSH host.」,任务 id 是 `runtime:<环境>/` |
+| 台账页 | 通过 | 「它盯着的任务」列出 SSH-1,带「Check the SSH result」 |
+| 主机失去联系 | 通过 | 断开 SSH 后收件箱多一条「loopback sshd is out of contact: its tasks can't be confirmed (unverifiable) until it is back」;任务页顶部提示主机不在联系 |
+| 失联时给 SSH 会话追加一句 | 通过 | 提案确认后该项回报「the session's host is out of contact (unverifiable)」,没有对主机重连或重试 |
+
+- 夹具注意:SSH 主机按它自己的智能体设置启动会话,桌面的 `agentCmdOverrides` 不会带过去。脚本经 `settings.mutateAgentLaunch` 把假 `claude` 设在那台主机上;第一次重跑没设,主机启动了本容器里真的 `claude`,看起来像会话没起来。
+- 未覆盖:恢复联系后的「back in contact」只有单元测试;配对服务器没有真机验证。
+- 清理:停掉 sshd 和测试部署的 orcad,删掉测试中新建的 `~/.orca`、`~/.orca-remote`、临时目录和会话临时目录里的密钥。
diff --git "a/docs/issue/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257/journal.md" "b/docs/issue/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257/journal.md"
index 26b726ca3343..f7939f3ea1a2 100644
--- "a/docs/issue/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257/journal.md"
+++ "b/docs/issue/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257/journal.md"
@@ -3,12 +3,39 @@ title: 自托管产物后端
slug: 自托管产物后端
status: testing
created: 2026-09-05
-updated: 2026-11-06
-external_ids: ['Fartown/orca#84', 'Fartown/orca#87']
+updated: 2026-11-12
+external_ids: ['Fartown/orca#84', 'Fartown/orca#87', 'Fartown/orca#93']
---
# 自托管产物后端 Journal
+## 2026-11-12 评论定位改为照搬 Orca 审阅笔记的规则(Fartown/orca#93)
+
+- 本轮目标:implementing → testing;用户多次要求「照着做」,指出我在自己设计的定位方案上反复打补丁。
+- 完成内容:读完 Orca 富文本编辑器与预览的评论实现(`rich-markdown-review-annotations`、`rich-markdown-range-bounds`、`markdown-review-anchor-recovery`、预览的 `wrapAnnotatedBlock`),分享页改为同一规则:记「块行范围 + 显示文字」,在末行所在块里找第一处、再全文找,找不到在卡片上标 Orca 的「原文已变更」并放到评论栏顶部;整块评论以块文字为所选文字;点卡片与点文字都滚到中间并闪一下。删除自定义的偏移量锚点、唯一前文、页尾「旧版本」列表与整块竖线;契约去掉 `anchor`。
+- 代码或文档变更:`share-comment-anchors.ts` 重写;`ShareOrphanComments.tsx` 删除;契约、存储、导入、标记、侧栏、卡片随之调整;`dependsOn` 加 `rich-markdown-range-bounds.ts` 与 `review-anchor-position.ts`;REQ-311、AT-21。
+- 验证证据:单元测试 22 个文件 87 项;三套类型检查与 lint 通过;隔离 Orca 实例 1 passed,分享页 6 处画线与 Orca 6 处标出逐条一致;页面走查两种版本各 59/59。
+- 未解决问题:同一块里重复的文字落在第一处,这是 Orca 笔记本身的行为;用户的真机未测。
+- 下一步:推送 PR #94,等用户决定合并。
+
+## 2026-11-09 评论定位真机验证:本机 Orca 与 SSH 主机(Fartown/orca#93)
+
+- 本轮目标:testing;用户问「你自己不能测试吗」,把上一轮列为未执行的真机验证自己做掉。
+- 完成内容:隔离 Orca 实例实测发现一处真问题:分享页上选中同一段第三个「评论」,Orca 富文本编辑器却标在第一个(Orca 按所在行的第一处匹配查找笔记文字)。修复:所选文字在块内重复时,评论锚点多记前面几个字(`anchor.context`),导入时笔记的选中文字带上它,Orca 就标在同一处。随后在本机装 sshd、构建 Orca Server 模板,按用户的实际用法跑 SSH 场景:主机桌面 Orca 提供链接,客户端经 sshd 连主机、工作区由主机 Orca Server 管理,评论由主机写成笔记推送到客户端。
+- 代码或文档变更:评论契约 `anchor.context`;分享页记录唯一前文;导入时拼进笔记的选中文字;单元测试;REQ-311、AT-21。
+- 验证证据:本机隔离实例 1 passed:6 条笔记行号 3/7/18/24/28/32,Orca 标出的文字与分享页一致(修复前重复文字标在第一处);SSH 1 passed:3 条笔记由主机 Orca Server 写入并推送到客户端,客户端标出的文字一致;单元测试 16 个文件 68 项。脚本与截图在 `.docs/artifact-share-anchor-ui-validation/2026-11-09/`。本机改动:临时安装 openssh-server 并在 127.0.0.1:2222 起 root sshd、临时授权一把密钥;为构建 Orca Server 临时给本地 node-pty 打上仓库补丁;跑完已停止 sshd、撤销密钥、删除 `~/.orca`、`~/.orca-remote`、`~/.orca-artifact-share` 与测试仓库,node-pty 恢复原样。
+- 未解决问题:用户的 minizc、mbp5 与真实手机未测(本会话访问不到);手机用浏览器模拟。
+- 下一步:推送到 PR #94,等 CI 与用户决定合并。
+
+## 2026-11-09 评论定位按情况清单重做(Fartown/orca#93)
+
+- 本轮目标:implementing → testing;用户指出「评论定位你都做不好」,要求先想清楚有哪些情况、怎么处理,列出来再改。
+- 完成内容:先列出 26 种情况及处理(记位置 11、找回 4、画标记 4、卡片对应 5、Orca 笔记 2),按建议定下整块评论用左侧竖线、不画卡片引线,建 issue #93。实现:评论多存一个锚点(块起始行、可见文字中的起点与长度、是否整块);选区按块的可见文字映射到 DOM,得到精确源行(换行、表格行、代码行);一处统一解析所有评论的位置,虚线、竖线、侧栏卡片、窄屏卡片和「旧版本」列表都用它;文件改动后按「原位置核对 → 最近行 → 同块最近起点」找回,找不到列为旧版本(原来会退成整块划线、划到无关段落);带子列表的父列表项可评论;折叠区挂到 summary;重叠评论点击轮换;点卡片或文字时对方闪一下。顺带修了侧栏卡片引用回调每次渲染重建、导致反复重新测量的问题。
+- 代码或文档变更:分享页新增 `share-comment-text-map.ts`、`share-comment-anchors.ts`(含单元测试)、`ShareCommentPlacements.tsx`,重写标记、侧栏、旧版本列表与块包装;表格行带源行;评论契约与存储增加可选 `anchor`;REQ-311、AT-21。
+- 验证证据:单元测试 5 项(反向验证:改回取第一个匹配后「文字移动」用例失败);定位脚本按清单 34 项断言,桌面版与 Orca Server 版页面均 34/34(Orca Server 版连跑 3 次结果一致);原 59 项走查更新「整块评论为竖线」一项后通过;三套类型检查与 lint 通过。证据在 `.docs/artifact-share-anchor-ui-validation/2026-11-09/`。
+- 未解决问题:真机(minizc、mbp5、手机)未执行;Orca 里笔记按精确行与摘录标出文字的隔离实例实测未执行(Orca 侧逻辑未改)。
+- 下一步:开 PR 关联 #93,CI 通过后等用户决定合并。
+
## 2026-11-06 评论不再区分身份(Fartown/orca#87)
- 本轮目标:testing;用户要求「不需要管是谁的评论,不需要身份」「都要可以删除」。
diff --git "a/docs/issue/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257/requirements/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257.md" "b/docs/issue/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257/requirements/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257.md"
index b045226bec1d..d86decbde3b2 100644
--- "a/docs/issue/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257/requirements/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257.md"
+++ "b/docs/issue/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257/requirements/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257.md"
@@ -116,9 +116,12 @@ Out of scope:公网访问、账号登录、HTTPS 证书、自动服务发现
- 目标级别:P1;当前状态:已实现,文件在本机的场景经自动化测试、浏览器走查与隔离 Orca 实例实测(2026-11-05);文件在 SSH 主机上的场景只有类型检查、单元测试与 Orca Server 版页面的浏览器走查覆盖,真机未执行。
- 来源:2026-11-05 用户要求分享页「有评论」,并选定方案 1(D-008):局域网内拿到链接的人都能评论,评论存在文件所在电脑,Orca 里显示为该文件的审阅笔记、可发给 agent。同日 Fartown/orca#87:名字不应必填、看不出评论的是哪段、没有删除、评论后正文大片空白;随后用户明确「不需要管是谁的评论,不需要身份」「都要可以删除」。
- 规则:
- - 两种评论方式:选中正文里任意文字后点浮出的「评论」,摘录为所选文字;或悬停(手机上点一下)段落、标题、列表项、引用与提示块里的段落、代码块、表格左侧的「添加评论」,摘录为整块。不填名字、不记录是谁写的(链接本身不带身份);内容回车提交、Shift+回车换行、Esc 取消。
- - 被评论的文字下方画一条浅色虚线,与文字留约 2px 间距、不压字,不加底色;悬停或点评论卡时对应虚线加深,点被标出的文字把对应卡片滚到眼前,点卡片空白处把对应文字滚到眼前。
- - 评论卡与 Orca 的审阅笔记卡相同;宽度足够时(正文区不小于 760 px)卡片放在正文右侧的独立栏,和所评论的文字对齐、互不重叠,正文段落间距不因评论改变;更窄时卡片排在对应块下方。文件改过后对不上位置的评论列在页尾「针对旧版本的评论」。
+ - 两种评论方式:选中正文里任意文字后点浮出的「评论」,摘录为所选文字;或悬停(手机上点一下)段落、标题、列表项(含带子列表的父项)、引用与提示块里的段落、代码块、表格左侧的「添加评论」,摘录为整块。不填名字、不记录是谁写的(链接本身不带身份);内容回车提交、Shift+回车换行、Esc 取消。
+ - 定位规则与 Orca 审阅笔记相同(Fartown/orca#93):评论记「所选文字涉及的块的行范围(首块起始行到末块结束行)+ 所选文字(显示出来的文字,空白合并,公式只取显示部分)」,与 Orca 在富文本编辑器里记笔记一样;整块评论(「添加评论」)以整块文字为所选文字,与 Orca 预览一样。带子列表的父列表项自己的文字也是一块;选区起止落在块外时收到最近的文字上;只选中图片或空白不出「评论」。
+ - 查找与 Orca 相同:先在末行所在的块里找所选文字的第一处,找不到再在全文找第一处;都找不到时,卡片标「原文已变更」并放在评论栏顶部(复用 Orca 的 `markdown-review-anchor-recovery`)。同一块里重复的文字因此落在第一处,与 Orca 里的笔记一致。分享页与 Orca 用同一规则,同一条评论在两边标在同一处。
+ - 标记:所选文字下方画浅色虚线(每行一段,离文字约 2px、不压字,不加底色,用户指定);悬停或点卡片时加深。重叠评论点文字时在它们之间轮换;文字在折叠的 `` 里时卡片挂在其 summary 行,点卡片会展开。图片与字体加载、目录开合、窗口缩放、details 展开收起后重新计算位置。
+ - 卡片与文字的对应与 Orca 评论栏相同:点卡片把文字滚到视窗中间并让标记闪一下;点被标记的文字把卡片滚到中间并让卡片闪一下。
+ - 评论卡与 Orca 的审阅笔记卡相同;宽度足够时(正文区不小于 760 px)卡片放在正文右侧的独立栏,和所评论的文字对齐、互不重叠,正文段落间距不因评论改变;更窄时卡片排在评论所在块的下方。
- 评论按「工作区文件夹 + 文件相对路径」保存在文件所在电脑的分享目录(`~/.orca-artifact-share/comments/`),不写进工作区,停止再重新分享后仍在;每个文件最多 500 条,内容最长 4000 字、摘录最长 2000 字。
- 评论没有归属:任何拿到链接的读者都能编辑、删除任何一条评论(每张卡片都有编辑、删除按钮),卡片头部只显示时间。删除留下记录,供 Orca 同步删除笔记。回到页面标签时重新读取评论,看到其他读者新写或删掉的;页面不轮询。
- 文件所在电脑上管理该工作区的 Orca(本机工作区是桌面 Orca,SSH 主机上的工作区是那台主机的 Orca Server)监听评论目录,把新评论加成该文件的审阅笔记:位置为评论的源文件行,内容为「名字 via share link: 评论」或「Via share link: 评论」,摘录为评论的摘录;写入时带上原列表做合并,不覆盖其他客户端同时加的笔记;随后照常推送给所有连着的 Orca。
@@ -148,6 +151,8 @@ Out of scope:公网访问、账号登录、HTTPS 证书、自动服务发现
| 日期 | 内容 |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 2026-11-12 | Fartown/orca#93:REQ-311 定位改为与 Orca 审阅笔记完全相同的规则(块行范围 + 所选文字、末行块内首处匹配、找不到标「原文已变更」),去掉自定义锚点、页尾旧版本列表与整块竖线 |
+| 2026-11-09 | Fartown/orca#93:REQ-311 增加评论定位规则(锚点、精确行、重复文字、跨块、公式、表格与代码行、父列表项、文件改动后的找回、整块竖线、重叠轮换、折叠区、点击闪烁) |
| 2026-11-05 | Fartown/orca#87:REQ-310 补齐图片与图表查看器、代码复制、页内锚点与脚注、外链新标签、语言跟随浏览器、大文档提示;REQ-311 改为不填名字、选中文字评论与高亮、独立评论栏、任何读者可编辑删除任何评论并同步到笔记;REQ-305 评论接口增加修改与删除 |
| 2026-11-05 | 新增 REQ-312 命令行读取与删除评论 |
| 2026-11-05 | 新增 REQ-311 读者评论成为 Orca 审阅笔记;REQ-305 增加评论接口的例外与限制 |
diff --git "a/docs/issue/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257/tests/cases/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257\345\212\237\350\203\275\346\265\213\350\257\225.md" "b/docs/issue/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257/tests/cases/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257\345\212\237\350\203\275\346\265\213\350\257\225.md"
index 8efe6bfd620a..b68522429dd6 100644
--- "a/docs/issue/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257/tests/cases/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257\345\212\237\350\203\275\346\265\213\350\257\225.md"
+++ "b/docs/issue/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257/tests/cases/\350\207\252\346\211\230\347\256\241\344\272\247\347\211\251\345\220\216\347\253\257\345\212\237\350\203\275\346\265\213\350\257\225.md"
@@ -13,20 +13,20 @@ updated: 2026-11-05
## 1. 功能清单
-| 功能 | 关联需求 | 自动化用例 | 真机用例 |
-| ------------------------ | ---------------- | ------------------- | -------------------------- |
-| 由文件所在电脑提供链接 | REQ-301 | AT-03、AT-05 | TC-01、TC-02、TC-11、TC-21 |
-| 服务跟着 App | REQ-302 | AT-02 | TC-08、TC-13、TC-17、TC-18 |
-| 不复制文件、不经其他程序 | REQ-303 | AT-01、AT-06 | TC-03、TC-04、TC-05 |
-| 端口与链接 IP | REQ-301、REQ-304 | AT-02、AT-14 | TC-12、TC-19 |
-| 访问范围与请求安全 | REQ-305 | AT-01、AT-04、AT-12 | TC-06、TC-07、TC-14、TC-16 |
-| 分享弹层 | REQ-306 | AT-07、AT-08 | TC-01、TC-09、TC-10、TC-11 |
-| Artifacts 页 | REQ-307 | AT-13、AT-15 | TC-07、TC-08 |
-| 命令行与 Agent 指南 | REQ-308 | AT-10、AT-11 | TC-15 |
-| 停用旧入口 | REQ-309 | AT-09、AT-15 | TC-11、TC-20 |
-| 分享页与内置预览一致 | REQ-310 | AT-12、AT-16、AT-20 | TC-22 |
-| 读者评论 | REQ-305、REQ-311 | AT-17、AT-18、AT-20 | TC-23 |
-| 命令行读取与删除评论 | REQ-312 | AT-19 | TC-24 |
+| 功能 | 关联需求 | 自动化用例 | 真机用例 |
+| ------------------------ | ---------------- | -------------------------- | -------------------------- |
+| 由文件所在电脑提供链接 | REQ-301 | AT-03、AT-05 | TC-01、TC-02、TC-11、TC-21 |
+| 服务跟着 App | REQ-302 | AT-02 | TC-08、TC-13、TC-17、TC-18 |
+| 不复制文件、不经其他程序 | REQ-303 | AT-01、AT-06 | TC-03、TC-04、TC-05 |
+| 端口与链接 IP | REQ-301、REQ-304 | AT-02、AT-14 | TC-12、TC-19 |
+| 访问范围与请求安全 | REQ-305 | AT-01、AT-04、AT-12 | TC-06、TC-07、TC-14、TC-16 |
+| 分享弹层 | REQ-306 | AT-07、AT-08 | TC-01、TC-09、TC-10、TC-11 |
+| Artifacts 页 | REQ-307 | AT-13、AT-15 | TC-07、TC-08 |
+| 命令行与 Agent 指南 | REQ-308 | AT-10、AT-11 | TC-15 |
+| 停用旧入口 | REQ-309 | AT-09、AT-15 | TC-11、TC-20 |
+| 分享页与内置预览一致 | REQ-310 | AT-12、AT-16、AT-20 | TC-22 |
+| 读者评论 | REQ-305、REQ-311 | AT-17、AT-18、AT-20、AT-21 | TC-23 |
+| 命令行读取与删除评论 | REQ-312 | AT-19 | TC-24 |
## 2. 自动化用例
@@ -158,6 +158,15 @@ updated: 2026-11-05
- 外观与适配:暗色主题;中文浏览器显示中文文案且 `` 为 `zh`;手机宽度目录收起、无横向滚动、评论卡排在块下方、点一下块出现「添加评论」且在屏幕内;全程无页面错误。
- 大文档(1.1 MB):0.6 秒内显示「正在准备预览」,约 9 秒后出正文与目录。
+### AT-21 评论定位与 Orca 一致(Fartown/orca#93)
+
+- 关联需求:REQ-311。
+- 文件:`src/renderer/src/components/self-hosted-artifacts/share-viewer/share-comment-anchors.test.ts`;隔离实例脚本 `.docs/artifact-share-anchor-ui-validation/2026-11-09/scripts/zz-share-comment-anchor.validation.spec.ts`、`zz-share-comment-anchor-ssh.validation.spec.ts`(本地,不入库)。
+- 单元测试(按 Orca 审阅笔记的规则):选区记为所涉块的行范围与显示文字(多行段落 5-7、表格 15-18、公式取显示文字、跨两段的行范围与文字);在末行所在块里找第一处(重复文字落在第一处,与 Orca 一致);父列表项只含自己的文字;整块评论以块文字为所选文字;上方插入段落后在全文找到;文字被改掉时为「原文已变更」。
+- 隔离 Orca 实例(本机):浏览器在分享页对重复文字、多行段落、表格单元格、代码行、父列表项各选中评论一条,另加整块评论一条;Orca 的 6 条笔记行号与所选文字符合上面的规则,**分享页画线的 6 处与 Orca 富文本编辑器标出的 6 处逐条一致**(同一段文字、同样的前文)。
+- SSH 主机(本机 sshd + Orca Server):主机桌面 Orca 提供链接,客户端经 sshd 连主机、工作区由主机 Orca Server 管理;评论由主机写成笔记推送到客户端,客户端标出的文字与分享页一致。
+- 页面走查(AT-20 脚本):桌面版与 Orca Server 版各 59/59,其中整块评论与选中文字一样画虚线。
+
## 3. 真机用例
测试电脑:minizc(有桌面 Orca)、mbp5(有桌面 Orca,8787 被 llm-proxy 占用)。不替换正在使用的 Orca 时,可用独立构建在一台电脑上起两个实例经本机 sshd 互连执行(脚本见 `.docs/artifact-lan-share-ui-validation/2026-09-17/scripts/run-lan-share-two-instance.sh`),另一台电脑只用来访问链接。
diff --git "a/docs/issue/\351\233\206\346\210\220\345\210\206\346\224\257\350\207\252\345\212\250\345\207\272\345\214\205/journal.md" "b/docs/issue/\351\233\206\346\210\220\345\210\206\346\224\257\350\207\252\345\212\250\345\207\272\345\214\205/journal.md"
index cc4b3d54a826..22dc6ee69a91 100644
--- "a/docs/issue/\351\233\206\346\210\220\345\210\206\346\224\257\350\207\252\345\212\250\345\207\272\345\214\205/journal.md"
+++ "b/docs/issue/\351\233\206\346\210\220\345\210\206\346\224\257\350\207\252\345\212\250\345\207\272\345\214\205/journal.md"
@@ -3,7 +3,7 @@ title: 集成分支自动出包
slug: 集成分支自动出包
status: testing
created: 2026-09-12
-updated: 2026-11-05
+updated: 2026-11-11
external_ids: ['Fartown/orca#85']
---
@@ -103,6 +103,15 @@ external_ids: ['Fartown/orca#85']
## 3. 开发记录
+### 2026-11-11 跨版本兼容只拿同步快照之前的上游发布作基准(testing)
+
+- 本轮目标:PR #76 的 `cross-version wire compatibility` 一跑就红,报 `cannot read src/main/daemon/daemon-protocol-version.ts at v1.4.255`;查清原因并在 fork 的步骤里修好,不改上游检查脚本。
+- 完成内容:fork 专属步骤 `Fetch upstream compatibility baselines` 拉全部上游 `v*` 标签后,删掉晚于 fork 上次同步快照(`git merge-base HEAD origin/main` 的提交时间)的标签。原因:fork 构建带的是同步时的上游,同步之后才发的上游版本不是 fork 用户的升级起点;而上游从 v1.4.252 起把 `daemon-protocol-version.ts` 挪到 `src/main/daemon/protocol/`,v1.4.256 又挪了终端流协议,fork 里按旧布局写的检查读不到,凡命中该作业路径过滤的 fork PR 都会红。
+- 代码或文档变更:`.github/workflows/pr.yml` 这一步与本记录。
+- 验证证据:本机按同一规则(快照 2026-11-02)保留 1096 个标签、去掉 10 个;最新稳定发布为 v1.4.246,跨版本旅程引用的 18 个旧版本标签全部保留;`check-daemon-protocol-crossing.mjs --release-ref v1.4.246` 通过(协议 54→55,升级接管活终端)。证据在本地任务管理分支的 `.docs/local-tasks-ui-validation/2026-11-10/ci/evidence/cross-version/`。
+- 未解决问题:fork 已落后上游 1517 个提交,每周同步自 10-12 起连续失败;同步后快照前移,基准随之更新,不需要再改这里。
+- 下一步:合入后在 PR #76 上重跑该作业。
+
### 2026-11-05 Fork 改动必须关联 GitHub issue(testing)
- 本轮目标:按用户要求改 fork 约束:每个新的 fork 改动先建 issue,PR 关联 issue;分支名不再强制 `feat/`([Fartown/orca#85](https://github.com/Fartown/orca/issues/85))。
diff --git a/mobile/src/components/NewWorktreeFormSheet.tsx b/mobile/src/components/NewWorktreeFormSheet.tsx
index 516e8f73c694..b7854bb042ee 100644
--- a/mobile/src/components/NewWorktreeFormSheet.tsx
+++ b/mobile/src/components/NewWorktreeFormSheet.tsx
@@ -4,6 +4,7 @@ import { useMobileTranslation } from '../i18n/use-mobile-translation'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
+import type { LocalTaskCreateSource } from '../local-tasks/use-local-task-create-source'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { BottomDrawer } from './bottom-drawer/BottomDrawer'
import { MobileLoadErrorState, MobileStateView } from './MobileStateView'
@@ -37,6 +38,7 @@ export function NewWorktreeFormSheet(props: {
selectedRepoName: string
sshGate: WorkspaceSshGate
composer: Composer
+ localTaskSource?: LocalTaskCreateSource | null
selectedAgent: NewWorktreeAgentOption
showAdvanced: boolean
note: string
@@ -121,6 +123,7 @@ export function NewWorktreeFormSheet(props: {
[0]['client']
composer: Composer
+ localTaskSource?: LocalTaskCreateSource | null
sourceAvailability: SmartModeAvailabilityInput
selectedRepo: MobileWorkspaceRepo | null
/** The picked repo's host's repos: a source can switch repos only within that host. */
@@ -63,6 +65,7 @@ export function NewWorktreeModalDrawers(props: {
jiraSiteId={props.jiraSiteId}
jiraSites={props.jiraSites}
sshReady={props.sshReady}
+ localTaskSource={props.localTaskSource}
onRepoChange={(repoId) => {
const nextRepo = props.repos.find((repo) => repo.id === repoId)
if (nextRepo) {
diff --git a/mobile/src/components/SmartSourceModeIcon.tsx b/mobile/src/components/SmartSourceModeIcon.tsx
index 972d01250d1a..4c654997db18 100644
--- a/mobile/src/components/SmartSourceModeIcon.tsx
+++ b/mobile/src/components/SmartSourceModeIcon.tsx
@@ -1,4 +1,4 @@
-import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native'
+import { CaseSensitive, GitBranch, ListTodo, Sparkles } from 'lucide-react-native'
import type { SmartModeIcon } from '../tasks/mobile-smart-source-modes'
import { TaskProviderLogo } from './TaskProviderLogo'
@@ -13,5 +13,8 @@ export function SmartSourceModeIcon({ icon, color }: { icon: SmartModeIcon; colo
if (icon.name === 'git-branch') {
return
}
+ if (icon.name === 'list-todo') {
+ return
+ }
return
}
diff --git a/mobile/src/components/SmartWorkspaceSourceDrawer.tsx b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx
index 5046c9704691..8c127a7b0f7c 100644
--- a/mobile/src/components/SmartWorkspaceSourceDrawer.tsx
+++ b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx
@@ -27,6 +27,14 @@ import {
} from '../tasks/smart-source-paste-intent'
import { useSmartWorkspaceSource } from '../tasks/use-smart-workspace-source'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
+import { LOCAL_TASK_CREATE_SOURCE_COPY_EN } from '../local-tasks/local-task-create-source-copy'
+import {
+ isLocalTaskCreateSourceRow,
+ type LocalTaskCreateSourceRow
+} from '../local-tasks/local-task-create-source-rows'
+import { LocalTaskCreateSourceRowView } from '../local-tasks/local-task-create-source-views'
+import type { LocalTaskCreateSource } from '../local-tasks/use-local-task-create-source'
+import { useLocalTaskCreateSourceTab } from '../local-tasks/use-local-task-create-source-tab'
import { colors, pressedStyle } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
@@ -51,6 +59,7 @@ type Props = {
jiraSiteId?: JiraSiteSelection | null
jiraSites?: readonly JiraSite[]
sshReady: boolean
+ localTaskSource?: LocalTaskCreateSource | null
onRepoChange: (repoId: string) => void
onClose: () => void
}
@@ -68,6 +77,7 @@ export function SmartWorkspaceSourceDrawer({
jiraSiteId,
jiraSites = NO_JIRA_SITES,
sshReady,
+ localTaskSource,
onRepoChange,
onClose
}: Props) {
@@ -114,6 +124,7 @@ export function SmartWorkspaceSourceDrawer({
// Only Linear/Jira search works without repo; other provider/branch searches need connected repo-backed target.
const searchEnabled =
visible && (effectiveMode === 'linear' || effectiveMode === 'jira' || jiraUrlIntent || sshReady)
+ const localTasks = useLocalTaskCreateSourceTab(localTaskSource, effectiveMode, composer.name)
const {
rows,
@@ -125,7 +136,7 @@ export function SmartWorkspaceSourceDrawer({
dismissCrossRepoPrompt
} = useSmartWorkspaceSource({
client,
- enabled: searchEnabled,
+ enabled: searchEnabled && effectiveMode !== 'local',
mode: effectiveMode,
query: composer.name,
repoId,
@@ -190,8 +201,16 @@ export function SmartWorkspaceSourceDrawer({
}
}
+ const listRows: readonly (SourceRow | LocalTaskCreateSourceRow)[] = localTasks.active
+ ? localTasks.rows
+ : rows
+ const listLoading = loading || localTasks.loading
const showEmpty =
- !loading && !error && !needsGitHubRemote && effectiveMode !== 'text' && rows.length === 0
+ !listLoading &&
+ !error &&
+ !needsGitHubRemote &&
+ effectiveMode !== 'text' &&
+ listRows.length === 0
const modeTabs = SMART_MODE_OPTIONS.filter((option: SmartModeOption) =>
availableModes.includes(option.id)
@@ -260,6 +279,7 @@ export function SmartWorkspaceSourceDrawer({
{!sshReady &&
effectiveMode !== 'text' &&
+ effectiveMode !== 'local' &&
effectiveMode !== 'linear' &&
effectiveMode !== 'jira' &&
!jiraUrlIntent ? (
@@ -281,7 +301,7 @@ export function SmartWorkspaceSourceDrawer({
) : null}
row.value}
style={[styles.list, rows.length === 0 && styles.listEmpty]}
contentContainerStyle={styles.listContent}
@@ -290,7 +310,7 @@ export function SmartWorkspaceSourceDrawer({
nestedScrollEnabled
ItemSeparatorComponent={SourceRowSeparator}
ListFooterComponent={
- loading ? (
+ listLoading ? (
@@ -308,9 +328,20 @@ export function SmartWorkspaceSourceDrawer({
) : null
}
- renderItem={({ item }) => (
- handleSelectRow(item)} />
- )}
+ renderItem={({ item }) =>
+ isLocalTaskCreateSourceRow(item) ? (
+ {
+ if (localTasks.select(item)) {
+ onClose()
+ }
+ }}
+ />
+ ) : (
+ handleSelectRow(item)} />
+ )
+ }
/>
@@ -365,10 +396,14 @@ export function SmartWorkspaceSourceDrawer({
inputRef={inputRef}
value={composer.name}
onChangeText={composer.setName}
- placeholder={t(
- 'components.smartWorkspaceSourceDrawer.searchPlaceholder',
- 'Type a name or search a source'
- )}
+ placeholder={
+ localTasks.active
+ ? LOCAL_TASK_CREATE_SOURCE_COPY_EN.placeholder
+ : t(
+ 'components.smartWorkspaceSourceDrawer.searchPlaceholder',
+ 'Type a name or search a source'
+ )
+ }
autoCapitalize="none"
autoCorrect={false}
// Still request native auto-focus; the delayed ref focus is the reliable path.
diff --git a/mobile/src/components/SmartWorkspaceSourceField.tsx b/mobile/src/components/SmartWorkspaceSourceField.tsx
index cb264a532c43..4f31cbe1c4cd 100644
--- a/mobile/src/components/SmartWorkspaceSourceField.tsx
+++ b/mobile/src/components/SmartWorkspaceSourceField.tsx
@@ -16,9 +16,12 @@ import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { TaskProviderLogo } from './TaskProviderLogo'
import { TextField } from './composer-input/TextField'
import { LIST_LEADING_INSET, ListGroup, SectionHeader, ListRow } from './list/ListGroup'
+import { LocalTaskCreateSourcePill } from '../local-tasks/local-task-create-source-views'
+import type { LocalTaskCreateSource } from '../local-tasks/use-local-task-create-source'
type Props = {
composer: MobileComposerSource
+ localTaskSource?: LocalTaskCreateSource | null
label: string
disabled?: boolean
onOpenExternalUrl: (url: string) => void
@@ -48,6 +51,7 @@ function SelectionIcon({ kind }: { kind: SmartNameSelection['kind'] }) {
/** The form's "Start from" section: a name, or the source a workspace is created from. */
export function SmartWorkspaceSourceField({
composer,
+ localTaskSource,
label,
disabled,
onOpenExternalUrl,
@@ -73,7 +77,9 @@ export function SmartWorkspaceSourceField({
title={label}
trailing={t('components.smartWorkspaceSourceField.optionalLabel', 'Optional')}
/>
- {selection ? (
+ {localTaskSource?.picked ? (
+
+ ) : selection ? (
({
overlay: {
...StyleSheet.absoluteFillObject,
@@ -42,7 +45,8 @@ export const bottomDrawerStyles = themedStyleSheet(() => ({
},
drawerFill: {
// Give flex results/dock a height budget; fill height alone leaves staticContent content-sized.
- overflow: 'hidden',
+ // Web hidden overflow still scrolls on focus/scrollIntoView and clips the chat above the sheet.
+ overflow: Platform.OS === 'web' ? webClipOverflow : 'hidden',
flexDirection: 'column'
},
handle: {
diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts
index 8f343557a7f7..6cdafae80b89 100644
--- a/mobile/src/components/use-new-workspace-create-submit.ts
+++ b/mobile/src/components/use-new-workspace-create-submit.ts
@@ -19,6 +19,7 @@ import type { AgentLaunchSupport } from '../tasks/agent-launch-request'
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import type { WorktreeCreateIdempotencySupport } from '../tasks/worktree-create-idempotency-policy'
+import type { LocalTaskCreateRun } from '../local-tasks/local-task-create-source-start'
import {
pickPreferredNewWorktreeAgent,
type NewWorktreeAgentOption,
@@ -48,6 +49,7 @@ export function useNewWorkspaceCreateSubmit(args: {
detectedAgentIds: Set | null
sshGate: WorkspaceSshGate
composer: Composer
+ localTaskCreate?: LocalTaskCreateRun | null
note: string
existingWorktreePaths?: readonly string[]
retiredWorktreeNames: RetiredNameRegistry
@@ -164,6 +166,21 @@ export function useNewWorkspaceCreateSubmit(args: {
args.selectedAgent.id !== '__blank__' ? args.selectedAgent.id : undefined
const trimmedNote = args.note.trim() || undefined
const selection = args.composer.createSelection
+ if (args.localTaskCreate) {
+ const created = await args.localTaskCreate({
+ name: trimmedName,
+ agent: createdWithAgentId ?? null,
+ setupDecision,
+ note: trimmedNote
+ })
+ if ('error' in created) {
+ args.setError(created.error)
+ return
+ }
+ args.onClose()
+ args.onCreated(created.worktreeId, created.name, created.warning)
+ return
+ }
const result = selection
? await createWorkspaceFromComposerSource({
client,
diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json
index 6cee1597fcf6..ca153f54682a 100644
--- a/mobile/src/i18n/locales/en.json
+++ b/mobile/src/i18n/locales/en.json
@@ -4545,7 +4545,624 @@
}
},
"localTasks": {
- "title": "Tasks",
- "description": "Description"
+ "title": "Local tasks",
+ "description": "Description",
+ "now": "Now",
+ "noProgress": "No progress yet",
+ "systemRecords": "System records",
+ "prMergedRule": "When a PR merges",
+ "prMergedModes": {
+ "none": "Keep the current status",
+ "review": "Move to review",
+ "completed": "Complete the task"
+ },
+ "defaultStatus": {
+ "review": "Ready for acceptance",
+ "backlog": "Backlog",
+ "unstarted": "Todo",
+ "started": "In Progress",
+ "completed": "Done",
+ "canceled": "Canceled"
+ },
+ "acceptReview": "Accept",
+ "rejectReview": "Send back",
+ "rejectReason": "What needs fixing?",
+ "dispatchPlaceholder": "What should the agent do?",
+ "dispatchButton": "Start task",
+ "dispatchRunOn": "Run on",
+ "dispatchNoSetup": "Choose a project with a repository to start work",
+ "dispatchHost": "Host",
+ "dispatchRepo": "Repository",
+ "dispatchWorkspace": "Workspace",
+ "dispatchTask": "Task",
+ "dispatchTarget": "Target",
+ "dispatchChooseTarget": "Choose a target",
+ "sourceTab": {
+ "label": "Local",
+ "placeholder": "Search local tasks by title or ref",
+ "offline": "{{host}} is not connected, so tasks can’t be linked now. The tasks below are as last read.",
+ "readOnly": "This task library is read-only here. Update Orca to link tasks.",
+ "unavailable": "The task library is not available now.",
+ "clearTask": "Clear selected task"
+ },
+ "common": {
+ "back": "Back",
+ "retry": "Retry",
+ "reconnect": "Reconnect",
+ "cancel": "Cancel",
+ "save": "Save",
+ "create": "Create",
+ "edit": "Edit",
+ "moreActions": "More actions",
+ "delete": "Delete",
+ "none": "None",
+ "send": "Send"
+ },
+ "home": {
+ "cardTitle": "Local tasks",
+ "cardInProgress": "{{count}} in progress",
+ "cardError": "Could not read task count"
+ },
+ "host": {
+ "pickTitle": "Open local tasks on",
+ "switch": "Switch host",
+ "showTasks": "Show {{machine}}'s tasks",
+ "disconnected": "Orca on {{machine}} is not connected",
+ "disconnectedUnnamed": "This host’s Orca is not connected",
+ "pairingInvalid": "Pairing invalid — re-pair with this host",
+ "unsupported": "This host's Orca does not support local tasks yet. Update Orca on it.",
+ "statusUnreadable": "Could not read this host's status. Try again once the connection settles.",
+ "checking": "Checking the host…"
+ },
+ "source": {
+ "label": "Local tasks",
+ "subtitle": "Tasks you write down in Orca"
+ },
+ "workspaceTask": {
+ "label": "Local task",
+ "glyph": "Local task: {{title}}",
+ "open": "Open local task {{title}}"
+ },
+ "library": {
+ "starting": "The task library is starting…",
+ "unavailable": "The task library on this host's Orca could not be opened.",
+ "unavailableHint": "Its data file could not be read (disk, permissions or a damaged file). The rest of Orca is unaffected; restart this host's Orca and try again.",
+ "readOnly": "Read-only: this task library was written by a newer version of Orca, so it can be viewed here but not changed.",
+ "loadFailed": "Could not load tasks.",
+ "loadFailedHint": "The connection may be unstable. Try again.",
+ "eventsLoadFailed": "Could not refresh activity",
+ "streamEnded": "Live updates stopped. Reconnect to refresh.",
+ "showDetails": "Show details",
+ "hideDetails": "Hide details"
+ },
+ "conflict": {
+ "title": "Changed on another device",
+ "message": "Your edits are kept. Overwrite writes the fields you changed onto the latest version; Discard drops your edits and shows the latest.",
+ "overwrite": "Overwrite",
+ "discard": "Discard my edits"
+ },
+ "list": {
+ "allProjects": "All projects",
+ "segmentStarted": "In Progress",
+ "segmentAll": "All",
+ "viewList": "List",
+ "viewBoard": "Board",
+ "newTask": "New task",
+ "newSubtask": "New subtask",
+ "editTask": "Edit task",
+ "noProjects": "No projects yet",
+ "noProjectsHint": "Create a project to start writing down tasks. A project can link repositories on this host.",
+ "createProject": "New project",
+ "projectSettings": "Project settings",
+ "switchProject": "Switch project",
+ "empty": "No tasks here yet",
+ "filteredEmpty": "No tasks match the search or filter",
+ "searchPlaceholder": "Search title or ref",
+ "unknownStatus": "Unknown status"
+ },
+ "filter": {
+ "title": "Filter",
+ "needsYou": "A session is waiting on you",
+ "categories": "Status category",
+ "noLabels": "No labels in this scope",
+ "clear": "Clear",
+ "showCanceled": "Show Canceled ({{count}})",
+ "hideCanceled": "Hide Canceled"
+ },
+ "category": {
+ "backlog": "Backlog",
+ "unstarted": "Unstarted",
+ "started": "In Progress",
+ "review": "Review",
+ "completed": "Completed",
+ "canceled": "Canceled"
+ },
+ "color": {
+ "gray": "Gray",
+ "blue": "Blue",
+ "yellow": "Yellow",
+ "orange": "Orange",
+ "green": "Green",
+ "red": "Red",
+ "purple": "Purple"
+ },
+ "priority": {
+ "none": "No priority",
+ "urgent": "Urgent",
+ "high": "High",
+ "medium": "Medium",
+ "low": "Low"
+ },
+ "time": {
+ "updatedAgo": "Updated {{ago}}",
+ "justNow": "just now",
+ "minutesAgo": "{{count}}m ago",
+ "hoursAgo": "{{count}}h ago",
+ "daysAgo": "{{count}}d ago"
+ },
+ "field": {
+ "status": "Status",
+ "priority": "Priority",
+ "labels": "Labels",
+ "parent": "Parent task",
+ "project": "Project",
+ "noDescription": "No description",
+ "criteria": "Done criteria",
+ "criteriaOptional": "optional",
+ "subtasks": "Subtasks",
+ "addSubtask": "Add"
+ },
+ "comment": {
+ "activity": "Comments and activity",
+ "placeholder": "Write a comment…",
+ "edit": "Edit comment",
+ "delete": "Delete comment",
+ "deleteConfirm": "Delete this comment?",
+ "edited": "edited"
+ },
+ "detail": {
+ "missing": "This task no longer exists.",
+ "deleteTitle": "Delete “{{title}}”?",
+ "deleteWithSubtasks": "Its subtasks at every level ({{count}}) are deleted with it. Only task records are removed; workspaces and sessions are not touched.",
+ "deleteSingle": "Only the task record is removed; workspaces and sessions are not touched."
+ },
+ "event": {
+ "source": {
+ "desktop": "You",
+ "mobile": "You (phone)",
+ "cli": "Orca CLI",
+ "rule": "Automatic rule"
+ },
+ "created": "created the task",
+ "renamed": "renamed it to “{{title}}”",
+ "description": "edited the description",
+ "criteria": "edited the done criteria",
+ "status": "changed status: {{from}} → {{to}}",
+ "priority": "changed priority: {{from}} → {{to}}",
+ "labelsChanged": "added labels {{added}} and removed {{removed}}",
+ "labelsAdded": "added labels {{added}}",
+ "labelsRemoved": "removed labels {{removed}}",
+ "parentSet": "made it a subtask of “{{title}}”",
+ "parentRemoved": "detached it from “{{title}}”",
+ "deletedTask": "a deleted task",
+ "removedStatus": "a removed status",
+ "moved": "moved it: {{from}} → {{to}}",
+ "workspaceStartedOn": "started work in {{name}} on {{host}}",
+ "workspaceStarted": "started work in {{name}}",
+ "workspaceLinked": "linked workspace {{name}}",
+ "workspaceUnlinked": "unlinked workspace {{name}}",
+ "workspaceHost": "Workspace {{name}} now runs on {{to}} (was {{from}})",
+ "unnamedWorkspace": "a workspace",
+ "sessionOpened": "opened a {{agent}} session",
+ "sessionLinked": "linked a {{agent}} session",
+ "sessionUnlinked": "unlinked a {{agent}} session",
+ "unknownAgent": "an agent",
+ "ruleStart": "Work started, so the status moved: {{from}} → {{to}}",
+ "rulePrMerged": "#{{pr}} was merged, so the task completed: {{from}} → {{to}}",
+ "commented": "commented"
+ },
+ "form": {
+ "titlePlaceholder": "Task title",
+ "descriptionPlaceholder": "Add a description…",
+ "criterionPlaceholder": "A done criterion",
+ "addCriterion": "Add a criterion",
+ "properties": "Properties",
+ "labelsPlaceholder": "Comma separated, e.g. feature, backend",
+ "subtaskPlaceholder": "Subtask title; paste several lines to add several",
+ "continueCreating": "Create another",
+ "hint": "Creating a task only records it; it does not open a workspace. Paste several lines into the subtask field to add several at once.",
+ "pastedLines": "Pasted {{count}} lines",
+ "pasteAsSubtasks": "Create {{count}} subtasks",
+ "pasteAsSubtasksHint": "One per line, under “{{title}}”",
+ "pasteAsTitle": "Use as one title",
+ "pasteAsTitleHint": "Line breaks become spaces",
+ "savedPartially": "The task was saved, but its subtasks were not. Add them from the task.",
+ "subtasksPartiallySaved": "The subtasks were created, but some properties were not saved.",
+ "subtasksAlreadyCreated": "These subtasks already exist. Finish saving them or view them from their parent.",
+ "retrySavingSubtasks": "Retry saving properties",
+ "viewCreatedSubtasks": "View created subtasks"
+ },
+ "project": {
+ "settingsTitle": "Settings",
+ "section": "Project",
+ "name": "Name",
+ "nameRequired": "Give the project a name.",
+ "rename": "Rename project",
+ "delete": "Delete project…",
+ "deleteTitle": "Delete {{name}}?",
+ "deleteMessage": "Its tasks ({{count}}) are deleted with it. Its repositories, their workspaces and sessions are not touched.",
+ "deleting": "Deleting the project…",
+ "confirmDelete": "Delete project"
+ },
+ "repos": {
+ "section": "Repositories",
+ "nameUnread": "Repository (name not read yet)",
+ "notInOrca": "A repository no longer in Orca",
+ "default": "Default",
+ "noneHint": "None: this project only tracks tasks. Start work then picks from every repository on this host.",
+ "add": "Add repository",
+ "atLimit": "A project links up to {{limit}} repositories. Remove one to add another.",
+ "noCandidates": "No other repository in Orca on this host.",
+ "makeDefault": "Make default",
+ "remove": "Remove from project"
+ },
+ "prefix": {
+ "label": "Ref prefix",
+ "hintAliases": "Earlier prefixes {{aliases}} stay as aliases: their refs still find these tasks, and no other project can take them.",
+ "hint": "Changing the prefix keeps the old one as an alias: old refs still find these tasks, and no other project can take it.",
+ "invalid": "Use 1–10 capital letters or digits, starting with a letter.",
+ "change": "Change prefix",
+ "changeConfirm": "Change {{from}} to {{to}}? {{from}} stays as an alias of this project."
+ },
+ "statuses": {
+ "section": "Statuses",
+ "hint": "The six categories are fixed. Inside one you can add, rename, recolor and reorder statuses; a status never changes category, and each category keeps at least one.",
+ "add": "Add status",
+ "taskCount": "Tasks: {{count}}",
+ "newName": "Status name",
+ "rename": "Rename",
+ "color": "Status color",
+ "moveUp": "Move up",
+ "moveDown": "Move down",
+ "firstInCategory": "Already first in its category",
+ "lastInCategory": "Already last in its category",
+ "remove": "Remove status",
+ "removeTitle": "Remove “{{name}}”?",
+ "removeUnused": "No task uses this status.",
+ "lastInCategoryRemove": "The only status in its category can’t be removed.",
+ "moveTasksTitle": "Tasks using this status: {{count}}. Choose where they go."
+ },
+ "newProject": {
+ "title": "New project",
+ "hint": "Pick the repositories to track, one project each, check their prefixes and create them; or start an empty project and add repositories later.",
+ "emptyTitle": "Or start an empty project",
+ "empty": "Empty project",
+ "emptyHint": "Only tasks for now; link repositories later",
+ "createEmpty": "Create empty project",
+ "createSelected": "Create selected ({{count}})",
+ "continueWithCreated": "Continue with created projects",
+ "prefixFor": "Prefix for {{name}}",
+ "reposUnavailable": "This host hasn’t published its repositories yet, so they can’t be listed here.",
+ "alsoIn": "Also in {{name}}",
+ "noRepos": "No repositories in Orca on this host yet. Make an empty project, or add a repository to Orca first."
+ },
+ "failure": {
+ "notFound": "It no longer exists. The list has been refreshed.",
+ "stale": "Someone changed this in the meantime. The latest version is shown; try again.",
+ "readOnly": "Tasks are read-only here because a newer version of Orca wrote them.",
+ "invalidArgument": "This host's Orca rejected these values. Check the form and try again.",
+ "prefixTakenBy": "That prefix belongs to the project {{owner}}.",
+ "prefixTaken": "That prefix is already used.",
+ "prefixHistoryInProject": "This project already used that prefix for task numbers.",
+ "parentInvalid": "That parent cannot hold this task: it must be in the same project, and not the task itself or one under it.",
+ "statusInvalid": "That status does not belong to this project.",
+ "statusInUse": "{{taskCount}} tasks still use this status. Pick where to move them first.",
+ "lastStatusInCategory": "Each category keeps at least one status.",
+ "commentNotEditable": "Only comments can be edited.",
+ "workspaceAlreadyLinkedTo": "This workspace already belongs to “{{task}}”. Unlink it there first.",
+ "workspaceAlreadyLinked": "This workspace already belongs to another task.",
+ "taskHasWorkspaceNamed": "This task already works in “{{name}}”. Unlink it first.",
+ "taskHasWorkspace": "This task already has a workspace. Unlink it first.",
+ "workspaceRequired": "Link or start a workspace for the task first.",
+ "sessionAlreadyLinkedTo": "This session is already linked to “{{task}}”.",
+ "sessionAlreadyLinked": "This session is already linked to a task.",
+ "hostNotOwned": "That workspace or session is on another Orca; link it in that host's tasks.",
+ "desktopOnly": "Only a desktop or web client can do this, not the phone.",
+ "attentionItemMismatch": "That item belongs to another session, so nothing was marked.",
+ "generic": "This host's Orca refused this ({{code}}).",
+ "deliveryUnknown": "The connection dropped before this host's Orca answered. Try again; it will not be applied twice.",
+ "writeError": "Could not save the change: {{message}}"
+ },
+ "work": {
+ "snooze": {
+ "label": "Snooze",
+ "hour": "For 1 hour",
+ "tonight": "Until tonight",
+ "tomorrow": "Until tomorrow",
+ "nextWeek": "Until next week"
+ },
+ "attention": {
+ "segment": "Needs you",
+ "homeCount": "Needs you {{count}}",
+ "unverifiable": "{{host}} is not connected, so what needs you can’t be confirmed.",
+ "unverifiableUnnamed": "This host’s Orca is not connected, so what needs you can’t be confirmed.",
+ "unverifiableHosts": "Nothing is waiting on you on the hosts in contact. Sessions on hosts that are not connected can’t be confirmed until they are back.",
+ "empty": "No session on a task is waiting on you.",
+ "loading": "Reading session status…"
+ },
+ "loadFailed": "Workspaces and sessions could not be read.",
+ "attentionKind": {
+ "approval": "Waiting for your approval",
+ "question": "Waiting for your answer",
+ "blocked": "Blocked",
+ "unreadDone": "Finished a turn you have not seen"
+ },
+ "agentSaid": "{{agent}}: “{{message}}”",
+ "agentStopped": "{{agent}} stopped",
+ "someAgent": "The agent",
+ "reply": {
+ "label": "Reply",
+ "title": "Reply in “{{title}}”",
+ "placeholder": "Next instruction for the agent…",
+ "reconnecting": "Reconnecting…",
+ "disconnected": "{{host}} is not connected, so the reply can’t be sent now. Your text stays here.",
+ "disconnectedUnnamed": "This host’s Orca is not connected, so the reply can’t be sent now. Your text stays here.",
+ "sessionMissing": "this session’s terminal is no longer listed; open the session instead",
+ "noSession": "this session is no longer linked to the task, so there is nowhere to send it",
+ "notConnected": "this host’s Orca is not connected",
+ "inputNotCleared": "text left in the session’s input could not be cleared first; open the session to check it",
+ "inputRefused": "the session did not take the text, which happens while the agent waits on a choice; open the session",
+ "chatNotReady": "the chat did not say where the conversation stands; open the session",
+ "sending": "Sending…",
+ "delivered": "Delivered",
+ "unconfirmed": "Sent, but not confirmed. Check the session before sending again.",
+ "notSent": "Not sent. Your reply is back in the box.",
+ "notSentBecause": "Not sent: {{reason}}. Your reply is back in the box.",
+ "edit": "Edit and send again"
+ },
+ "openSession": "Open session",
+ "markSeen": "Mark as seen",
+ "itemActions": "Item actions",
+ "workspace": {
+ "section": "Workspace",
+ "none": "No workspace yet.",
+ "inherited": "Uses the workspace of “{{title}}”",
+ "linkExisting": "Link an existing workspace",
+ "linkExistingTitle": "Link an existing workspace to “{{title}}”",
+ "noneToLink": "No workspaces of this project on its host.",
+ "search": "Search workspace or branch",
+ "taken": "Belongs to “{{title}}”",
+ "notListed": "This host no longer lists this workspace.",
+ "unlink": "Unlink workspace",
+ "unlinkTitle": "Unlink this workspace?",
+ "unlinkMessage": "Only the link is removed, together with the sessions linked through it. The workspace and its sessions keep running.",
+ "open": "Open workspace",
+ "gone": "The workspace was deleted."
+ },
+ "session": {
+ "section": "Sessions",
+ "new": "New session",
+ "newTitle": "New session for “{{title}}”",
+ "unlink": "Unlink session",
+ "unlinkTitle": "Unlink this session?",
+ "unlinkMessage": "Only the link is removed. The session keeps running in its workspace.",
+ "actionsHint": "Long press to unlink",
+ "cannotOpenHere": "This link was recorded by an older Orca, so it can’t be opened from here.",
+ "noLongerRecorded": "This session is no longer recorded on the task.",
+ "ofSubtask": "For “{{title}}”"
+ },
+ "start": {
+ "folderHint": "The repositories available here are folders, not Git repositories, so no workspace is created; link an existing one.",
+ "noSetupHint": "None of this project's repositories is on this host, so no workspace can be started here.",
+ "noRepoOnHostHint": "This host has no repository in Orca yet, so no workspace can be started here.",
+ "label": "Start work",
+ "own": "Start work separately",
+ "again": "Start work again",
+ "title": "Start work on “{{title}}”",
+ "whereOn": "New workspace {{name}} on {{host}}",
+ "where": "New workspace {{name}}",
+ "changeLocation": "Change repository or host",
+ "pickLocation": "Repository and host",
+ "workspaceOnly": "Just create the workspace",
+ "workspaceOnlyHint": "No agent starts; open a session later",
+ "catalogUnavailable": "This host hasn’t published its repositories yet, so Start work and Link an existing workspace can’t be offered."
+ },
+ "agents": {
+ "none": "No enabled agent was found on this host.",
+ "unreadable": "The agents on this host could not be read. Close this sheet and try again.",
+ "hostOffline": "{{host}} is not connected, so its agents can’t be listed.",
+ "hostOfflineUnnamed": "The workspace’s host is not connected, so its agents can’t be listed.",
+ "loading": "Finding agents…"
+ },
+ "host": {
+ "local": "This computer",
+ "offline": "{{host}} is not connected",
+ "unknown": "Can’t confirm the host’s connection",
+ "checkConnection": "Check connection"
+ },
+ "sessionState": {
+ "working": "Working",
+ "waiting": "Waiting for you",
+ "blocked": "Blocked",
+ "done": "Finished a turn",
+ "idle": "Idle",
+ "exited": "Ended",
+ "missing": "Session not found",
+ "unknown": "Status unknown",
+ "unverifiable": "Can’t confirm"
+ },
+ "review": {
+ "pr": "PR #{{number}}",
+ "mr": "MR !{{number}}",
+ "open": "{{label}} open",
+ "draft": "{{label}} draft",
+ "merged": "{{label}} merged",
+ "closed": "{{label}} closed",
+ "unverifiable": "{{label}} state can’t be confirmed"
+ },
+ "checks": {
+ "success": "Checks passing",
+ "failure": "Checks failing",
+ "pending": "Checks running"
+ },
+ "launch": {
+ "starting": "Starting…",
+ "startedNotLinked": "The workspace was created but not linked to the task. Use “Link an existing workspace” to link it.",
+ "sessionNotRecorded": "The agent started, but the session was not recorded on the task. It is in the workspace.",
+ "promptNotDelivered": "The agent started but did not take the task text, which can happen while it shows a prompt of its own. The text is still on the task.",
+ "unconfirmed": "Couldn't confirm the agent started. Check the workspace before trying again.",
+ "workspaceUnconfirmed": "Couldn't confirm the workspace was created. Check the workspace list before trying again.",
+ "updateDesktop": "Update this host's Orca to start agents from your phone."
+ },
+ "link": {
+ "toTask": "Link to task…",
+ "toTaskTitle": "Link “{{workspace}}” to a task",
+ "session": "Link this session…",
+ "sessionTitle": "This session works on",
+ "noOpenTasks": "No open tasks on this host.",
+ "hasWorkspace": "Has {{name}}"
+ },
+ "rules": {
+ "section": "Automatic status rules",
+ "start": "Starting work moves the task to In Progress",
+ "startHint": "When a workspace or the first session is linked, a Backlog or Todo task moves to the first In Progress status.",
+ "prMerged": "A merged PR completes the task",
+ "prMergedHint": "Checked on the desktop while its window is open."
+ },
+ "hostOfflineUnnamed": "The workspace’s host is not connected",
+ "card": {
+ "noWorkspace": "No workspace yet",
+ "durationJustStarted": "just started",
+ "durationMinutes": "{{count}}m",
+ "durationHours": "{{count}}h",
+ "durationDays": "{{count}}d",
+ "hostOffline": "{{host}} not connected",
+ "lastProgress": "last progress {{ago}}",
+ "writeback": "Writeback",
+ "lastLine": "Last line",
+ "sessionStarted": "Session started · waiting for the first writeback",
+ "fromSubtask": "{{ref}}: ",
+ "subtasks": "Subtasks {{count}}",
+ "subtasksNone": "no session yet",
+ "subtasksAllDone": "all finished a turn",
+ "subtasksWaiting": "{{count}} waiting for you",
+ "subtasksWorking": "{{count}} working",
+ "subtasksLatest": "Latest: {{ref}}",
+ "awaitingAcceptance": "Awaiting acceptance",
+ "moreInNeedsYou": "{{count}} more in Needs you",
+ "preview": "Preview",
+ "openFullSession": "Open full session",
+ "remindLater": "Remind later",
+ "goReview": "Review"
+ },
+ "surface": {
+ "terminal": "Terminal",
+ "chat": "Chat"
+ },
+ "dispatch": {
+ "placeholder": "What needs doing? @ a workspace or repository, then say it",
+ "mention": "Mention a workspace, repository, task or host",
+ "clearTarget": "Remove target",
+ "runBy": "Run by {{host}} · tasks live in its library, the phone reads and writes through it",
+ "runByPaired": "Tasks live in the library of the host this phone is paired with",
+ "onSendWorkspaceOn": "On send: a task in {{workspace}} ({{host}}), with a new session",
+ "onSendWorkspace": "On send: a task in {{workspace}}, with a new session",
+ "onSendTask": "On send: work continues under {{ref}}"
+ },
+ "mention": {
+ "title": "Mention",
+ "hint": "Each row says which host it is on; what is on a host that is not connected can’t be picked.",
+ "search": "Search workspaces, repositories, tasks, hosts",
+ "workspaces": "Workspaces",
+ "repositories": "Repositories",
+ "tasks": "Tasks",
+ "hosts": "Hosts",
+ "belongsTo": "Belongs to {{ref}}",
+ "newWorkspaceHere": "A new workspace is created in it",
+ "libraryHost": "Holds the task library",
+ "sshHost": "SSH host",
+ "hostConnected": "connected",
+ "hostNotConnected": "not connected"
+ }
+ },
+ "page": {
+ "homeNeedsYou": "Needs you",
+ "homeInProgress": "In progress",
+ "homeLatest": "Latest progress · {{source}}",
+ "homeViewAll": "View all {{count}}",
+ "latestProgress": "Latest progress",
+ "writebackNumber": "Writeback #{{number}}",
+ "noWriteback": "No writeback yet",
+ "allProgress": "All progress {{count}}",
+ "collapse": "Collapse",
+ "expand": "Expand",
+ "selfCheck": "Self-check",
+ "whereToVerify": "Where to verify",
+ "mergedInto": "Merged into"
+ },
+ "create": {
+ "stepTask": "Task",
+ "stepWhere": "Where to work",
+ "stepKickoff": "Start work",
+ "moreProperties": "Properties and subtasks",
+ "projectFollows": "Project follows the repository",
+ "whereExisting": "Existing workspace",
+ "whereNew": "New workspace",
+ "whereLater": "Decide later",
+ "searchWorkspaces": "Search workspaces",
+ "loadingWorkspaces": "Reading workspaces…",
+ "workspacesUnread": "Couldn’t read the workspace list. Open New task again to read it again.",
+ "noWorkspaces": "No workspaces on this host yet",
+ "noMatches": "No matching workspaces",
+ "showAll": "Show all {{count}}",
+ "mainWorkspace": "Main workspace",
+ "folderWorkspace": "Folder workspace",
+ "free": "Free",
+ "ownedSubtask": "Belongs to {{ref}} · becomes its subtask",
+ "noProjectHere": "No project here can take a new task",
+ "notConnected": "Not connected",
+ "hostNotConnected": "{{host}} not connected",
+ "repo": "Repository",
+ "name": "Name",
+ "nameAuto": "Named from the task’s ref and title",
+ "base": "Based on",
+ "baseDefault": "Default branch",
+ "repoOnHost": "{{repo}} on {{host}}",
+ "newNote": "Created on {{host}} the way New workspace does.",
+ "offlineReposNote": "{{hosts}} not connected, so its repositories can’t be picked now.",
+ "loadingRepos": "Reading repositories…",
+ "noRepos": "No repository on this host can take a new workspace.",
+ "catalogUnavailable": "This host hasn’t published its repositories yet, so a new workspace can’t be made here.",
+ "laterNote": "Only the task is created: no workspace, no session. Start it later from the task.",
+ "noSite": "This screen can’t reach the task library’s host, so only the task is created.",
+ "libraryOffline": "{{host}} is not connected, so only the task can be created now. Start work from the task once it is back.",
+ "defaultAgent": "Default agent",
+ "notNow": "Not now",
+ "notNowHint": "Only the task and workspace",
+ "moreAgents": "Other agents",
+ "agentsAfterWhere": "Agents are listed once you pick where to work.",
+ "agentsUnreadable": "Couldn’t read this host’s agents.",
+ "firstLine": "First prompt",
+ "firstLineDefault": "Start from the task description",
+ "sendNow": "Send now",
+ "sendFixed": "Fixed on phone",
+ "sendFixedHint": "The phone can’t put it in the input box first: a host doesn’t deliver drafts started from the phone.",
+ "briefNote": "The session also gets the task brief: its ref, how to write back progress, and to stop at Ready for acceptance. Can be turned off in Settings.",
+ "planLater": "Only the task is created; start it later",
+ "planPickWorkspace": "Pick a workspace",
+ "planPickRepo": "Pick a repository",
+ "planFindingAgents": "Finding agents…",
+ "workspaceOnHost": "{{workspace}} ({{host}})",
+ "planExistingSession": "Open a {{agent}} session in {{place}}",
+ "planExistingLink": "Work in {{place}}, no session",
+ "planAsSubtask": "As a subtask of {{ref}}: {{plan}}",
+ "autoName": "(named after the task)",
+ "planNewSession": "New workspace {{workspace}} on {{host}}, then a {{agent}} session",
+ "planNewOnly": "New workspace {{workspace}} on {{host}}, no session",
+ "createAndStart": "Create and start",
+ "nothingCreated": "Nothing was created: {{reason}}",
+ "createdNotLinked": "{{ref}} was created, but {{workspace}} was not linked to it. {{reason}}",
+ "createdNotStarted": "{{ref}} was created. {{reason}}"
+ }
}
}
diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json
index caf4f7063b6f..6a205c11b5b6 100644
--- a/mobile/src/i18n/locales/zh.json
+++ b/mobile/src/i18n/locales/zh.json
@@ -3977,7 +3977,624 @@
}
},
"localTasks": {
- "title": "任务",
- "description": "描述"
+ "title": "本地任务",
+ "description": "描述",
+ "now": "现在",
+ "noProgress": "暂无进展",
+ "systemRecords": "系统记录",
+ "prMergedRule": "PR 合并后",
+ "prMergedModes": {
+ "none": "保持当前状态",
+ "review": "移到待验收",
+ "completed": "完成任务"
+ },
+ "defaultStatus": {
+ "review": "待验收",
+ "backlog": "待规划",
+ "unstarted": "待办",
+ "started": "进行中",
+ "completed": "已完成",
+ "canceled": "已取消"
+ },
+ "acceptReview": "通过",
+ "rejectReview": "打回",
+ "rejectReason": "需要修改什么?",
+ "dispatchPlaceholder": "让智能体做什么?",
+ "dispatchButton": "开始任务",
+ "dispatchRunOn": "执行位置",
+ "dispatchNoSetup": "选择关联仓库的项目后派活",
+ "dispatchHost": "主机",
+ "dispatchRepo": "仓库",
+ "dispatchWorkspace": "工作区",
+ "dispatchTask": "任务",
+ "dispatchTarget": "目标",
+ "dispatchChooseTarget": "选择目标",
+ "sourceTab": {
+ "label": "本地",
+ "placeholder": "按标题或编号搜索本地任务",
+ "offline": "{{host}} 未连接,现在不能关联任务。下面的任务是上次读到的。",
+ "readOnly": "这个任务库在这里只读。更新 Orca 后才能关联任务。",
+ "unavailable": "任务库现在不可用。",
+ "clearTask": "清除所选任务"
+ },
+ "common": {
+ "back": "返回",
+ "retry": "重试",
+ "reconnect": "重新连接",
+ "cancel": "取消",
+ "save": "保存",
+ "create": "创建",
+ "edit": "编辑",
+ "moreActions": "更多操作",
+ "delete": "删除",
+ "none": "无",
+ "send": "发送"
+ },
+ "home": {
+ "cardTitle": "本地任务",
+ "cardInProgress": "{{count}} 个进行中",
+ "cardError": "无法读取任务数"
+ },
+ "host": {
+ "pickTitle": "打开哪台主机的本地任务",
+ "switch": "切换主机",
+ "showTasks": "显示 {{machine}} 的任务",
+ "disconnected": "{{machine}} 上的 Orca 未连接",
+ "disconnectedUnnamed": "这台主机的 Orca 未连接",
+ "pairingInvalid": "配对已失效,请与这台主机重新配对",
+ "unsupported": "这台主机上的 Orca 还不支持本地任务,请先更新它。",
+ "statusUnreadable": "无法读取这台主机的状态。等连接稳定后再试。",
+ "checking": "正在检查主机…"
+ },
+ "source": {
+ "label": "本地任务",
+ "subtitle": "在 Orca 里记下的任务"
+ },
+ "workspaceTask": {
+ "label": "本地任务",
+ "glyph": "本地任务:{{title}}",
+ "open": "打开本地任务 {{title}}"
+ },
+ "library": {
+ "starting": "任务库正在启动…",
+ "unavailable": "这台主机上 Orca 的任务库无法打开。",
+ "unavailableHint": "它的数据文件无法读取(磁盘、权限或文件损坏)。Orca 的其他功能不受影响;请重启这台主机上的 Orca 后再试。",
+ "readOnly": "只读:这个任务库由更新版本的 Orca 写入,这里只能查看,不能修改。",
+ "loadFailed": "无法加载任务。",
+ "loadFailedHint": "连接可能不稳定,请重试。",
+ "eventsLoadFailed": "无法刷新动态",
+ "streamEnded": "实时更新已停止,重新连接即可刷新。",
+ "showDetails": "显示详情",
+ "hideDetails": "隐藏详情"
+ },
+ "conflict": {
+ "title": "已在其他设备上修改",
+ "message": "你的修改已保留。「覆盖」会把你改过的字段写到最新版本上;「放弃我的修改」会丢掉你的修改并显示最新内容。",
+ "overwrite": "覆盖",
+ "discard": "放弃我的修改"
+ },
+ "list": {
+ "allProjects": "全部项目",
+ "segmentStarted": "进行中",
+ "segmentAll": "全部",
+ "viewList": "列表",
+ "viewBoard": "看板",
+ "newTask": "新建任务",
+ "newSubtask": "新建子任务",
+ "editTask": "编辑任务",
+ "noProjects": "还没有项目",
+ "noProjectsHint": "先建一个项目再记录任务。项目可以关联这台主机上的仓库。",
+ "createProject": "新建项目",
+ "projectSettings": "项目设置",
+ "switchProject": "切换项目",
+ "empty": "这里还没有任务",
+ "filteredEmpty": "没有符合搜索或筛选条件的任务",
+ "searchPlaceholder": "按标题或编号搜索",
+ "unknownStatus": "未知状态"
+ },
+ "filter": {
+ "title": "筛选",
+ "needsYou": "有会话在等你",
+ "categories": "状态类别",
+ "noLabels": "当前范围内没有标签",
+ "clear": "清除",
+ "showCanceled": "显示已取消({{count}})",
+ "hideCanceled": "隐藏已取消"
+ },
+ "category": {
+ "backlog": "待规划",
+ "unstarted": "未开始",
+ "started": "进行中",
+ "review": "待验收",
+ "completed": "已完成",
+ "canceled": "已取消"
+ },
+ "color": {
+ "gray": "灰色",
+ "blue": "蓝色",
+ "yellow": "黄色",
+ "orange": "橙色",
+ "green": "绿色",
+ "red": "红色",
+ "purple": "紫色"
+ },
+ "priority": {
+ "none": "无优先级",
+ "urgent": "紧急",
+ "high": "高",
+ "medium": "中",
+ "low": "低"
+ },
+ "time": {
+ "updatedAgo": "{{ago}}更新",
+ "justNow": "刚刚",
+ "minutesAgo": "{{count}} 分钟前",
+ "hoursAgo": "{{count}} 小时前",
+ "daysAgo": "{{count}} 天前"
+ },
+ "field": {
+ "status": "状态",
+ "priority": "优先级",
+ "labels": "标签",
+ "parent": "父任务",
+ "project": "项目",
+ "noDescription": "无描述",
+ "criteria": "完成标准",
+ "criteriaOptional": "可选",
+ "subtasks": "子任务",
+ "addSubtask": "添加"
+ },
+ "comment": {
+ "activity": "评论和动态",
+ "placeholder": "写评论…",
+ "edit": "编辑评论",
+ "delete": "删除评论",
+ "deleteConfirm": "删除这条评论?",
+ "edited": "已编辑"
+ },
+ "detail": {
+ "missing": "这个任务已不存在。",
+ "deleteTitle": "删除「{{title}}」?",
+ "deleteWithSubtasks": "它下面各层的子任务(共 {{count}} 个)会一起删除。只删除任务记录,工作区和会话不受影响。",
+ "deleteSingle": "只删除任务记录,工作区和会话不受影响。"
+ },
+ "event": {
+ "source": {
+ "desktop": "我",
+ "mobile": "我(手机)",
+ "cli": "Orca CLI",
+ "rule": "自动规则"
+ },
+ "created": "创建了任务",
+ "renamed": "把标题改为「{{title}}」",
+ "description": "编辑了描述",
+ "criteria": "编辑了完成标准",
+ "status": "修改了状态:{{from}} → {{to}}",
+ "priority": "修改了优先级:{{from}} → {{to}}",
+ "labelsChanged": "添加了标签 {{added}},移除了 {{removed}}",
+ "labelsAdded": "添加了标签 {{added}}",
+ "labelsRemoved": "移除了标签 {{removed}}",
+ "parentSet": "把它设为「{{title}}」的子任务",
+ "parentRemoved": "把它从「{{title}}」下移出",
+ "deletedTask": "已删除的任务",
+ "removedStatus": "已删除的状态",
+ "moved": "移动了任务:{{from}} → {{to}}",
+ "workspaceStartedOn": "在 {{host}} 上的 {{name}} 开始做",
+ "workspaceStarted": "在 {{name}} 开始做",
+ "workspaceLinked": "关联了工作区 {{name}}",
+ "workspaceUnlinked": "取消关联工作区 {{name}}",
+ "workspaceHost": "工作区 {{name}} 现在运行于 {{to}}(原为 {{from}})",
+ "unnamedWorkspace": "一个工作区",
+ "sessionOpened": "打开了 {{agent}} 会话",
+ "sessionLinked": "关联了 {{agent}} 会话",
+ "sessionUnlinked": "取消关联 {{agent}} 会话",
+ "unknownAgent": "一个智能体",
+ "ruleStart": "开始做后,状态从 {{from}} 变为 {{to}}",
+ "rulePrMerged": "#{{pr}} 已合入,任务完成:{{from}} → {{to}}",
+ "commented": "发表了评论"
+ },
+ "form": {
+ "titlePlaceholder": "任务标题",
+ "descriptionPlaceholder": "添加描述…",
+ "criterionPlaceholder": "一条完成标准",
+ "addCriterion": "添加完成标准",
+ "properties": "属性",
+ "labelsPlaceholder": "用逗号分隔,例如 feature, backend",
+ "subtaskPlaceholder": "子任务标题;粘贴多行可一次添加多个",
+ "continueCreating": "继续创建",
+ "hint": "创建任务只是把它记下来,不会打开工作区。在子任务栏粘贴多行可一次添加多个。",
+ "pastedLines": "粘贴了 {{count}} 行",
+ "pasteAsSubtasks": "创建 {{count}} 个子任务",
+ "pasteAsSubtasksHint": "每行一个,放在「{{title}}」下",
+ "pasteAsTitle": "合成一个标题",
+ "pasteAsTitleHint": "换行会变成空格",
+ "savedPartially": "任务已保存,但子任务没有保存。请在任务里添加。",
+ "subtasksPartiallySaved": "子任务已创建,但部分属性没有保存。",
+ "subtasksAlreadyCreated": "这些子任务已存在。请继续保存它们,或从父任务里查看。",
+ "retrySavingSubtasks": "重试保存属性",
+ "viewCreatedSubtasks": "查看已创建的子任务"
+ },
+ "project": {
+ "settingsTitle": "设置",
+ "section": "项目",
+ "name": "名称",
+ "nameRequired": "请给项目起个名字。",
+ "rename": "重命名项目",
+ "delete": "删除项目…",
+ "deleteTitle": "删除 {{name}}?",
+ "deleteMessage": "它的任务({{count}} 个)会一起删除。关联的仓库及其工作区和会话不受影响。",
+ "deleting": "正在删除项目…",
+ "confirmDelete": "删除项目"
+ },
+ "repos": {
+ "section": "仓库",
+ "nameUnread": "仓库(尚未读到名称)",
+ "notInOrca": "已不在 Orca 里的仓库",
+ "default": "默认",
+ "noneHint": "无:这个项目只用来记录任务。「开始做」时会从这台主机上的所有仓库里选。",
+ "add": "添加仓库",
+ "atLimit": "每个项目最多关联 {{limit}} 个仓库。移除一个才能再添加。",
+ "noCandidates": "这台主机上没有其他 Orca 仓库。",
+ "makeDefault": "设为默认",
+ "remove": "从项目中移除"
+ },
+ "prefix": {
+ "label": "编号前缀",
+ "hintAliases": "之前的前缀 {{aliases}} 保留为别名:旧编号仍能找到这些任务,其他项目也不能再使用它们。",
+ "hint": "修改前缀后,旧前缀保留为别名:旧编号仍能找到这些任务,其他项目也不能再使用它。",
+ "invalid": "前缀需为 1–10 位大写字母或数字,并以字母开头。",
+ "change": "修改前缀",
+ "changeConfirm": "把 {{from}} 改为 {{to}}?{{from}} 会保留为这个项目的别名。"
+ },
+ "statuses": {
+ "section": "状态",
+ "hint": "6 个类别是固定的。在类别内可以新增、改名、改颜色和排序状态;状态不能换类别,每个类别至少保留一个状态。",
+ "add": "添加状态",
+ "taskCount": "任务:{{count}}",
+ "newName": "状态名称",
+ "rename": "改名",
+ "color": "状态颜色",
+ "moveUp": "上移",
+ "moveDown": "下移",
+ "firstInCategory": "已经是这个类别的第一个",
+ "lastInCategory": "已经是这个类别的最后一个",
+ "remove": "删除状态",
+ "removeTitle": "删除「{{name}}」?",
+ "removeUnused": "没有任务使用这个状态。",
+ "lastInCategoryRemove": "类别里唯一的状态不能删除。",
+ "moveTasksTitle": "使用这个状态的任务:{{count}} 个。请选择把它们移到哪里。"
+ },
+ "newProject": {
+ "title": "新建项目",
+ "hint": "勾选要跟踪的仓库(每个仓库一个项目),确认编号前缀后创建;也可以先建一个空项目,之后再关联仓库。",
+ "emptyTitle": "或者先建一个空项目",
+ "empty": "空项目",
+ "emptyHint": "先只记任务,之后再关联仓库",
+ "createEmpty": "新建空项目",
+ "createSelected": "创建所选({{count}})",
+ "continueWithCreated": "继续使用已创建的项目",
+ "prefixFor": "{{name}} 的前缀",
+ "reposUnavailable": "这台主机还没有发布它的仓库,所以这里列不出来。",
+ "alsoIn": "也在 {{name}} 中",
+ "noRepos": "这台主机的 Orca 里还没有仓库。可以先建一个空项目,或先在 Orca 里添加仓库。"
+ },
+ "failure": {
+ "notFound": "它已不存在,列表已刷新。",
+ "stale": "它已在别处先被修改。现在显示的是最新内容,请重新修改一次。",
+ "readOnly": "任务库是更新版本的 Orca 写入的,这里只能查看。",
+ "invalidArgument": "这台主机上的 Orca 拒绝了这些值。请检查填写内容后重试。",
+ "prefixTakenBy": "这个前缀已被项目「{{owner}}」使用。",
+ "prefixTaken": "这个前缀已被使用。",
+ "prefixHistoryInProject": "这个项目的任务编号已经用过这个前缀。",
+ "parentInvalid": "这个父任务放不下它:父任务必须在同一个项目里,且不能是任务自己或它的下级任务。",
+ "statusInvalid": "这个状态不属于这个项目。",
+ "statusInUse": "仍有 {{taskCount}} 个任务使用这个状态,请先选择把它们移到哪里。",
+ "lastStatusInCategory": "每个类别至少要保留一个状态。",
+ "commentNotEditable": "只有评论可以编辑。",
+ "workspaceAlreadyLinkedTo": "这个工作区已属于「{{task}}」,请先在那边取消关联。",
+ "workspaceAlreadyLinked": "这个工作区已属于别的任务。",
+ "taskHasWorkspaceNamed": "这个任务已在「{{name}}」里做,请先取消关联。",
+ "taskHasWorkspace": "这个任务已有工作区,请先取消关联。",
+ "workspaceRequired": "请先为任务开始做或关联一个工作区。",
+ "sessionAlreadyLinkedTo": "这个会话已关联到「{{task}}」。",
+ "sessionAlreadyLinked": "这个会话已关联到某个任务。",
+ "hostNotOwned": "这个工作区或会话在另一台 Orca 上,请在那台主机的任务里关联。",
+ "desktopOnly": "这项操作只能在桌面端或网页端进行,不能在手机上进行。",
+ "attentionItemMismatch": "这条提醒属于另一个会话,没有做任何标记。",
+ "generic": "这台主机上的 Orca 拒绝了这项操作({{code}})。",
+ "deliveryUnknown": "这台主机上的 Orca 回应前连接断开了。请重试,不会重复执行。",
+ "writeError": "无法保存修改:{{message}}"
+ },
+ "work": {
+ "snooze": {
+ "label": "稍后提醒",
+ "hour": "1 小时",
+ "tonight": "到今晚",
+ "tomorrow": "到明天",
+ "nextWeek": "到下周"
+ },
+ "attention": {
+ "segment": "需要你",
+ "homeCount": "需要你 {{count}}",
+ "unverifiable": "{{host}} 未连接,无法确认哪些需要你。",
+ "unverifiableUnnamed": "这台主机的 Orca 未连接,无法确认哪些需要你。",
+ "unverifiableHosts": "已连接的主机上没有等你处理的会话。未连接主机上的会话要等它恢复连接后才能确认。",
+ "empty": "任务上的会话都不需要你。",
+ "loading": "正在读取会话状态…"
+ },
+ "loadFailed": "无法读取工作区和会话。",
+ "attentionKind": {
+ "approval": "等你批准",
+ "question": "等你回答",
+ "blocked": "被卡住",
+ "unreadDone": "做完一轮,你还没看"
+ },
+ "agentSaid": "{{agent}}:「{{message}}」",
+ "agentStopped": "{{agent}} 停下了",
+ "someAgent": "智能体",
+ "reply": {
+ "label": "回复",
+ "title": "在「{{title}}」里回复",
+ "placeholder": "给智能体的下一句指令…",
+ "reconnecting": "正在重新连接…",
+ "disconnected": "{{host}} 未连接,回复暂时发不出去。文字保留在这里。",
+ "disconnectedUnnamed": "这台主机的 Orca 未连接,回复暂时发不出去。文字保留在这里。",
+ "sessionMissing": "这个会话的终端已不在列表里,请改为打开会话",
+ "noSession": "这个会话已不再关联到任务,回复无处可发",
+ "notConnected": "这台主机的 Orca 未连接",
+ "inputNotCleared": "会话输入框里残留的文字没能先清掉,请打开会话查看",
+ "inputRefused": "会话没有接收这段文字,智能体在等你做选择时会这样,请打开会话",
+ "chatNotReady": "聊天没有说明对话进行到哪一步,请打开会话",
+ "sending": "正在发送…",
+ "delivered": "已送达",
+ "unconfirmed": "已发送,但未确认送达。再发之前请先查看会话。",
+ "notSent": "没有发出,回复已放回输入框。",
+ "notSentBecause": "没有发出:{{reason}}。回复已放回输入框。",
+ "edit": "修改后重新发送"
+ },
+ "openSession": "打开会话",
+ "markSeen": "标记已看",
+ "itemActions": "条目操作",
+ "workspace": {
+ "section": "工作区",
+ "none": "还没有工作区。",
+ "inherited": "沿用「{{title}}」的工作区",
+ "linkExisting": "关联已有工作区",
+ "linkExistingTitle": "为「{{title}}」关联已有工作区",
+ "noneToLink": "这个项目所在的主机上没有它的工作区。",
+ "search": "搜索工作区或分支",
+ "taken": "属于「{{title}}」",
+ "notListed": "这台主机已不再列出这个工作区。",
+ "unlink": "取消关联工作区",
+ "unlinkTitle": "取消关联这个工作区?",
+ "unlinkMessage": "只移除关联,以及通过它关联的会话。工作区和其中的会话继续运行。",
+ "open": "打开工作区",
+ "gone": "工作区已被删除。"
+ },
+ "session": {
+ "section": "会话",
+ "new": "开会话",
+ "newTitle": "为「{{title}}」开会话",
+ "unlink": "取消关联会话",
+ "unlinkTitle": "取消关联这个会话?",
+ "unlinkMessage": "只移除关联,会话继续在它的工作区里运行。",
+ "actionsHint": "长按可取消关联",
+ "cannotOpenHere": "这条关联由旧版 Orca 记录,无法从这里打开。",
+ "noLongerRecorded": "任务上已不再记录这个会话。",
+ "ofSubtask": "属于「{{title}}」"
+ },
+ "start": {
+ "folderHint": "这里可用的仓库是文件夹而不是 Git 仓库,所以不会新建工作区;请关联已有工作区。",
+ "noSetupHint": "这个项目的仓库都不在这台主机上,无法在这里新建工作区。",
+ "noRepoOnHostHint": "这台主机的 Orca 里还没有仓库,无法在这里新建工作区。",
+ "label": "开始做",
+ "own": "单独开始做",
+ "again": "重新开始做",
+ "title": "为「{{title}}」开始做",
+ "whereOn": "在 {{host}} 上新建工作区 {{name}}",
+ "where": "新建工作区 {{name}}",
+ "changeLocation": "更换仓库或主机",
+ "pickLocation": "仓库和主机",
+ "workspaceOnly": "只建工作区",
+ "workspaceOnlyHint": "不启动智能体,之后再开会话",
+ "catalogUnavailable": "这台主机还没有发布它的仓库,所以暂时不能「开始做」或「关联已有工作区」。"
+ },
+ "agents": {
+ "none": "这台主机上没有找到已启用的智能体。",
+ "unreadable": "无法读取这台主机上的智能体。关闭这个面板后重试。",
+ "hostOffline": "{{host}} 未连接,无法列出它的智能体。",
+ "hostOfflineUnnamed": "工作区所在主机未连接,无法列出它的智能体。",
+ "loading": "正在查找智能体…"
+ },
+ "host": {
+ "local": "本机",
+ "offline": "{{host}} 未连接",
+ "unknown": "无法确认主机的连接",
+ "checkConnection": "检查连接"
+ },
+ "sessionState": {
+ "working": "工作中",
+ "waiting": "等待你处理",
+ "blocked": "被卡住",
+ "done": "做完一轮",
+ "idle": "空闲",
+ "exited": "已结束",
+ "missing": "找不到会话",
+ "unknown": "状态未知",
+ "unverifiable": "无法确认"
+ },
+ "review": {
+ "pr": "PR #{{number}}",
+ "mr": "MR !{{number}}",
+ "open": "{{label}} 未关闭",
+ "draft": "{{label}} 为草稿",
+ "merged": "{{label}} 已合并",
+ "closed": "{{label}} 已关闭",
+ "unverifiable": "无法确认 {{label}} 的状态"
+ },
+ "checks": {
+ "success": "检查通过",
+ "failure": "检查失败",
+ "pending": "检查进行中"
+ },
+ "launch": {
+ "starting": "正在启动…",
+ "startedNotLinked": "工作区已创建,但没有关联到任务。请用「关联已有工作区」关联它。",
+ "sessionNotRecorded": "智能体已启动,但会话没有记到任务上。它在工作区里。",
+ "promptNotDelivered": "智能体已启动,但没有接收任务内容;它显示自己的提示时可能会这样。任务内容仍在任务上。",
+ "unconfirmed": "无法确认智能体已启动。重试前请先检查工作区。",
+ "workspaceUnconfirmed": "无法确认工作区已创建。重试前请先检查工作区列表。",
+ "updateDesktop": "更新这台主机上的 Orca 后,才能从手机启动智能体。"
+ },
+ "link": {
+ "toTask": "关联到任务…",
+ "toTaskTitle": "把「{{workspace}}」关联到任务",
+ "session": "关联这个会话…",
+ "sessionTitle": "这个会话在做",
+ "noOpenTasks": "这台主机上没有未完成的任务。",
+ "hasWorkspace": "已有 {{name}}"
+ },
+ "rules": {
+ "section": "自动状态规则",
+ "start": "开始做后移到进行中",
+ "startHint": "关联工作区或开第一个会话时,处于待规划或未开始的任务移到进行中类别的第一个状态。",
+ "prMerged": "PR 合并后完成任务",
+ "prMergedHint": "在桌面端窗口打开时检查。"
+ },
+ "hostOfflineUnnamed": "工作区所在主机未连接",
+ "card": {
+ "noWorkspace": "还没有工作区",
+ "durationJustStarted": "刚开始",
+ "durationMinutes": "{{count}} 分钟",
+ "durationHours": "{{count}} 小时",
+ "durationDays": "{{count}} 天",
+ "hostOffline": "{{host}} 未连接",
+ "lastProgress": "上次进展 {{ago}}",
+ "writeback": "回写",
+ "lastLine": "最后一句",
+ "sessionStarted": "会话已开始 · 等第一次回写",
+ "fromSubtask": "{{ref}}:",
+ "subtasks": "子任务 {{count}}",
+ "subtasksNone": "还没有会话",
+ "subtasksAllDone": "都做完一轮",
+ "subtasksWaiting": "{{count}} 个在等你",
+ "subtasksWorking": "{{count}} 个工作中",
+ "subtasksLatest": "最新:{{ref}}",
+ "awaitingAcceptance": "待验收",
+ "moreInNeedsYou": "另有 {{count}} 个在「需要你」",
+ "preview": "速览",
+ "openFullSession": "打开完整会话",
+ "remindLater": "稍后提醒",
+ "goReview": "去验收"
+ },
+ "surface": {
+ "terminal": "终端",
+ "chat": "聊天"
+ },
+ "dispatch": {
+ "placeholder": "要做什么?@ 工作区或仓库,说一句话",
+ "mention": "提及工作区、仓库、任务或主机",
+ "clearTarget": "移除目标",
+ "runBy": "由 {{host}} 执行 · 任务存在它的任务库里,手机通过它读写",
+ "runByPaired": "任务存在与这台手机配对的主机的任务库里",
+ "onSendWorkspaceOn": "发送后:在 {{workspace}}({{host}})新建任务并开会话",
+ "onSendWorkspace": "发送后:在 {{workspace}} 新建任务并开会话",
+ "onSendTask": "发送后:在 {{ref}} 下继续做"
+ },
+ "mention": {
+ "title": "提及",
+ "hint": "每一行都标明所在主机;未连接主机上的内容不能选。",
+ "search": "搜索工作区、仓库、任务、主机",
+ "workspaces": "工作区",
+ "repositories": "仓库",
+ "tasks": "任务",
+ "hosts": "主机",
+ "belongsTo": "属于 {{ref}}",
+ "newWorkspaceHere": "在这里新建工作区",
+ "libraryHost": "任务库在这台主机上",
+ "sshHost": "SSH 主机",
+ "hostConnected": "已连接",
+ "hostNotConnected": "未连接"
+ }
+ },
+ "page": {
+ "homeNeedsYou": "需要你",
+ "homeInProgress": "进行中",
+ "homeLatest": "最新进展 · {{source}}",
+ "homeViewAll": "查看全部 {{count}} 条",
+ "latestProgress": "最新进展",
+ "writebackNumber": "回写 #{{number}}",
+ "noWriteback": "还没有回写",
+ "allProgress": "全部进展 {{count}}",
+ "collapse": "收起",
+ "expand": "展开",
+ "selfCheck": "自检",
+ "whereToVerify": "在哪验收",
+ "mergedInto": "合入位置"
+ },
+ "create": {
+ "stepTask": "任务",
+ "stepWhere": "在哪做",
+ "stepKickoff": "开工",
+ "moreProperties": "属性和子任务",
+ "projectFollows": "项目跟着仓库走",
+ "whereExisting": "已有工作区",
+ "whereNew": "新建工作区",
+ "whereLater": "稍后再定",
+ "searchWorkspaces": "搜索工作区",
+ "loadingWorkspaces": "正在读取工作区…",
+ "workspacesUnread": "读不到工作区列表。重新打开「新建任务」会再读一次。",
+ "noWorkspaces": "这台主机上还没有工作区",
+ "noMatches": "没有匹配的工作区",
+ "showAll": "显示全部 {{count}} 个",
+ "mainWorkspace": "主工作区",
+ "folderWorkspace": "文件夹工作区",
+ "free": "空闲",
+ "ownedSubtask": "属于 {{ref}} · 会建成它的子任务",
+ "noProjectHere": "这里没有能放新任务的项目",
+ "notConnected": "未连接",
+ "hostNotConnected": "{{host}} 未连接",
+ "repo": "仓库",
+ "name": "名称",
+ "nameAuto": "按任务编号和标题命名",
+ "base": "基于",
+ "baseDefault": "默认分支",
+ "repoOnHost": "{{host}} 上的 {{repo}}",
+ "newNote": "用现有的新建工作区流程,在 {{host}} 上建工作区。",
+ "offlineReposNote": "{{hosts}} 未连接,它的仓库现在不能选。",
+ "loadingRepos": "正在读取仓库…",
+ "noRepos": "这台主机上没有能新建工作区的仓库。",
+ "catalogUnavailable": "这台主机还没公布它的仓库,暂时不能在这里新建工作区。",
+ "laterNote": "只建任务:不建工作区,不开会话。之后在任务里开始做。",
+ "noSite": "这个页面连不到任务库所在的主机,只会建任务。",
+ "libraryOffline": "{{host}} 未连接,现在只能建任务。连上后在任务里开始做。",
+ "defaultAgent": "默认智能体",
+ "notNow": "先不开",
+ "notNowHint": "只建任务和工作区",
+ "moreAgents": "其他智能体",
+ "agentsAfterWhere": "选好在哪做后才会列出智能体。",
+ "agentsUnreadable": "读不到这台主机的智能体。",
+ "firstLine": "第一句指令",
+ "firstLineDefault": "按任务说明开始",
+ "sendNow": "直接发送",
+ "sendFixed": "手机固定",
+ "sendFixedHint": "手机不能先放进输入框:主机不会投递从手机发起的草稿。",
+ "briefNote": "会话还会收到任务简报:编号、怎么回写进展、做完停在待验收。可在设置里关闭。",
+ "planLater": "只建任务,稍后再开始",
+ "planPickWorkspace": "选一个工作区",
+ "planPickRepo": "选一个仓库",
+ "planFindingAgents": "正在查找智能体…",
+ "workspaceOnHost": "{{workspace}}({{host}})",
+ "planExistingSession": "在 {{place}} 开 {{agent}} 会话",
+ "planExistingLink": "在 {{place}} 做,不开会话",
+ "planAsSubtask": "作为 {{ref}} 的子任务:{{plan}}",
+ "autoName": "(按任务命名)",
+ "planNewSession": "在 {{host}} 新建工作区 {{workspace}},开 {{agent}} 会话",
+ "planNewOnly": "在 {{host}} 新建工作区 {{workspace}},不开会话",
+ "createAndStart": "创建并开工",
+ "nothingCreated": "什么都没有创建:{{reason}}",
+ "createdNotLinked": "已创建 {{ref}},但 {{workspace}} 没有关联上。{{reason}}",
+ "createdNotStarted": "已创建 {{ref}}。{{reason}}"
+ }
}
}
diff --git a/mobile/src/local-tasks/LocalTaskSessionStrip.tsx b/mobile/src/local-tasks/LocalTaskSessionStrip.tsx
index bdc7ce464540..b8a76544e770 100644
--- a/mobile/src/local-tasks/LocalTaskSessionStrip.tsx
+++ b/mobile/src/local-tasks/LocalTaskSessionStrip.tsx
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { ClipboardList } from 'lucide-react-native'
+import { matchesLocalTaskQuery } from '../../../src/shared/local-tasks/local-task-grouping'
import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
import { AgentStateDot } from '../components/AgentStateDot'
import { useResponsiveLayout } from '../layout/responsive-layout'
@@ -82,9 +83,11 @@ export function LocalTaskSessionStrip({ controller }: { controller: StripControl
return (
+ {/* Room for one: the title, with the ref for a screen reader (§4). */}
router.push(
localTasksRouteHref(
@@ -143,15 +146,23 @@ function LinkSessionAction(props: {
const [open, setOpen] = useState(false)
const [sessionIds] = useState(() => createLocalTaskOperationIds())
const [error, setError] = useState(null)
+ const [query, setQuery] = useState('')
+ // No project here to resolve an earlier prefix with; the current ref and the title still match.
+ const shown = props.choices.filter((entry) => matchesLocalTaskQuery(entry, undefined, query))
+ const close = () => {
+ setError(null)
+ setQuery('')
+ setOpen(false)
+ }
const linkSession = async (taskId: string) => {
const outcome = await sendLocalTaskWrite(
sessionIds,
{ ...props.record, taskId, linkedVia: 'manual' as const },
createLocalTasksApi(props.client).recordSession
)
- setError(localTaskWriteMessage(copy, outcome))
+ setError(localTaskWriteMessage(copy, outcome, props.choices))
if (outcome.kind === 'ok') {
- setOpen(false)
+ close()
props.onLinked()
}
}
@@ -164,16 +175,18 @@ function LinkSessionAction(props: {
visible={open}
title={copy.work.linkSessionTitle}
error={error}
- choices={props.choices.map((entry) => ({
+ search={
+ props.choices.length > 1
+ ? { query, placeholder: copy.searchPlaceholder, onChange: setQuery }
+ : null
+ }
+ choices={shown.map((entry) => ({
key: entry.id,
label: entry.title,
subtitle: entry.ref,
onPress: () => void linkSession(entry.id)
}))}
- onClose={() => {
- setError(null)
- setOpen(false)
- }}
+ onClose={close}
/>
>
)
diff --git a/mobile/src/local-tasks/MobileHomeLocalTasksCard.test.tsx b/mobile/src/local-tasks/MobileHomeLocalTasksCard.test.tsx
index 72f231222086..c6f0300129e1 100644
--- a/mobile/src/local-tasks/MobileHomeLocalTasksCard.test.tsx
+++ b/mobile/src/local-tasks/MobileHomeLocalTasksCard.test.tsx
@@ -49,6 +49,7 @@ vi.mock('../transport/client-context', () => {
})
import { localTasksCopy } from './local-tasks-copy'
+import { localTasksPageCopy } from './local-tasks-page-copy'
import { MobileHomeLocalTasksCard } from './MobileHomeLocalTasksCard'
function host(id: string): HostProfile {
@@ -78,7 +79,9 @@ function desktop(id: string, projectCount: number, sourceHidden?: boolean) {
projectCount,
...(sourceHidden === undefined ? {} : { sourceHidden })
},
- 'localTasks.attention.get': { observedAt: 1, sessions: [], items: [] }
+ 'localTasks.attention.get': { observedAt: 1, sessions: [], items: [] },
+ 'localTasks.tasks.list': { tasks: [] },
+ 'localTasks.projects.list': { projects: [] }
}
const client: RpcClient = {
sendRequest: async (method: string): Promise => {
@@ -169,10 +172,8 @@ describe('the home local tasks card', () => {
await show(['home-hidden', 'home-older'])
expect(hidden).toEqual(['status.get', 'localTasks.status'])
// Only the older host's 3 in progress: the hidden library is neither read further nor counted.
- expect(texts()).toEqual([
- copy.homeCardTitle,
- `${copy.work.homeNeedsYou(0)} · ${copy.homeCardInProgress(3)}`
- ])
+ const page = localTasksPageCopy()
+ expect(texts()).toEqual([copy.homeCardTitle, '0', page.homeNeedsYou, '3', page.homeInProgress])
// Out of contact later, a hidden library is no "cannot connect" line either.
env.states['home-hidden'] = 'disconnected'
diff --git a/mobile/src/local-tasks/MobileHomeLocalTasksCard.tsx b/mobile/src/local-tasks/MobileHomeLocalTasksCard.tsx
index 6cc352d86fc2..69f4b72fd36e 100644
--- a/mobile/src/local-tasks/MobileHomeLocalTasksCard.tsx
+++ b/mobile/src/local-tasks/MobileHomeLocalTasksCard.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronRight, ClipboardList, Monitor } from 'lucide-react-native'
import { PickerModal } from '../components/PickerModal'
@@ -6,8 +7,9 @@ import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { HostProfile } from '../transport/types'
import { localTaskHostMachineName } from './local-tasks-access'
-import { localTasksCopy } from './local-tasks-copy'
+import { formatLocalTaskAgo, localTasksCopy } from './local-tasks-copy'
import { localTaskAttentionLine } from './local-task-attention-row'
+import { localTasksPageCopy } from './local-tasks-page-copy'
import type { LocalTaskHomeAttentionRow } from './local-task-home-attention'
import { localTasksRouteTarget } from './local-tasks-route'
import { useLocalTaskHomeHosts, type LocalTaskHomeHost } from './use-local-task-home-hosts'
@@ -23,9 +25,8 @@ export type LocalTaskHomeCardRow = LocalTaskHomeAttentionRow & {
hostLabel: string | null
}
-/** Counts and the tasks waiting longest across every library of every connected host. */
+/** Counts, "Needs you" rows and the newest writeback across every library of every connected host. */
export function summarizeLocalTaskHomeHosts(ready: readonly ReadyHost[]) {
- const copy = localTasksCopy()
const libraries = ready.flatMap((entry) =>
entry.libraries.map((library) => ({ hostId: entry.host.id, library }))
)
@@ -34,21 +35,27 @@ export function summarizeLocalTaskHomeHosts(ready: readonly ReadyHost[]) {
library.attention === 'error' ? [] : [library.attention]
)
const needsYou = read.reduce((sum, attention) => sum + attention.count, 0)
- const parts: string[] = []
- if (read.length > 0) {
- parts.push(copy.work.homeNeedsYou(needsYou))
- }
- parts.push(
- libraries.some(({ library }) => library.inProgress === 'error')
- ? copy.homeCardError
- : copy.homeCardInProgress(
- libraries.reduce(
- (sum, { library }) =>
- sum + (typeof library.inProgress === 'number' ? library.inProgress : 0),
- 0
- )
- )
- )
+ const inProgress = libraries.some(({ library }) => library.inProgress === 'error')
+ ? null
+ : libraries.reduce(
+ (sum, { library }) =>
+ sum + (typeof library.inProgress === 'number' ? library.inProgress : 0),
+ 0
+ )
+ const latest =
+ libraries
+ .flatMap(({ hostId, library }) =>
+ library.attention !== 'error' && library.attention.latest
+ ? [
+ {
+ ...library.attention.latest,
+ hostId,
+ ...(library.executionHost ? { executionHost: library.executionHost } : {})
+ }
+ ]
+ : []
+ )
+ .sort((a, b) => b.at - a.at)[0] ?? null
const rows: LocalTaskHomeCardRow[] = libraries
.flatMap(({ hostId, library }) =>
library.attention === 'error'
@@ -60,9 +67,16 @@ export function summarizeLocalTaskHomeHosts(ready: readonly ReadyHost[]) {
hostLabel: named ? library.label : null
}))
)
- .sort((a, b) => a.since - b.since)
.slice(0, HOME_ROWS)
- return { subtitle: parts.join(' · '), rows, needsYou: needsYou > 0 }
+ return {
+ /** Null until some library's "Needs you" was read. */
+ needsYou: read.length > 0 ? needsYou : null,
+ /** Null when some library's count could not be read. */
+ inProgress,
+ rows,
+ latest,
+ openCount: read.reduce((sum, attention) => sum + attention.openCount, 0)
+ }
}
/** Where a host's entry lands: the first of its libraries with something that needs you. */
@@ -87,13 +101,19 @@ export function MobileHomeLocalTasksCard(props: { connectedHosts: HostProfile[]
const hosts = useLocalTaskHomeHosts(props.connectedHosts)
const openHostStackRoute = useOpenHostStackRoute()
const picker = useConnectedHostPicker(props.connectedHosts)
+ // Why tied to the reads: relative times move when home reads again, and no clock ticks for them.
+ const [now, setNow] = useState(() => Date.now())
+ useEffect(() => {
+ setNow(Date.now())
+ }, [hosts])
const ready = hosts.flatMap((entry) => (entry.kind === 'ready' ? [entry] : []))
const unreachable = hosts.flatMap((entry) => (entry.kind === 'unreachable' ? [entry] : []))
if (ready.length === 0 && unreachable.length === 0) {
return null
}
const summary = summarizeLocalTaskHomeHosts(ready)
- const openRow = (row: LocalTaskHomeCardRow) =>
+ const page = localTasksPageCopy()
+ const openTask = (row: { hostId: string; taskId: string; executionHost?: `runtime:${string}` }) =>
openHostStackRoute(
row.hostId,
localTasksRouteTarget(
@@ -102,23 +122,26 @@ export function MobileHomeLocalTasksCard(props: { connectedHosts: HostProfile[]
row.executionHost ? { executionHost: row.executionHost } : undefined
)
)
+ const openEntry = (attention: boolean) =>
+ picker.pickHost(
+ (hostId) => {
+ const host = ready.find((entry) => entry.host.id === hostId)
+ if (host) {
+ openHostStackRoute(
+ hostId,
+ attention ? homeEntryTarget(host) : localTasksRouteTarget(hostId)
+ )
+ }
+ },
+ ready.map((entry) => entry.host)
+ )
return (
{ready.length > 0 ? (
[styles.header, pressed && styles.cardPressed]}
- onPress={() =>
- picker.pickHost(
- (hostId) => {
- const host = ready.find((entry) => entry.host.id === hostId)
- if (host) {
- openHostStackRoute(hostId, homeEntryTarget(host))
- }
- },
- ready.map((entry) => entry.host)
- )
- }
+ onPress={() => openEntry(true)}
accessibilityRole="button"
accessibilityLabel={copy.homeCardTitle}
>
@@ -127,28 +150,90 @@ export function MobileHomeLocalTasksCard(props: { connectedHosts: HostProfile[]
{copy.homeCardTitle}
-
- {summary.subtitle}
-
+
+ openEntry(true)}
+ accessibilityRole="button"
+ testID="local-tasks-home-needs-you"
+ >
+ {summary.needsYou ?? '–'}
+ {page.homeNeedsYou}
+
+
+ openEntry(false)}
+ accessibilityRole="button"
+ testID="local-tasks-home-in-progress"
+ >
+ {summary.inProgress ?? '–'}
+
+ {summary.inProgress === null ? copy.homeCardError : page.homeInProgress}
+
+
+
{summary.rows.map((row) => (
[styles.row, pressed && styles.cardPressed]}
- onPress={() => openRow(row)}
+ onPress={() => openTask(row)}
accessibilityRole="button"
>
+
+ {row.ref}
+
+ {row.item
+ ? copy.work.attentionKinds[row.item.kind]
+ : copy.work.card.awaitingAcceptance}
+
+
+ {formatLocalTaskAgo(copy, row.since, now)}
+
+
- {row.title}
+ {row.item ? localTaskAttentionLine(row.item) : row.title}
{row.hostLabel ? ` · ${row.hostLabel}` : ''}
-
- {copy.work.attentionKinds[row.item.kind]} · {localTaskAttentionLine(row.item)}
-
))}
+ {summary.latest ? (
+ [styles.row, pressed && styles.cardPressed]}
+ onPress={() => summary.latest && openTask(summary.latest)}
+ accessibilityRole="button"
+ >
+
+ {summary.latest.ref}
+
+ {page.homeLatest(
+ summary.latest.writeback ? copy.work.card.writeback : copy.work.card.lastLine
+ )}
+
+
+ {formatLocalTaskAgo(copy, summary.latest.at, now)}
+
+
+
+ {summary.latest.body}
+
+
+ ) : null}
+ {summary.openCount > 0 ? (
+ [styles.viewAll, pressed && styles.cardPressed]}
+ onPress={() => openEntry(false)}
+ accessibilityRole="button"
+ >
+
+ {page.homeViewAll(summary.openCount)}
+
+
+
+ ) : null}
) : null}
{unreachable.map(({ host, pairingInvalid }) => (
@@ -198,8 +283,47 @@ const styles = StyleSheet.create({
stack: { gap: spacing.sm, marginTop: spacing.sm },
column: { flexDirection: 'column', alignItems: 'stretch' },
header: { flexDirection: 'row', alignItems: 'center' },
- // Why 60: the icon's 46 plus its 14 margin, so rows align under the title.
- row: { paddingTop: spacing.sm, paddingLeft: 60, gap: 2 },
+ counters: {
+ flexDirection: 'row',
+ alignItems: 'stretch',
+ marginTop: spacing.md,
+ paddingTop: spacing.sm,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.borderSubtle
+ },
+ counter: { flex: 1, flexDirection: 'row', alignItems: 'baseline', gap: 6 },
+ count: { fontSize: 24, fontWeight: '700', color: colors.textPrimary },
+ divider: {
+ width: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle,
+ marginRight: 14
+ },
+ attention: { color: colors.statusAmber },
+ review: { color: colors.statusGreen },
+ row: {
+ paddingVertical: spacing.sm,
+ gap: 2,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.borderSubtle
+ },
+ rowHead: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ ref: {
+ fontFamily: typography.monoFamily,
+ color: colors.textMuted,
+ fontSize: typography.metaSize
+ },
+ kind: { color: colors.textSecondary, fontSize: typography.metaSize, fontWeight: '600' },
+ time: { marginLeft: 'auto' },
rowTitle: { color: colors.textPrimary, fontSize: typography.bodySize },
- rowMeta: { color: colors.textMuted, fontSize: typography.metaSize }
+ rowMeta: { color: colors.textMuted, fontSize: typography.metaSize },
+ viewAll: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 4,
+ paddingTop: spacing.sm,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.borderSubtle
+ },
+ viewAllText: { fontSize: typography.bodySize, fontWeight: '600' }
})
diff --git a/mobile/src/local-tasks/local-task-activity-section.tsx b/mobile/src/local-tasks/local-task-activity-section.tsx
index a98b3fcf4744..a643986f366e 100644
--- a/mobile/src/local-tasks/local-task-activity-section.tsx
+++ b/mobile/src/local-tasks/local-task-activity-section.tsx
@@ -1,26 +1,31 @@
import { useState } from 'react'
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
-import { Pencil, Send, Trash2, X } from 'lucide-react-native'
+import { ChevronDown, ChevronRight, Pencil, Send, Trash2, X } from 'lucide-react-native'
import type {
+ LocalTask,
LocalTaskEvent,
LocalTaskStatusDef
} from '../../../src/shared/local-tasks/local-task-types'
+import { isLocalTaskProgressPayload } from '../../../src/shared/local-tasks/local-task-progress'
import { ActionSheetModal } from '../components/ActionSheetModal'
import { ConfirmModal } from '../components/ConfirmModal'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { LocalTasksApi } from './local-tasks-api'
import { formatLocalTaskAgo, localTasksCopy } from './local-tasks-copy'
+import { localTasksPageCopy } from './local-tasks-page-copy'
import { describeLocalTaskEvent } from './local-task-event-copy'
import { localTaskWriteMessage } from './local-task-failure-message'
import { createLocalTaskOperationIds, sendLocalTaskWrite } from './local-task-operation-ids'
import { localTaskStyles } from './local-tasks-styles'
-import { useLocalTaskEvents } from './use-local-tasks-library'
+import type { useLocalTaskEvents } from './use-local-tasks-library'
type Props = {
api: LocalTasksApi
taskId: string
- eventsVersion: number
+ /** Read once by the page, which also numbers the writebacks from them. */
+ events: ReturnType
statuses: Map
+ tasksById: ReadonlyMap
now: number
readOnly: boolean
canRead: boolean
@@ -29,11 +34,8 @@ type Props = {
export function LocalTaskActivitySection(props: Props) {
const copy = localTasksCopy()
- const {
- events,
- error: eventsError,
- retry
- } = useLocalTaskEvents(props.canRead ? props.api : null, props.taskId, props.eventsVersion)
+ const page = localTasksPageCopy()
+ const { events, error: eventsError, retry } = props.events
const [ids] = useState(() => ({
add: createLocalTaskOperationIds(),
edit: createLocalTaskOperationIds(),
@@ -44,6 +46,7 @@ export function LocalTaskActivitySection(props: Props) {
const [menuFor, setMenuFor] = useState(null)
const [removing, setRemoving] = useState(null)
const [busy, setBusy] = useState(false)
+ const [showSystemRecords, setShowSystemRecords] = useState(false)
const [error, setError] = useState(null)
const submit = async () => {
@@ -77,36 +80,89 @@ export function LocalTaskActivitySection(props: Props) {
}
}
- const ordered = [...events].sort((a, b) => a.createdAt - b.createdAt)
+ const ordered = [...events]
+ .filter((event) => event.kind !== 'turn-ended')
+ .sort((a, b) => a.createdAt - b.createdAt)
+ const systemCount = ordered.filter((event) => event.kind !== 'comment').length
return (
{copy.activity}
- {ordered.map((event) => (
+ {/* System records sit folded in one line above the conversation (m4). */}
+ {systemCount > 0 ? (
setMenuFor(event)}
- delayLongPress={350}
+ onPress={() => setShowSystemRecords((shown) => !shown)}
+ accessibilityRole="button"
+ accessibilityState={{ expanded: showSystemRecords }}
+ style={styles.systemToggle}
>
-
-
-
- {copy.eventSources[event.source]}{' '}
- {describeLocalTaskEvent(copy, event, props.statuses)}{' '}
-
- {formatLocalTaskAgo(copy, event.createdAt, props.now)}
- {event.editedAt ? ` · ${copy.edited}` : ''}
-
-
- {event.kind === 'comment' && event.body ? (
-
- {event.body}
-
- ) : null}
-
+ {showSystemRecords ? (
+
+ ) : (
+
+ )}
+
+ {copy.systemRecords} {systemCount} · {showSystemRecords ? page.collapse : page.expand}
+
- ))}
+ ) : null}
+ {showSystemRecords
+ ? ordered
+ .filter((event) => event.kind !== 'comment')
+ .map((event) => {
+ const line = describeLocalTaskEvent(copy, event, props.statuses, props.tasksById)
+ return (
+
+
+
+ {line.actor ? `${line.actor} ` : ''}
+ {line.text}{' '}
+
+ {formatLocalTaskAgo(copy, event.createdAt, props.now)}
+
+
+
+ )
+ })
+ : null}
+ {ordered
+ .filter((event) => event.kind === 'comment')
+ .map((event) => {
+ const line = describeLocalTaskEvent(copy, event, props.statuses, props.tasksById)
+ return (
+ setMenuFor(event)}
+ delayLongPress={350}
+ >
+
+
+
+ {line.actor ? (
+ <>
+ {line.actor}{' '}
+ >
+ ) : null}
+ {line.text}{' '}
+
+ {formatLocalTaskAgo(copy, event.createdAt, props.now)}
+ {event.editedAt ? ` · ${copy.edited}` : ''}
+
+
+ {event.kind === 'comment' && event.body ? (
+
+ {event.body}
+
+ ) : null}
+
+
+ )
+ })}
{eventsError && props.canRead ? (
@@ -194,6 +250,12 @@ export function LocalTaskActivitySection(props: Props) {
}
const styles = StyleSheet.create({
+ systemToggle: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ paddingVertical: spacing.sm
+ },
entry: { flexDirection: 'row', gap: spacing.sm, paddingVertical: 6 },
dot: {
width: 7,
diff --git a/mobile/src/local-tasks/local-task-agent-launch.test.ts b/mobile/src/local-tasks/local-task-agent-launch.test.ts
index 0a40120913e9..d77b3deebedc 100644
--- a/mobile/src/local-tasks/local-task-agent-launch.test.ts
+++ b/mobile/src/local-tasks/local-task-agent-launch.test.ts
@@ -241,4 +241,60 @@ describe('Start with an agent', () => {
paneKey: HOST_PANE
})
})
+
+ it('says so when the agent started but the task text was not delivered', async () => {
+ const { agentLaunchReplayRun } = await import('../tasks/mobile-workspace-create-operations')
+ vi.mocked(agentLaunchReplayRun.interpret).mockReturnValueOnce({
+ worktreeId: 'repo::/shop-12',
+ outcome: { kind: 'terminal', handle: 'term_1', paneKey: HOST_PANE },
+ receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: '' },
+ // A submit the agent never took, e.g. while its own trust prompt held the terminal.
+ prompt: { delivery: 'submit', outcome: 'not-delivered' }
+ })
+ const api = createLocalTasksApi({
+ sendRequest: async () => {
+ throw new Error('unexpected RPC')
+ }
+ })
+ vi.spyOn(api, 'linkWorkspace').mockResolvedValue({
+ kind: 'ok',
+ value: {
+ ok: true,
+ workspace: {
+ taskId: 'shop-12',
+ hostId: 'local',
+ kind: 'git-worktree',
+ instanceId: null,
+ worktreeId: 'repo::/shop-12',
+ folderWorkspaceId: null,
+ repoId: 'repo',
+ branchSnapshot: null,
+ nameSnapshot: 'shop-12',
+ hostLabelSnapshot: null,
+ linkedVia: 'start',
+ linkedAt: 1
+ }
+ }
+ })
+ vi.spyOn(api, 'recordSession').mockResolvedValue({ kind: 'unknown' })
+ const result = await startLocalTaskInNewWorkspace({
+ launchClient: new FakeSession('connected'),
+ hostCapabilities: [
+ AGENT_LAUNCH_RUNTIME_CAPABILITY,
+ AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY
+ ],
+ api,
+ ids: { link: createLocalTaskOperationIds(), record: createLocalTaskOperationIds() },
+ workspaceTaskId: 'shop-12',
+ sessionTaskId: 'shop-12',
+ setup: { hostId: 'local', repoId: 'repo', label: '' },
+ name: 'shop-12',
+ agent: 'claude',
+ prompt: { text: 'Task SHOP-12', delivery: 'submit' }
+ })
+ expect(result).toMatchObject({ promptNotDelivered: true })
+ expect(localTaskLaunchMessage(localTasksCopy(), result)).toContain(
+ localTasksCopy().work.promptNotDelivered
+ )
+ })
})
diff --git a/mobile/src/local-tasks/local-task-agent-launch.ts b/mobile/src/local-tasks/local-task-agent-launch.ts
index b1094a3a2856..8d2c4c3ccfd7 100644
--- a/mobile/src/local-tasks/local-task-agent-launch.ts
+++ b/mobile/src/local-tasks/local-task-agent-launch.ts
@@ -24,13 +24,14 @@ import { readNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-cap
import { agentLaunchReplayRun } from '../tasks/mobile-workspace-create-operations'
import { sendReplayingAmbiguousDelivery } from '../tasks/replay-on-ambiguous-delivery'
import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout'
+import type { WorkspaceCreateParams } from '../tasks/workspace-create-params'
import type { RpcClient } from '../transport/rpc-client'
import type { LocalTasksApi, LocalTaskWriteOutcome } from './local-tasks-api'
import { sendLocalTaskWrite, type LocalTaskOperationIds } from './local-task-operation-ids'
export type LocalTaskLaunchOutcome =
/** The agent runs and the task records it; `paneKey` opens its pane. */
- | { kind: 'done'; worktreeId: string; paneKey: string | null }
+ | { kind: 'done'; worktreeId: string; paneKey: string | null; promptNotDelivered?: true }
/** The workspace is created and linked, with no agent session by choice. */
| { kind: 'workspace-created'; worktreeId: string }
/** Nothing started. */
@@ -42,10 +43,25 @@ export type LocalTaskLaunchOutcome =
/** The workspace exists, the link was not written: no link, no session record. */
| { kind: 'not-linked'; worktreeId: string; write: LocalTaskWriteOutcome }
/** The agent runs in the linked workspace, but its session is not on the task. */
- | { kind: 'session-not-recorded'; worktreeId: string; paneKey: string | null }
+ | {
+ kind: 'session-not-recorded'
+ worktreeId: string
+ paneKey: string | null
+ promptNotDelivered?: true
+ }
export type LocalTaskLaunchIds = { link: LocalTaskOperationIds; record: LocalTaskOperationIds }
+/** Create From's own choices; Start leaves both to the host's defaults. */
+export type LocalTaskCreateChoices = Pick
+
+function createChoiceFields(choices: LocalTaskCreateChoices | undefined) {
+ return {
+ setupDecision: choices?.setupDecision ?? ('inherit' as const),
+ ...(choices?.comment ? { comment: choices.comment } : {})
+ }
+}
+
type SessionTask = { taskId: string; hostId: string }
type LaunchedSession = {
@@ -104,9 +120,12 @@ async function recordLaunchedSession(args: {
args.api.recordSession
)
const paneKey = session.paneKey
+ // Why: the host keeps nothing it could not hand over; the user must hear the text did not land.
+ const lost =
+ args.result.prompt?.outcome === 'not-delivered' ? ({ promptNotDelivered: true } as const) : {}
return write.kind === 'ok'
- ? { kind: 'done', worktreeId: args.worktreeId, paneKey }
- : { kind: 'session-not-recorded', worktreeId: args.worktreeId, paneKey }
+ ? { kind: 'done', worktreeId: args.worktreeId, paneKey, ...lost }
+ : { kind: 'session-not-recorded', worktreeId: args.worktreeId, paneKey, ...lost }
}
/** Start: one `agent.launchReplay` creates the workspace and starts the agent, then the links. */
@@ -126,6 +145,9 @@ export async function startLocalTaskInNewWorkspace(args: {
agent: TuiAgent | null
prompt: AgentLaunchPrompt
reservation?: MobileAgentLaunchReservation
+ choices?: LocalTaskCreateChoices
+ /** Start work again: the link replaces the one to a deleted workspace. */
+ replace?: boolean
}): Promise {
if (args.agent === null) {
return createLocalTaskWorkspaceOnly(args)
@@ -146,7 +168,7 @@ export async function startLocalTaskInNewWorkspace(args: {
name: args.name,
displayName: args.name,
displayNameKind: 'user' as const,
- setupDecision: 'inherit' as const
+ ...createChoiceFields(args.choices)
})
},
prompt: args.prompt,
@@ -195,7 +217,8 @@ export async function startLocalTaskInNewWorkspace(args: {
repoId: args.setup.repoId,
nameSnapshot: args.name,
...(args.setup.label ? { hostLabelSnapshot: args.setup.label } : {}),
- linkedVia: 'start' as const
+ linkedVia: 'start' as const,
+ ...(args.replace ? { replace: true } : {})
},
args.api.linkWorkspace
)
@@ -225,6 +248,8 @@ async function createLocalTaskWorkspaceOnly(args: {
workspaceTaskId: string
setup: { hostId: string; repoId: string; label: string }
name: string
+ choices?: LocalTaskCreateChoices
+ replace?: boolean
}): Promise {
let created
try {
@@ -238,7 +263,7 @@ async function createLocalTaskWorkspaceOnly(args: {
name,
displayName: name,
displayNameKind: 'user' as const,
- setupDecision: 'inherit' as const
+ ...createChoiceFields(args.choices)
})
})
} catch {
@@ -257,7 +282,8 @@ async function createLocalTaskWorkspaceOnly(args: {
repoId: args.setup.repoId,
nameSnapshot: args.name,
...(args.setup.label ? { hostLabelSnapshot: args.setup.label } : {}),
- linkedVia: 'start' as const
+ linkedVia: 'start' as const,
+ ...(args.replace ? { replace: true } : {})
},
args.api.linkWorkspace
)
diff --git a/mobile/src/local-tasks/local-task-attention-row.tsx b/mobile/src/local-tasks/local-task-attention-row.tsx
index dc60cfcf20fa..0032d68349fe 100644
--- a/mobile/src/local-tasks/local-task-attention-row.tsx
+++ b/mobile/src/local-tasks/local-task-attention-row.tsx
@@ -1,21 +1,44 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label'
-import type { LocalTaskAttentionItem } from '../../../src/shared/local-tasks/local-task-link-types'
+import type {
+ LocalTaskAttentionItem,
+ LocalTaskAttentionKind
+} from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTaskStatusDef } from '../../../src/shared/local-tasks/local-task-types'
+import { AgentStateDot } from '../components/AgentStateDot'
import { colors, spacing, typography } from '../theme/mobile-theme'
+import {
+ LocalTaskCardButton,
+ LocalTaskCardLatestLine,
+ LocalTaskCardMeta,
+ LocalTaskSubtaskSummary,
+ localTaskCardStyles,
+ localTaskLeadItem
+} from './local-task-card'
+import type { LocalTaskCardFacts } from './local-task-card-facts'
import { formatLocalTaskAgo, localTasksCopy } from './local-tasks-copy'
import type { LocalTaskAttentionRow } from './local-task-progress-projection'
import { localTaskStyles } from './local-tasks-styles'
+import { LocalTaskStatusIcon } from './local-tasks-visuals'
import type { LocalTaskReplyStatus } from './use-local-task-attention-actions'
-type ItemHandlers = {
+export type LocalTaskAttentionItemHandlers = {
errors: Record
replyFor: (itemKey: string) => LocalTaskReplyStatus | null
- onOpenSession: (item: LocalTaskAttentionItem) => void
+ /** A chat session opens in the drawer; a terminal one opens its session page. */
+ onPreview: (taskSessionId: string) => void
+ onOpenSession: (taskSessionId: string) => void
onReply: (item: LocalTaskAttentionItem) => void
- /** Both write to the library, so they are off while it cannot take writes. */
+ /** Snoozing writes to the library, so it is off while it cannot take writes. */
canWrite: boolean
- onMarkSeen: (item: LocalTaskAttentionItem) => void
- onSnooze: (item: LocalTaskAttentionItem) => void
+ onSnooze: (items: LocalTaskAttentionItem[]) => void
+}
+
+const ATTENTION_DOT: Record = {
+ approval: 'waiting',
+ question: 'waiting',
+ blocked: 'blocked',
+ 'unread-done': 'done'
}
/** What an agent said, as the item states it: "finished a turn" names the agent, never the task. */
@@ -48,124 +71,227 @@ function ReplyState(props: { item: LocalTaskAttentionItem; reply: LocalTaskReply
)
}
-function ItemButton(props: {
- label: string
- testID?: string
- disabled?: boolean
- onPress: () => void
+/** A reply typed from the list: only a finished turn takes one; its outcome stays on the card. */
+function ReplyButton(props: {
+ item: LocalTaskAttentionItem
+ handlers: LocalTaskAttentionItemHandlers
}) {
+ const work = localTasksCopy().work
+ const reply = props.handlers.replyFor(props.item.key)
return (
props.handlers.onReply(props.item)}
>
- {props.label}
+
+ {reply?.kind === 'not-sent' ? work.editReply : work.reply}
+
)
}
-/**
- * One item: what it waits for and whose it is on one line, as other rows pair an agent with its
- * state, then its last words and every action in view. Swiping the row still marks or snoozes it
- * whole; the buttons are what a page without swipe hints can be seen to offer.
- */
-function AttentionItemLine(props: { item: LocalTaskAttentionItem; handlers: ItemHandlers }) {
+function ItemOutcome(props: {
+ item: LocalTaskAttentionItem
+ handlers: LocalTaskAttentionItemHandlers
+}) {
+ const reply = props.handlers.replyFor(props.item.key)
+ const error = props.handlers.errors[props.item.key]
+ return (
+ <>
+ {reply ? : null}
+ {error ? {error} : null}
+ >
+ )
+}
+
+/** Another session of the same task waiting too: what it waits for, and its own way in. */
+function OtherItemLine(props: {
+ item: LocalTaskAttentionItem
+ handlers: LocalTaskAttentionItemHandlers
+}) {
const work = localTasksCopy().work
const { item, handlers } = props
- const reply = handlers.replyFor(item.key)
- const error = handlers.errors[item.key]
- const sending = reply?.kind === 'sending'
return (
-
-
- {work.attentionKinds[item.kind]}
- {item.agent ? (
- · {formatAgentTypeLabel(item.agent)}
- ) : null}
-
- {item.lastMessage ? (
-
- {item.lastMessage}
+
+ handlers.onPreview(item.taskSessionId)}
+ accessibilityRole="button"
+ >
+
+
+ {`${work.attentionKinds[item.kind]} · ${localTaskAttentionLine(item)}`}
- ) : null}
-
- {item.replyable ? (
- handlers.onReply(item)}
- />
- ) : null}
- handlers.onOpenSession(item)} />
- handlers.onMarkSeen(item)}
- />
- handlers.onSnooze(item)}
- />
-
- {reply ? : null}
- {error ? {error} : null}
+
+ {item.replyable ? : null}
+
)
}
-/** One task's items: ref and title once, then each item with its own actions. */
+/**
+ * One task in "Needs you": a session waiting on the user (its question and three ways to act on
+ * it), or a task awaiting acceptance (its latest line, its subtasks, Preview, Review, Remind later).
+ */
export function LocalTaskAttentionCard(props: {
row: LocalTaskAttentionRow
+ facts: LocalTaskCardFacts
+ status: LocalTaskStatusDef | null
+ /** For a review card: the PR as its host has it, when read. */
+ reviewDetail: string | null
now: number
- handlers: ItemHandlers
+ first: boolean
+ handlers: LocalTaskAttentionItemHandlers
onOpenTask: (taskId: string) => void
onMore: () => void
}) {
const copy = localTasksCopy()
- const { row } = props
+ const card = copy.work.card
+ const { row, facts, handlers } = props
+ // A review card leads with acceptance; any session also waiting lists under it.
+ const lead = row.review ? null : localTaskLeadItem(row.items)
+ const others = row.items.filter((item) => item !== lead)
+ const snooze = (
+ handlers.onSnooze(row.items)}
+ />
+ )
return (
-
- props.onOpenTask(row.task.id)}
- onLongPress={props.onMore}
- accessibilityRole="button"
- >
- {row.task.ref}
-
- {row.task.title}
-
- {formatLocalTaskAgo(copy, row.since, props.now)}
-
- {row.items.map((item) => (
-
- ))}
+
+
+ {row.review || !lead ? (
+
+ ) : (
+
+ )}
+
+
+ [styles.main, pressed && styles.pressed]}
+ onPress={() => props.onOpenTask(row.task.id)}
+ onLongPress={props.onMore}
+ delayLongPress={350}
+ accessibilityRole="button"
+ accessibilityLabel={`${row.task.title} ${row.task.ref}`}
+ >
+
+
+ {row.task.title}
+
+
+ {formatLocalTaskAgo(
+ copy,
+ row.review ? row.task.updatedAt : (lead?.since ?? row.since),
+ props.now
+ )}
+
+
+ {row.review ? (
+
+ ) : (
+
+ )}
+ {row.review ? (
+ <>
+
+
+ >
+ ) : lead?.lastMessage ? (
+
+ {lead.lastMessage}
+
+ ) : null}
+
+
+ {row.review ? (
+ <>
+ facts.lead && handlers.onPreview(facts.lead.link.id)}
+ />
+ props.onOpenTask(row.task.id)}
+ />
+ {snooze}
+ >
+ ) : lead ? (
+ <>
+ handlers.onPreview(lead.taskSessionId)}
+ />
+ handlers.onOpenSession(lead.taskSessionId)}
+ />
+ {snooze}
+ {lead.replyable ? : null}
+ >
+ ) : null}
+
+ {lead ? : null}
+ {others.map((item) => (
+
+ ))}
+
)
}
-export type LocalTaskAttentionItemHandlers = ItemHandlers
-
const styles = StyleSheet.create({
+ block: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle },
+ blockFirst: { borderTopWidth: 0 },
card: {
- backgroundColor: colors.bgPanel,
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: colors.borderSubtle,
- paddingHorizontal: spacing.lg,
- paddingVertical: spacing.md,
- gap: spacing.sm
+ flexDirection: 'row',
+ gap: spacing.md,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.md
},
- header: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
+ pressed: { backgroundColor: colors.bgRaised },
+ icon: { paddingTop: 2 },
+ main: { flex: 1, minWidth: 0, gap: 4 },
+ titleLine: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
title: { flex: 1, color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
- item: { gap: 4 },
- kind: { color: colors.statusAmber, fontSize: typography.metaSize, fontWeight: '600' },
- agent: { color: colors.textSecondary, fontWeight: '400' },
- actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: 2 }
+ shrink: { flexShrink: 1, minWidth: 0 },
+ other: { gap: 4, marginTop: 4 },
+ otherLine: { flexDirection: 'row', alignItems: 'center', gap: 6 }
})
diff --git a/mobile/src/local-tasks/local-task-card-facts.test.ts b/mobile/src/local-tasks/local-task-card-facts.test.ts
new file mode 100644
index 000000000000..a21154ae65a1
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-card-facts.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, it } from 'vitest'
+import { buildLocalTaskIndex } from '../../../src/shared/local-tasks/local-task-grouping'
+import type {
+ LocalTaskLinks,
+ LocalTaskProgressSnapshot,
+ LocalTaskSessionLink
+} from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { localTaskCardFacts } from './local-task-card-facts'
+
+function task(id: string, parentId: string | null = null): LocalTask {
+ return {
+ id,
+ projectId: 'p',
+ number: Number(id.replace(/\D/g, '')) || 1,
+ ref: `ORC-${id}`,
+ title: id,
+ description: '',
+ criteria: [],
+ statusId: 'started',
+ priority: 0,
+ labels: [],
+ parentId,
+ createdAt: 0,
+ updatedAt: 0,
+ revision: 1
+ }
+}
+
+function session(id: string, taskId: string, openedAt: number): LocalTaskSessionLink {
+ return {
+ id,
+ taskId,
+ hostId: 'ssh:dbox',
+ worktreeId: 'wt',
+ instanceId: null,
+ surface: 'terminal',
+ tabId: null,
+ paneKey: null,
+ sessionId: null,
+ providerSession: null,
+ agent: 'claude',
+ linkedVia: 'start',
+ openedAt
+ }
+}
+
+const parent = task('41')
+const child = task('43', '41')
+const links: LocalTaskLinks = {
+ workspaces: [
+ {
+ taskId: parent.id,
+ hostId: 'ssh:dbox',
+ kind: 'git-worktree',
+ instanceId: null,
+ worktreeId: 'wt',
+ folderWorkspaceId: null,
+ repoId: 'repo',
+ branchSnapshot: 'feat-reconnect',
+ nameSnapshot: 'feat-reconnect',
+ hostLabelSnapshot: 'dbox (recorded)',
+ linkedVia: 'start',
+ linkedAt: 1
+ }
+ ],
+ sessions: [session('own', parent.id, 1), session('kid', child.id, 2)]
+}
+
+function facts(snapshot: LocalTaskProgressSnapshot, tasks: LocalTask[] = [parent, child]) {
+ return localTaskCardFacts({
+ task: tasks[0]!,
+ index: buildLocalTaskIndex([], tasks),
+ links,
+ snapshot,
+ inContact: true,
+ hostName: (hostId, recorded) => (hostId === 'ssh:dbox' ? 'dbox' : (recorded ?? '')),
+ hostConnected: () => null
+ })
+}
+
+describe('localTaskCardFacts', () => {
+ it('names the session waiting on the user first and sums up the subtasks', () => {
+ const result = facts({
+ observedAt: 10,
+ sessions: [
+ { taskSessionId: 'own', state: 'working', lastMessage: null, stateSince: 5, unread: false },
+ {
+ taskSessionId: 'kid',
+ state: 'waiting',
+ lastMessage: 'Top?',
+ stateSince: 6,
+ unread: false
+ }
+ ],
+ items: [
+ {
+ key: 'kid@6',
+ taskId: child.id,
+ taskSessionId: 'kid',
+ kind: 'question',
+ agent: 'claude',
+ lastMessage: 'Top?',
+ since: 6,
+ replyable: false
+ }
+ ]
+ })
+ expect(result.lead?.link.id).toBe('kid')
+ expect(result.subtasks?.summary).toEqual({ kind: 'waiting', count: 1 })
+ expect(result.offline).toBe(false)
+ expect(result.hostName).toBe('dbox')
+ })
+
+ it('says the host is not connected when every session there is unverifiable, keeping the last writeback', () => {
+ const written = {
+ ...parent,
+ latestProgress: {
+ kind: 'progress' as const,
+ body: 'Found it.\nNext: test',
+ at: 3,
+ sessionLinkId: null
+ }
+ }
+ const result = facts(
+ {
+ observedAt: 10,
+ sessions: ['own', 'kid'].map((id) => ({
+ taskSessionId: id,
+ state: 'unverifiable' as const,
+ lastMessage: null,
+ stateSince: null,
+ unread: false
+ })),
+ items: []
+ },
+ [written, child]
+ )
+ expect(result.offline).toBe(true)
+ expect(result.latest).toEqual({
+ source: 'writeback',
+ body: 'Found it. Next: test',
+ at: 3,
+ fromRef: null
+ })
+ })
+})
diff --git a/mobile/src/local-tasks/local-task-card-facts.ts b/mobile/src/local-tasks/local-task-card-facts.ts
new file mode 100644
index 000000000000..c8b6424c70e2
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-card-facts.ts
@@ -0,0 +1,249 @@
+import type { LocalTaskIndex } from '../../../src/shared/local-tasks/local-task-grouping'
+import type {
+ LocalTaskAttentionItem,
+ LocalTaskLinks,
+ LocalTaskProgressSnapshot,
+ LocalTaskSessionLink,
+ LocalTaskSessionState
+} from '../../../src/shared/local-tasks/local-task-link-types'
+import { localTaskDescendantIds } from '../../../src/shared/local-tasks/local-task-tree'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { localTaskLatestProgress } from './local-task-latest-line'
+import {
+ localTaskRowSessionStates,
+ localTaskWorkspaceLinkFor
+} from './local-task-progress-projection'
+
+export type LocalTaskCardSession = {
+ link: LocalTaskSessionLink
+ state: LocalTaskSessionState
+ /** What this session waits on the user for, when it does. */
+ item: LocalTaskAttentionItem | null
+ lastMessage: string | null
+ stateSince: number | null
+}
+
+export type LocalTaskCardLatest = {
+ /** One line: a writeback, the agent's last words, or a session that has not written yet. */
+ source: 'writeback' | 'last-line' | 'started'
+ body: string
+ at: number | null
+ /** The subtask that wrote it, when not the task itself. */
+ fromRef: string | null
+}
+
+export type LocalTaskCardSubtasks = {
+ count: number
+ summary:
+ | { kind: 'none' }
+ | { kind: 'all-done' }
+ | { kind: 'waiting'; count: number }
+ | { kind: 'working'; count: number }
+ | null
+ latest: { ref: string; body: string } | null
+}
+
+export type LocalTaskCardFacts = {
+ /** Where the task works: its own workspace or the nearest ancestor's. */
+ workspace: { name: string; hostId: string } | null
+ hostName: string
+ /**
+ * The workspace's host is out of contact: the library's own host, the paired host's word on an
+ * SSH host, or every session reading `unverifiable`. Only what was last read shows then.
+ */
+ offline: boolean
+ lead: LocalTaskCardSession | null
+ sessionCount: number
+ latest: LocalTaskCardLatest | null
+ subtasks: LocalTaskCardSubtasks | null
+}
+
+const LEAD_ORDER: readonly LocalTaskSessionState[] = [
+ 'waiting',
+ 'blocked',
+ 'working',
+ 'done',
+ 'idle',
+ 'unverifiable'
+]
+
+/** The session a card names first, as the desktop picks it: what waits, then work, then newest. */
+function pickLead(sessions: readonly LocalTaskCardSession[]): LocalTaskCardSession | null {
+ const waiting = sessions.findLast(
+ (session) => session.item && session.item.kind !== 'unread-done'
+ )
+ if (waiting) {
+ return waiting
+ }
+ for (const wanted of LEAD_ORDER) {
+ const found = sessions.findLast((session) => session.state === wanted)
+ if (found) {
+ return found
+ }
+ }
+ return sessions.at(-1) ?? null
+}
+
+/** A linked session as the drawer's head names it: whose it is, how it stands, and where it runs. */
+export type LocalTaskSessionSummary = {
+ link: LocalTaskSessionLink
+ task: LocalTask | null
+ state: LocalTaskSessionState
+ item: LocalTaskAttentionItem | null
+ stateSince: number | null
+ workspaceName: string
+ hostName: string
+}
+
+export function localTaskSessionSummary(args: {
+ taskSessionId: string
+ index: Pick
+ links: LocalTaskLinks
+ snapshot: LocalTaskProgressSnapshot | null
+ inContact: boolean
+ hostName: (ownerHostId: string, snapshotLabel: string | null) => string
+}): LocalTaskSessionSummary | null {
+ const link = args.links.sessions.find((entry) => entry.id === args.taskSessionId)
+ if (!link) {
+ return null
+ }
+ const task = args.index.tasksById.get(link.taskId) ?? null
+ const workspace = task ? localTaskWorkspaceLinkFor(task, args.index.tasksById, args.links) : null
+ const entry = args.snapshot?.sessions.find((session) => session.taskSessionId === link.id)
+ return {
+ link,
+ task,
+ state: args.inContact ? (entry?.state ?? 'unknown') : 'unverifiable',
+ item: args.inContact
+ ? (args.snapshot?.items.find((item) => item.taskSessionId === link.id) ?? null)
+ : null,
+ stateSince: entry?.stateSince ?? null,
+ workspaceName: workspace?.nameSnapshot ?? workspace?.branchSnapshot ?? '',
+ hostName: args.hostName(link.hostId, workspace?.hostLabelSnapshot ?? null)
+ }
+}
+
+function oneLine(text: string): string {
+ return text.replace(/\s*\n+\s*/g, ' ').trim()
+}
+
+function subtaskFacts(
+ task: LocalTask,
+ index: Pick,
+ links: LocalTaskLinks,
+ states: ReadonlyMap,
+ inContact: boolean
+): LocalTaskCardSubtasks | null {
+ const children = index.childrenByParentId.get(task.id) ?? []
+ if (children.length === 0) {
+ return null
+ }
+ const childIds = new Set(children.map((child) => child.id))
+ const sessionStates = links.sessions
+ .filter((link) => childIds.has(link.taskId))
+ .map((link) => (inContact ? (states.get(link.id) ?? 'unknown') : 'unverifiable'))
+ const waiting = sessionStates.filter((state) => state === 'waiting').length
+ const working = sessionStates.filter((state) => state === 'working').length
+ const summary: LocalTaskCardSubtasks['summary'] =
+ sessionStates.length === 0
+ ? { kind: 'none' }
+ : sessionStates.every((state) => state === 'done')
+ ? { kind: 'all-done' }
+ : waiting > 0
+ ? { kind: 'waiting', count: waiting }
+ : working > 0
+ ? { kind: 'working', count: working }
+ : null
+ const latest = children
+ .filter((child) => child.latestProgress?.body)
+ .sort((a, b) => (b.latestProgress?.at ?? 0) - (a.latestProgress?.at ?? 0))[0]
+ return {
+ count: children.length,
+ summary,
+ latest: latest?.latestProgress
+ ? { ref: latest.ref, body: oneLine(latest.latestProgress.body) }
+ : null
+ }
+}
+
+/**
+ * What a task card says, read only from what the page already loaded: no host is asked anything
+ * here, and out of contact every session reads `unverifiable` and keeps its last words.
+ */
+export function localTaskCardFacts(args: {
+ task: LocalTask
+ index: Pick
+ links: LocalTaskLinks
+ snapshot: LocalTaskProgressSnapshot | null
+ /** The library's own host is in contact. */
+ inContact: boolean
+ hostName: (ownerHostId: string, snapshotLabel: string | null) => string
+ /** The paired host's word on a host; null when it gave none. */
+ hostConnected: (ownerHostId: string) => boolean | null
+}): LocalTaskCardFacts {
+ const { task, index, links, snapshot, inContact } = args
+ const link = localTaskWorkspaceLinkFor(task, index.tasksById, links)
+ const progress = new Map((snapshot?.sessions ?? []).map((entry) => [entry.taskSessionId, entry]))
+ const states = new Map([...progress].map(([id, entry]) => [id, entry.state]))
+ const items = snapshot?.items ?? []
+ const sessions: LocalTaskCardSession[] = localTaskRowSessionStates({
+ task,
+ index,
+ links,
+ snapshot,
+ inContact
+ }).flatMap((row) => {
+ const sessionLink = links.sessions.find((entry) => entry.id === row.id)
+ if (!sessionLink) {
+ return []
+ }
+ const entry = progress.get(row.id)
+ return [
+ {
+ link: sessionLink,
+ state: row.state,
+ item: inContact ? (items.find((item) => item.taskSessionId === row.id) ?? null) : null,
+ lastMessage: entry?.lastMessage ?? null,
+ stateSince: entry?.stateSince ?? null
+ }
+ ]
+ })
+ const lead = pickLead(sessions)
+ const offline =
+ !inContact ||
+ (link !== null && args.hostConnected(link.hostId) === false) ||
+ (sessions.length > 0 && sessions.every((session) => session.state === 'unverifiable'))
+ const descendants = localTaskDescendantIds(task.id, index.childrenByParentId).flatMap((id) => {
+ const child = index.tasksById.get(id)
+ return child ? [child] : []
+ })
+ const written = localTaskLatestProgress(task, descendants)
+ const writer = written ? (index.tasksById.get(written.taskId) ?? task) : null
+ const latest: LocalTaskCardLatest | null = written
+ ? {
+ source: writer?.latestProgress?.kind === 'turn-ended' ? 'last-line' : 'writeback',
+ body: oneLine(written.body),
+ at: written.at,
+ fromRef:
+ written.taskId === task.id ? null : (index.tasksById.get(written.taskId)?.ref ?? null)
+ }
+ : lead?.lastMessage
+ ? { source: 'last-line', body: oneLine(lead.lastMessage), at: lead.stateSince, fromRef: null }
+ : lead
+ ? { source: 'started', body: '', at: null, fromRef: null }
+ : null
+ return {
+ workspace: link
+ ? {
+ name: link.nameSnapshot ?? link.branchSnapshot ?? link.worktreeId ?? '',
+ hostId: link.hostId
+ }
+ : null,
+ hostName: link ? args.hostName(link.hostId, link.hostLabelSnapshot) : '',
+ offline,
+ lead,
+ sessionCount: sessions.length,
+ latest,
+ subtasks: subtaskFacts(task, index, links, states, inContact)
+ }
+}
diff --git a/mobile/src/local-tasks/local-task-card.tsx b/mobile/src/local-tasks/local-task-card.tsx
new file mode 100644
index 000000000000..393802226c54
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-card.tsx
@@ -0,0 +1,299 @@
+import type { ReactNode } from 'react'
+import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { Check, Clock, CornerDownRight, Eye, SquareTerminal } from 'lucide-react-native'
+import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label'
+import type {
+ LocalTaskAttentionItem,
+ LocalTaskAttentionKind
+} from '../../../src/shared/local-tasks/local-task-link-types'
+import type {
+ LocalTask,
+ LocalTaskStatusDef
+} from '../../../src/shared/local-tasks/local-task-types'
+import { AgentStateDot } from '../components/AgentStateDot'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+import type { LocalTaskCardFacts } from './local-task-card-facts'
+import { formatLocalTaskAgo, localTasksCopy } from './local-tasks-copy'
+import { LOCAL_TASK_SESSION_DOT } from './local-task-session-dots'
+import { localTaskStyles } from './local-tasks-styles'
+import { LocalTaskStatusIcon } from './local-tasks-visuals'
+
+const ATTENTION_DOT: Record = {
+ approval: 'waiting',
+ question: 'waiting',
+ blocked: 'blocked',
+ 'unread-done': 'done'
+}
+
+/** `ref · workspace · host`, or that the task has no workspace yet. */
+export function LocalTaskCardMeta(props: {
+ task: LocalTask
+ facts: LocalTaskCardFacts
+ /** Said right after the ref, in its own color: what the card waits on the user for. */
+ kind?: { label: string; tone: 'attention' | 'review' | 'muted' }
+ /** What follows the kind instead of the workspace: the agent, a PR. */
+ detail?: string | null
+}) {
+ const work = localTasksCopy().work
+ const { facts } = props
+ const where = facts.workspace
+ ? [facts.workspace.name, facts.hostName].filter(Boolean).join(' · ')
+ : work.card.noWorkspace
+ const tail = props.detail ?? (props.kind ? null : where)
+ return (
+
+
+ {props.task.ref}
+
+ {props.kind ? (
+
+ {props.kind.label}
+
+ ) : null}
+ {tail ? (
+
+ · {tail}
+
+ ) : null}
+
+ )
+}
+
+/** The lead session's agent, state and time in it, and where the latest line came from. */
+export function LocalTaskCardFoot(props: { facts: LocalTaskCardFacts; now: number }) {
+ const copy = localTasksCopy()
+ const card = copy.work.card
+ const { facts } = props
+ if (facts.offline && facts.workspace) {
+ const ago =
+ facts.latest?.at !== null && facts.latest?.at !== undefined
+ ? formatLocalTaskAgo(copy, facts.latest.at, props.now)
+ : null
+ return (
+
+
+
+ {[card.hostOffline(facts.hostName), ago ? card.lastProgress(ago) : null]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+ )
+ }
+ const lead = facts.lead
+ if (!lead) {
+ return null
+ }
+ const stateLabel = lead.item
+ ? copy.work.attentionKinds[lead.item.kind]
+ : copy.work.sessionStates[lead.state]
+ const duration =
+ lead.state === 'working' && lead.stateSince !== null
+ ? card.duration(props.now - lead.stateSince)
+ : null
+ const source =
+ facts.latest && facts.latest.source !== 'started' && facts.latest.at !== null
+ ? `${facts.latest.source === 'writeback' ? card.writeback : card.lastLine} · ${formatLocalTaskAgo(copy, facts.latest.at, props.now)}`
+ : null
+ return (
+
+
+
+ {lead.link.agent ? `${formatAgentTypeLabel(lead.link.agent)} ` : ''}
+
+ {[stateLabel, duration].filter(Boolean).join(' · ')}
+
+ {facts.sessionCount > 1 ? ` +${facts.sessionCount - 1}` : ''}
+
+ {source ? {source} : null}
+
+ )
+}
+
+/** The latest line, two lines at most; a session that has not written yet says so. */
+export function LocalTaskCardLatestLine(props: { facts: LocalTaskCardFacts }) {
+ const card = localTasksCopy().work.card
+ const latest = props.facts.latest
+ if (!latest) {
+ return null
+ }
+ const text = latest.source === 'started' ? card.sessionStarted : latest.body
+ const dim = latest.source === 'started' || props.facts.offline
+ return (
+
+ {latest.fromRef ? (
+ {card.fromSubtask(latest.fromRef)}
+ ) : null}
+ {text}
+
+ )
+}
+
+/** A parent's line: how many subtasks, how their sessions stand, and the newest word. */
+export function LocalTaskSubtaskSummary(props: {
+ facts: LocalTaskCardFacts
+ expanded?: boolean
+ onToggle?: () => void
+}) {
+ const card = localTasksCopy().work.card
+ const subtasks = props.facts.subtasks
+ if (!subtasks) {
+ return null
+ }
+ const summary = subtasks.summary
+ const parts = [
+ card.subtasks(subtasks.count),
+ summary?.kind === 'none'
+ ? card.subtasksNone
+ : summary?.kind === 'all-done'
+ ? card.subtasksAllDone
+ : summary?.kind === 'waiting'
+ ? card.subtasksWaiting(summary.count)
+ : summary?.kind === 'working'
+ ? card.subtasksWorking(summary.count)
+ : null,
+ subtasks.latest ? `${card.subtasksLatest(subtasks.latest.ref)} ${subtasks.latest.body}` : null
+ ]
+ const line = (
+
+
+
+ {parts.filter(Boolean).join(' · ')}
+
+
+ )
+ return props.onToggle ? (
+
+ {line}
+
+ ) : (
+ line
+ )
+}
+
+/** A task in a status group: title, where it works, its latest line and its lead session. */
+export function LocalTaskCard(props: {
+ task: LocalTask
+ status: LocalTaskStatusDef | null
+ facts: LocalTaskCardFacts
+ now: number
+ first: boolean
+ subtasksExpanded?: boolean
+ onToggleSubtasks?: () => void
+ onOpen: (taskId: string) => void
+ /** Subtask lines drawn under the card, inside its row. */
+ children?: ReactNode
+}) {
+ const { task, status, facts } = props
+ const closed = status?.category === 'completed' || status?.category === 'canceled'
+ return (
+
+
+
+
+
+
+ [styles.main, pressed && styles.pressed]}
+ onPress={() => props.onOpen(task.id)}
+ accessibilityRole="button"
+ accessibilityLabel={`${task.title} ${task.ref}`}
+ >
+
+ {task.title}
+
+
+ {closed ? null : }
+ {closed ? null : }
+
+
+
+
+ {props.children}
+
+ )
+}
+
+export function LocalTaskCardButton(props: {
+ label: string
+ icon: 'preview' | 'open' | 'later' | 'review'
+ testID?: string
+ disabled?: boolean
+ onPress: () => void
+}) {
+ const Icon =
+ props.icon === 'preview'
+ ? Eye
+ : props.icon === 'open'
+ ? SquareTerminal
+ : props.icon === 'later'
+ ? Clock
+ : Check
+ return (
+
+
+ {props.label}
+
+ )
+}
+
+/** The item a "Needs you" card speaks for: one that waits on an answer before a finished turn. */
+export function localTaskLeadItem(
+ items: readonly LocalTaskAttentionItem[]
+): LocalTaskAttentionItem | null {
+ return items.findLast((item) => item.kind !== 'unread-done') ?? items.at(-1) ?? null
+}
+
+export const localTaskCardStyles = StyleSheet.create({
+ group: { marginHorizontal: spacing.lg },
+ actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: 4 },
+ question: { color: colors.textPrimary, fontSize: typography.bodySize, lineHeight: 20 }
+})
+
+const styles = StyleSheet.create({
+ cardBlock: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle },
+ cardBlockFirst: { borderTopWidth: 0 },
+ card: {
+ flexDirection: 'row',
+ gap: spacing.md,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.md
+ },
+ pressed: { backgroundColor: colors.bgRaised },
+ icon: { paddingTop: 2 },
+ main: { flex: 1, minWidth: 0, gap: 4 },
+ title: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
+ metaLine: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ kind: { fontSize: typography.metaSize, fontWeight: '600', flexShrink: 0 },
+ attention: { color: colors.statusAmber },
+ review: { color: colors.statusGreen },
+ muted: { color: colors.textSecondary },
+ shrink: { flexShrink: 1, minWidth: 0 },
+ // Why: a ref like SHOP-1 must stay on one line; the trailing workspace text gives way instead.
+ ref: { flexShrink: 0 },
+ body: { color: colors.textPrimary, fontSize: typography.metaSize + 1, lineHeight: 19 },
+ dim: { color: colors.textMuted },
+ foot: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ footText: { flex: 1, minWidth: 0, color: colors.textSecondary, fontSize: typography.metaSize },
+ kids: { flexDirection: 'row', alignItems: 'center', gap: 4 }
+})
diff --git a/mobile/src/local-tasks/local-task-choice-sheet.tsx b/mobile/src/local-tasks/local-task-choice-sheet.tsx
index ca6dc3848f73..d67f24ac732d 100644
--- a/mobile/src/local-tasks/local-task-choice-sheet.tsx
+++ b/mobile/src/local-tasks/local-task-choice-sheet.tsx
@@ -1,17 +1,46 @@
-import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
+import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
+import { Search, X } from 'lucide-react-native'
import { BottomDrawer } from '../components/bottom-drawer/BottomDrawer'
-import { colors, spacing, typography } from '../theme/mobile-theme'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { localTasksCopy } from './local-tasks-copy'
import { localTaskStyles } from './local-tasks-styles'
+/** A search box over the choices; the caller filters, so a task list matches by title or ref. */
+export type LocalTaskChoiceSearch = {
+ query: string
+ placeholder: string
+ onChange: (query: string) => void
+}
+
export type LocalTaskChoice = {
key: string
label: string
/** Why a choice is greyed out, or what it adds. */
subtitle?: string
+ /** A short fact on the right, e.g. which task already holds it. */
+ tag?: string
+ /** Consecutive choices with the same section are drawn as one titled group. */
+ section?: string
disabled?: boolean
onPress: () => void
}
+/** Consecutive choices grouped by section, each group keeping its order. */
+function choiceGroups(
+ choices: readonly LocalTaskChoice[]
+): { section: string | undefined; choices: LocalTaskChoice[] }[] {
+ const groups: { section: string | undefined; choices: LocalTaskChoice[] }[] = []
+ for (const choice of choices) {
+ const last = groups.at(-1)
+ if (last && last.section === choice.section) {
+ last.choices.push(choice)
+ } else {
+ groups.push({ section: choice.section, choices: [choice] })
+ }
+ }
+ return groups
+}
+
/**
* A list to pick one thing from, which stays open to say why a pick failed. PickerModal closes on
* select, and a refused link has to be read where it was made. Drawn like the app's other sheets:
@@ -25,8 +54,10 @@ export function LocalTaskChoiceSheet(props: {
/** Shown above the list: an empty state, or the last failure. */
message?: string | null
error?: string | null
+ search?: LocalTaskChoiceSearch | null
onClose: () => void
}) {
+ const { search } = props
return (
@@ -36,37 +67,82 @@ export function LocalTaskChoiceSheet(props: {
{props.message ? {props.message} : null}
{props.error ? {props.error} : null}
- {props.loading ? : null}
- {props.choices.length > 0 ? (
-
- {props.choices.map((choice, index) => (
-
- {index > 0 ? : null}
- [
- styles.row,
- pressed && !choice.disabled && styles.rowPressed,
- choice.disabled && localTaskStyles.disabled
- ]}
- disabled={choice.disabled}
- accessibilityRole="button"
- accessibilityState={{ disabled: choice.disabled === true }}
- onPress={choice.onPress}
- >
-
- {choice.label}
-
- {choice.subtitle ? (
-
- {choice.subtitle}
-
- ) : null}
-
-
- ))}
+ {search ? (
+
+
+
+ {search.query ? (
+ search.onChange('')}
+ hitSlop={8}
+ accessibilityRole="button"
+ accessibilityLabel={localTasksCopy().clearFilters}
+ >
+
+
+ ) : null}
) : null}
+ {search?.query && props.choices.length === 0 ? (
+ {localTasksCopy().filteredEmpty}
+ ) : null}
+ {props.loading ? : null}
+ {choiceGroups(props.choices).map((group, at) => (
+
+ {group.section ? (
+
+ {group.section} {group.choices.length}
+
+ ) : null}
+
+ {group.choices.map((choice, index) => (
+
+ {index > 0 ? : null}
+ [
+ styles.row,
+ pressed && !choice.disabled && styles.rowPressed,
+ choice.disabled && localTaskStyles.disabled
+ ]}
+ disabled={choice.disabled}
+ accessibilityRole="button"
+ accessibilityState={{ disabled: choice.disabled === true }}
+ onPress={choice.onPress}
+ >
+
+
+ {choice.label}
+
+ {choice.subtitle ? (
+
+ {choice.subtitle}
+
+ ) : null}
+
+ {choice.tag ? (
+
+
+ {choice.tag}
+
+
+ ) : null}
+
+
+ ))}
+
+
+ ))}
)
}
@@ -80,7 +156,37 @@ const styles = StyleSheet.create({
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
- row: { paddingVertical: spacing.md, paddingHorizontal: spacing.md + 2, gap: 2 },
+ // Drawn like the task list's search box (local-tasks-filter-bar.tsx).
+ search: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ borderRadius: radii.control,
+ paddingHorizontal: spacing.sm,
+ minHeight: 36,
+ marginBottom: spacing.sm
+ },
+ searchInput: { flex: 1, paddingVertical: 6 },
+ noMatch: { paddingHorizontal: spacing.xs, paddingBottom: spacing.sm },
+ section: { gap: spacing.xs, marginBottom: spacing.sm },
+ sectionTitle: {
+ paddingHorizontal: spacing.xs,
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ },
+ sectionCount: { color: colors.textMuted, fontWeight: '400' },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2
+ },
+ rowText: { flex: 1, minWidth: 0, gap: 2 },
rowPressed: { backgroundColor: colors.bgRaised },
label: { color: colors.textPrimary, fontSize: typography.bodySize }
})
diff --git a/mobile/src/local-tasks/local-task-create-choices.test.ts b/mobile/src/local-tasks/local-task-create-choices.test.ts
new file mode 100644
index 000000000000..64683d4553ab
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-create-choices.test.ts
@@ -0,0 +1,226 @@
+import { describe, expect, it } from 'vitest'
+import type { LocalTaskLinks } from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTask, LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
+import { ProjectSectionCatalogSchema } from '../../../src/shared/project-section-catalog-schema'
+import {
+ localTaskCreateDefaultRepo,
+ localTaskCreateRepoChoices,
+ localTaskCreateWorkspaceChoices,
+ localTaskCreateWorkspaceGroups,
+ readLocalTaskMainWorkspaceIds,
+ type LocalTaskCreateHosts
+} from './local-task-create-choices'
+import { localTaskEndpointFor } from './local-task-endpoint'
+import { localTaskSetupsFor } from './local-task-project-setups'
+import { readLocalTaskWorkspaceCandidates } from './local-task-workspace-candidates'
+import { readLocalTaskWorkspaceListing } from './local-task-workspace-row'
+
+function project(id: string, prefix: string, repos: string[]): LocalTaskProject {
+ return {
+ id,
+ name: id,
+ prefix,
+ aliases: [],
+ repos,
+ defaultRepoId: repos[0] ?? null,
+ lastExecutionHostId: null,
+ rules: { startMovesToInProgress: true, prMergedCompletes: false },
+ statuses: [],
+ createdAt: 0,
+ updatedAt: 0,
+ revision: 1
+ }
+}
+
+const shop = project('shop', 'SHOP', ['github:acme/shop'])
+const orc = project('orc', 'ORC', ['github:acme/orca'])
+const owner: LocalTask = {
+ id: 'task-12',
+ projectId: 'shop',
+ number: 12,
+ ref: 'SHOP-12',
+ title: 'Export orders',
+ description: '',
+ criteria: [],
+ statusId: 'todo',
+ priority: 0,
+ labels: [],
+ parentId: null,
+ createdAt: 0,
+ updatedAt: 0,
+ revision: 1
+}
+
+// The paired Orca's catalog: orca on its own machine, shop over SSH on minizc, infra on dbox.
+const catalog = ProjectSectionCatalogSchema.parse({
+ repos: [
+ { id: 'repo-shop', displayName: 'shop', path: '/shop' },
+ { id: 'repo-orca', displayName: 'orca', path: '/orca' },
+ { id: 'repo-infra', displayName: 'infra', path: '/infra' }
+ ],
+ projects: [
+ { id: 'github:acme/shop', displayName: 'shop' },
+ { id: 'github:acme/orca', displayName: 'orca' },
+ { id: 'github:acme/infra', displayName: 'infra' }
+ ],
+ setups: [
+ setup('orca-local', 'github:acme/orca', 'local', 'repo-orca'),
+ setup('shop-minizc', 'github:acme/shop', 'ssh:minizc', 'repo-shop'),
+ setup('infra-dbox', 'github:acme/infra', 'ssh:dbox', 'repo-infra')
+ ],
+ projectOrderBy: 'manual'
+})
+
+function setup(id: string, projectId: string, hostId: string, repoId: string) {
+ const displayName = id.split('-')[0]
+ return { id, projectId, hostId, repoId, path: `/${id}`, displayName, setupMethod: 'cloned' }
+}
+
+function row(repoId: string, hostId: string, name: string, extra: Record = {}) {
+ return { worktreeId: `${repoId}::/${name}`, repoId, hostId, displayName: name, ...extra }
+}
+
+const rows = [
+ row('repo-orca', 'local', 'main', { isMainWorktree: true }),
+ row('repo-orca', 'local', 'feat-reconnect'),
+ row('repo-infra', 'ssh:dbox', 'deploy-scripts'),
+ row('repo-shop', 'ssh:minizc', 'shop-12-order-export'),
+ row('repo-shop', 'ssh:minizc', 'refund-webhook', {
+ linkedPR: { number: 233, state: 'open', checksStatus: 'failure' }
+ }),
+ row('repo-shop', 'ssh:minizc', 'main', { isMainWorktree: true })
+]
+
+const links: LocalTaskLinks = {
+ workspaces: [
+ {
+ taskId: owner.id,
+ hostId: 'ssh:minizc',
+ kind: 'git-worktree',
+ instanceId: null,
+ worktreeId: 'repo-shop::/shop-12-order-export',
+ folderWorkspaceId: null,
+ repoId: 'repo-shop',
+ branchSnapshot: null,
+ nameSnapshot: null,
+ hostLabelSnapshot: null,
+ linkedVia: 'start',
+ linkedAt: 1
+ }
+ ],
+ sessions: []
+}
+
+const hosts: LocalTaskCreateHosts = {
+ name: (hostId) =>
+ ({ local: 'MacBook Pro', 'ssh:minizc': 'minizc', 'ssh:dbox': 'dbox' })[hostId] ?? '',
+ connected: (hostId) => (hostId === 'ssh:dbox' ? false : hostId === 'local' ? null : true)
+}
+
+function choicesIn(currentProjectId: string) {
+ return localTaskCreateWorkspaceChoices({
+ candidates: readLocalTaskWorkspaceCandidates(rows),
+ listing: readLocalTaskWorkspaceListing({ worktrees: rows, truncated: false }),
+ mainIds: readLocalTaskMainWorkspaceIds(rows),
+ catalog,
+ tasks: [owner],
+ links,
+ projects: [shop, orc],
+ currentProjectId,
+ hosts
+ })
+}
+
+describe('New task’s 已有工作区 on the phone (m8)', () => {
+ it('groups by repo·host: the opened project’s first, main on top, hosts out of contact last', () => {
+ const groups = localTaskCreateWorkspaceGroups({
+ choices: choicesIn('shop'),
+ query: '',
+ firstOrcaProjectIds: shop.repos,
+ hosts
+ })
+ expect(
+ groups.map((group) => [
+ group.repoLabel,
+ hosts.name(group.ownerHostId),
+ group.offline,
+ group.choices.map((choice) => choice.candidate.displayName)
+ ])
+ ).toEqual([
+ ['shop', 'minizc', false, ['main', 'shop-12-order-export', 'refund-webhook']],
+ ['orca', 'MacBook Pro', false, ['main', 'feat-reconnect']],
+ ['infra', 'dbox', true, ['deploy-scripts']]
+ ])
+ })
+
+ it('says what each workspace is and where a task working there goes', () => {
+ const byName = new Map(
+ choicesIn('shop').map((choice) => [
+ `${choice.repoLabel}/${choice.candidate.displayName}`,
+ choice
+ ])
+ )
+ const held = byName.get('shop/shop-12-order-export')
+ expect([held?.owner?.ref, held?.holder?.taskId, held?.projectId]).toEqual([
+ 'SHOP-12',
+ owner.id,
+ 'shop'
+ ])
+ expect(byName.get('shop/refund-webhook')?.review).toEqual({
+ kind: 'pr',
+ number: 233,
+ state: 'open',
+ checks: 'failure'
+ })
+ expect(byName.get('shop/main')?.main).toBe(true)
+ // The task follows the repository into the project that tracks it.
+ expect(byName.get('orca/feat-reconnect')?.projectId).toBe('orc')
+ // An SSH host the paired host says is not connected stays listed, and can't be picked.
+ expect(byName.get('infra/deploy-scripts')?.block).toBe('host-offline')
+ })
+
+ it('searches every word across workspace, repository, host and the holder’s ref', () => {
+ const search = (query: string) =>
+ localTaskCreateWorkspaceGroups({
+ choices: choicesIn('shop'),
+ query,
+ firstOrcaProjectIds: shop.repos,
+ hosts
+ }).flatMap((group) => group.choices.map((choice) => choice.candidate.displayName))
+ expect(search('minizc refund')).toEqual(['refund-webhook'])
+ expect(search('SHOP-12')).toEqual(['shop-12-order-export'])
+ })
+})
+
+describe('New task’s 新建工作区 on the phone (m8)', () => {
+ const setups = localTaskSetupsFor(catalog, localTaskEndpointFor(undefined), [])
+ const repos = (currentProjectId: string, first: readonly string[]) =>
+ localTaskCreateRepoChoices({
+ setups,
+ projects: [shop, orc],
+ currentProjectId,
+ firstOrcaProjectIds: first,
+ hosts
+ })
+
+ it('offers the opened project’s repositories first and greys out a host out of contact', () => {
+ expect(
+ repos('shop', shop.repos).map((choice) => [
+ choice.setup.label,
+ choice.setup.ownerHostId,
+ choice.projectId,
+ choice.block
+ ])
+ ).toEqual([
+ ['shop', 'ssh:minizc', 'shop', null],
+ ['orca', 'local', 'orc', null],
+ ['infra', 'ssh:dbox', 'shop', 'host-offline']
+ ])
+ })
+
+ it('starts on the project’s own usable repository, never one that would move the task', () => {
+ expect(localTaskCreateDefaultRepo(repos('shop', shop.repos), shop)?.setup.label).toBe('shop')
+ const infraOnly = project('ops', 'OPS', ['github:acme/infra'])
+ expect(localTaskCreateDefaultRepo(repos('ops', infraOnly.repos), infraOnly)).toBeNull()
+ })
+})
diff --git a/mobile/src/local-tasks/local-task-create-choices.ts b/mobile/src/local-tasks/local-task-create-choices.ts
new file mode 100644
index 000000000000..787832caf48a
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-create-choices.ts
@@ -0,0 +1,280 @@
+import { z } from 'zod'
+import { projectGroupIdFromRepoId } from '../../../src/shared/folder-workspace-worktree'
+import {
+ localTaskCreateBlock,
+ localTaskCreateProjectFor,
+ localTaskCreateQueryMatches,
+ type LocalTaskCreateBlock
+} from '../../../src/shared/local-tasks/local-task-create-plan'
+import type {
+ LocalTaskLinks,
+ LocalTaskWorkspaceLink
+} from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTask, LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
+import { salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage'
+import type { ServedProjectSections } from '../worktree/project-sections-operations'
+import {
+ localTaskOrcaProjectForRepo,
+ pickLocalTaskStartSetup,
+ type LocalTaskSetup
+} from './local-task-project-setups'
+import {
+ localTaskLinkHolding,
+ type LocalTaskWorkspaceCandidate
+} from './local-task-workspace-candidates'
+import {
+ localTaskWorkspaceVerdict,
+ type LocalTaskWorkspaceListing,
+ type LocalTaskWorkspaceReview
+} from './local-task-workspace-row'
+
+/** How New task names and judges hosts; every id here is owner-relative (`local`, `ssh:{t}`). */
+export type LocalTaskCreateHosts = {
+ name: (ownerHostId: string) => string
+ /** The paired host's word: false only when it says the host is not connected. */
+ connected: (ownerHostId: string) => boolean | null
+}
+
+/** One row of 已有工作区: what the workspace is, and where a task working there goes. */
+export type LocalTaskCreateWorkspaceChoice = {
+ key: string
+ candidate: LocalTaskWorkspaceCandidate
+ repoLabel: string
+ orcaProjectId: string | null
+ folder: boolean
+ main: boolean
+ /** The link of the task already working there; the new task becomes that task's subtask. */
+ holder: LocalTaskWorkspaceLink | null
+ owner: LocalTask | null
+ projectId: string | null
+ review: LocalTaskWorkspaceReview | null
+ block: LocalTaskCreateBlock | null
+}
+
+/** One repo·host heading of 已有工作区 and its rows. */
+export type LocalTaskCreateWorkspaceGroup = {
+ key: string
+ ownerHostId: string
+ repoLabel: string
+ offline: boolean
+ choices: LocalTaskCreateWorkspaceChoice[]
+}
+
+/** One repository chip of 新建工作区. */
+export type LocalTaskCreateRepoChoice = {
+ key: string
+ setup: LocalTaskSetup
+ projectId: string | null
+ block: LocalTaskCreateBlock | null
+}
+
+const mainRowSchema = z.looseObject({
+ worktreeId: z.string(),
+ isMainWorktree: salvagedOptional('isMainWorktree', z.boolean())
+})
+
+/** The workspaces a `worktree.ps` page marks as their repository's main checkout. */
+export function readLocalTaskMainWorkspaceIds(rows: unknown): ReadonlySet {
+ const parsed = salvagingArray(mainRowSchema).safeParse(rows)
+ if (!parsed.success) {
+ return new Set()
+ }
+ return new Set(parsed.data.filter((row) => row.isMainWorktree).map((row) => row.worktreeId))
+}
+
+/** A workspace row's repository as the sidebar names it; a folder workspace by its group. */
+export function localTaskCreateRepoLabel(
+ catalog: ServedProjectSections | null,
+ repoId: string
+): string {
+ if (!catalog) {
+ return ''
+ }
+ const group = projectGroupIdFromRepoId(repoId)
+ if (group) {
+ return catalog.projectGroups?.find((entry) => entry.id === group)?.name ?? ''
+ }
+ return catalog.repos.find((repo) => repo.id === repoId)?.displayName ?? ''
+}
+
+function idSet(id: string | null): ReadonlySet {
+ return new Set(id ? [id] : [])
+}
+
+/**
+ * Every workspace and folder workspace the library's host lists, with what New task needs to place
+ * a task in it. Rows on a host out of contact stay listed and blocked; nothing is read from them.
+ */
+export function localTaskCreateWorkspaceChoices(args: {
+ candidates: readonly LocalTaskWorkspaceCandidate[]
+ listing: LocalTaskWorkspaceListing | null
+ mainIds: ReadonlySet
+ catalog: ServedProjectSections | null
+ tasks: readonly LocalTask[]
+ links: LocalTaskLinks
+ projects: readonly LocalTaskProject[]
+ currentProjectId: string | null
+ hosts: Pick
+}): LocalTaskCreateWorkspaceChoice[] {
+ const tasksById = new Map(args.tasks.map((task) => [task.id, task]))
+ return args.candidates.map((candidate) => {
+ const holder = localTaskLinkHolding(args.links, candidate)
+ const owner = holder ? (tasksById.get(holder.taskId) ?? null) : null
+ const orcaProjectId = args.catalog
+ ? localTaskOrcaProjectForRepo(args.catalog, candidate.repoId)
+ : null
+ const projectId = localTaskCreateProjectFor({
+ held: holder !== null,
+ owner,
+ projects: args.projects,
+ currentProjectId: args.currentProjectId,
+ orcaProjectIds: idSet(orcaProjectId)
+ })
+ const connected = args.hosts.connected(candidate.hostId)
+ const folder = candidate.folderWorkspaceId !== null
+ const verdict =
+ args.listing && !folder
+ ? localTaskWorkspaceVerdict(
+ {
+ hostId: candidate.hostId,
+ kind: 'git-worktree',
+ instanceId: candidate.instanceId,
+ worktreeId: candidate.worktreeId,
+ folderWorkspaceId: null,
+ repoId: candidate.repoId
+ },
+ args.listing,
+ connected !== false
+ )
+ : null
+ return {
+ key: `${candidate.hostId}\u0000${candidate.worktreeId}`,
+ candidate,
+ repoLabel: localTaskCreateRepoLabel(args.catalog, candidate.repoId),
+ orcaProjectId,
+ folder,
+ main: args.mainIds.has(candidate.worktreeId),
+ holder: owner ? holder : null,
+ owner,
+ projectId,
+ review: verdict?.kind === 'found' ? verdict.review : null,
+ block: localTaskCreateBlock({ hostConnected: connected, projectId })
+ }
+ })
+}
+
+/**
+ * The rows matching the search, under repo·host headings: the project the form opened in first,
+ * hosts out of contact last; a repository's main workspace heads its group.
+ */
+export function localTaskCreateWorkspaceGroups(args: {
+ choices: readonly LocalTaskCreateWorkspaceChoice[]
+ query: string
+ /** The Orca projects of the project the form opened in. */
+ firstOrcaProjectIds: readonly string[]
+ hosts: LocalTaskCreateHosts
+}): LocalTaskCreateWorkspaceGroup[] {
+ const groups = new Map()
+ for (const choice of args.choices) {
+ const { candidate } = choice
+ const fields = [
+ candidate.displayName,
+ candidate.branch,
+ choice.repoLabel,
+ args.hosts.name(candidate.hostId),
+ choice.owner?.ref
+ ]
+ if (!localTaskCreateQueryMatches(fields, args.query)) {
+ continue
+ }
+ const key = `${candidate.hostId}\u0000${candidate.repoId}`
+ const offline = args.hosts.connected(candidate.hostId) === false
+ const entry = groups.get(key) ?? {
+ group: {
+ key,
+ ownerHostId: candidate.hostId,
+ repoLabel: choice.repoLabel,
+ offline,
+ choices: []
+ },
+ rank: offline
+ ? 2
+ : choice.orcaProjectId && args.firstOrcaProjectIds.includes(choice.orcaProjectId)
+ ? 0
+ : 1
+ }
+ entry.group.choices.push(choice)
+ groups.set(key, entry)
+ }
+ return [...groups.values()]
+ .map((entry, at) => ({ ...entry, at }))
+ .sort((left, right) => left.rank - right.rank || left.at - right.at)
+ .map(({ group }) => ({
+ ...group,
+ choices: [
+ ...group.choices.filter((choice) => choice.main),
+ ...group.choices.filter((choice) => !choice.main)
+ ]
+ }))
+}
+
+/**
+ * The repositories a new workspace can be made from on the library's hosts: git checkouts only (a
+ * folder project has nothing to create), the opened project's own first.
+ */
+export function localTaskCreateRepoChoices(args: {
+ setups: readonly LocalTaskSetup[]
+ projects: readonly LocalTaskProject[]
+ currentProjectId: string | null
+ firstOrcaProjectIds: readonly string[]
+ hosts: Pick
+}): LocalTaskCreateRepoChoice[] {
+ const seen = new Set()
+ const rank = (setup: LocalTaskSetup) => {
+ const at = args.firstOrcaProjectIds.indexOf(setup.orcaProjectId)
+ return at === -1 ? args.firstOrcaProjectIds.length : at
+ }
+ return args.setups
+ .filter((setup) => setup.kind === 'git')
+ .map((setup, at) => ({ setup, at }))
+ .sort((left, right) => rank(left.setup) - rank(right.setup) || left.at - right.at)
+ .flatMap(({ setup }): LocalTaskCreateRepoChoice[] => {
+ const key = `${setup.ownerHostId}\u0000${setup.repoId}`
+ if (seen.has(key)) {
+ return []
+ }
+ seen.add(key)
+ const projectId = localTaskCreateProjectFor({
+ held: false,
+ owner: null,
+ projects: args.projects,
+ currentProjectId: args.currentProjectId,
+ orcaProjectIds: idSet(setup.orcaProjectId)
+ })
+ const hostConnected = args.hosts.connected(setup.ownerHostId)
+ return [{ key, setup, projectId, block: localTaskCreateBlock({ hostConnected, projectId }) }]
+ })
+}
+
+/**
+ * The repository 新建工作区 starts on: Start's own default among the project's usable checkouts, or
+ * any usable one for a project tracking none. None otherwise, so a pick never moves the project.
+ */
+export function localTaskCreateDefaultRepo(
+ choices: readonly LocalTaskCreateRepoChoice[],
+ project: LocalTaskProject | null
+): LocalTaskCreateRepoChoice | null {
+ if (!project) {
+ return null
+ }
+ const usable = choices.filter((choice) => choice.block === null)
+ const own =
+ project.repos.length === 0
+ ? usable
+ : usable.filter((choice) => project.repos.includes(choice.setup.orcaProjectId))
+ const setup = pickLocalTaskStartSetup(
+ own.map((choice) => choice.setup),
+ project
+ )
+ return own.find((choice) => choice.setup === setup) ?? null
+}
diff --git a/mobile/src/local-tasks/local-task-create-copy.ts b/mobile/src/local-tasks/local-task-create-copy.ts
new file mode 100644
index 000000000000..8eb92127cfa0
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-create-copy.ts
@@ -0,0 +1,162 @@
+import { translate } from '../i18n/mobile-i18n'
+
+/** New task's 在哪做 and 开工 steps (m8); the form's other words are the task page's. */
+export function localTaskCreateCopy() {
+ return {
+ stepTask: translate('localTasks.create.stepTask', 'Task'),
+ stepWhere: translate('localTasks.create.stepWhere', 'Where to work'),
+ stepKickoff: translate('localTasks.create.stepKickoff', 'Start work'),
+ moreProperties: translate('localTasks.create.moreProperties', 'Properties and subtasks'),
+ projectFollows: translate('localTasks.create.projectFollows', 'Project follows the repository'),
+ whereExisting: translate('localTasks.create.whereExisting', 'Existing workspace'),
+ whereNew: translate('localTasks.create.whereNew', 'New workspace'),
+ whereLater: translate('localTasks.create.whereLater', 'Decide later'),
+ searchWorkspaces: translate('localTasks.create.searchWorkspaces', 'Search workspaces'),
+ loadingWorkspaces: translate('localTasks.create.loadingWorkspaces', 'Reading workspaces…'),
+ workspacesUnread: translate(
+ 'localTasks.create.workspacesUnread',
+ 'Couldn’t read the workspace list. Open New task again to read it again.'
+ ),
+ noWorkspaces: translate('localTasks.create.noWorkspaces', 'No workspaces on this host yet'),
+ noMatches: translate('localTasks.create.noMatches', 'No matching workspaces'),
+ showAll: (count: number) =>
+ translate('localTasks.create.showAll', 'Show all {{count}}', { count }),
+ mainWorkspace: translate('localTasks.create.mainWorkspace', 'Main workspace'),
+ folderWorkspace: translate('localTasks.create.folderWorkspace', 'Folder workspace'),
+ free: translate('localTasks.create.free', 'Free'),
+ ownedSubtask: (ref: string) =>
+ translate('localTasks.create.ownedSubtask', 'Belongs to {{ref}} · becomes its subtask', {
+ ref
+ }),
+ noProjectHere: translate(
+ 'localTasks.create.noProjectHere',
+ 'No project here can take a new task'
+ ),
+ notConnected: translate('localTasks.create.notConnected', 'Not connected'),
+ hostNotConnected: (host: string) =>
+ host
+ ? translate('localTasks.create.hostNotConnected', '{{host}} not connected', { host })
+ : translate('localTasks.create.notConnected', 'Not connected'),
+ repo: translate('localTasks.create.repo', 'Repository'),
+ name: translate('localTasks.create.name', 'Name'),
+ nameAuto: translate('localTasks.create.nameAuto', 'Named from the task’s ref and title'),
+ base: translate('localTasks.create.base', 'Based on'),
+ baseDefault: translate('localTasks.create.baseDefault', 'Default branch'),
+ repoOnHost: (repo: string, host: string) =>
+ host
+ ? translate('localTasks.create.repoOnHost', '{{repo}} on {{host}}', { repo, host })
+ : repo,
+ newNote: (host: string) =>
+ translate('localTasks.create.newNote', 'Created on {{host}} the way New workspace does.', {
+ host
+ }),
+ offlineReposNote: (hosts: string) =>
+ translate(
+ 'localTasks.create.offlineReposNote',
+ '{{hosts}} not connected, so its repositories can’t be picked now.',
+ { hosts }
+ ),
+ loadingRepos: translate('localTasks.create.loadingRepos', 'Reading repositories…'),
+ noRepos: translate(
+ 'localTasks.create.noRepos',
+ 'No repository on this host can take a new workspace.'
+ ),
+ catalogUnavailable: translate(
+ 'localTasks.create.catalogUnavailable',
+ 'This host hasn’t published its repositories yet, so a new workspace can’t be made here.'
+ ),
+ laterNote: translate(
+ 'localTasks.create.laterNote',
+ 'Only the task is created: no workspace, no session. Start it later from the task.'
+ ),
+ noSite: translate(
+ 'localTasks.create.noSite',
+ 'This screen can’t reach the task library’s host, so only the task is created.'
+ ),
+ libraryOffline: (host: string) =>
+ translate(
+ 'localTasks.create.libraryOffline',
+ '{{host}} is not connected, so only the task can be created now. Start work from the task once it is back.',
+ { host }
+ ),
+ defaultAgent: translate('localTasks.create.defaultAgent', 'Default agent'),
+ notNow: translate('localTasks.create.notNow', 'Not now'),
+ notNowHint: translate('localTasks.create.notNowHint', 'Only the task and workspace'),
+ moreAgents: translate('localTasks.create.moreAgents', 'Other agents'),
+ agentsAfterWhere: translate(
+ 'localTasks.create.agentsAfterWhere',
+ 'Agents are listed once you pick where to work.'
+ ),
+ agentsUnreadable: translate(
+ 'localTasks.create.agentsUnreadable',
+ 'Couldn’t read this host’s agents.'
+ ),
+ firstLine: translate('localTasks.create.firstLine', 'First prompt'),
+ firstLineDefault: translate(
+ 'localTasks.create.firstLineDefault',
+ 'Start from the task description'
+ ),
+ sendNow: translate('localTasks.create.sendNow', 'Send now'),
+ sendFixed: translate('localTasks.create.sendFixed', 'Fixed on phone'),
+ sendFixedHint: translate(
+ 'localTasks.create.sendFixedHint',
+ 'The phone can’t put it in the input box first: a host doesn’t deliver drafts started from the phone.'
+ ),
+ briefNote: translate(
+ 'localTasks.create.briefNote',
+ 'The session also gets the task brief: its ref, how to write back progress, and to stop at Ready for acceptance. Can be turned off in Settings.'
+ ),
+ planLater: translate('localTasks.create.planLater', 'Only the task is created; start it later'),
+ planPickWorkspace: translate('localTasks.create.planPickWorkspace', 'Pick a workspace'),
+ planPickRepo: translate('localTasks.create.planPickRepo', 'Pick a repository'),
+ planFindingAgents: translate('localTasks.create.planFindingAgents', 'Finding agents…'),
+ workspaceOnHost: (workspace: string, host: string) =>
+ host
+ ? translate('localTasks.create.workspaceOnHost', '{{workspace}} ({{host}})', {
+ workspace,
+ host
+ })
+ : workspace,
+ planExistingSession: (place: string, agent: string) =>
+ translate('localTasks.create.planExistingSession', 'Open a {{agent}} session in {{place}}', {
+ place,
+ agent
+ }),
+ planExistingLink: (place: string) =>
+ translate('localTasks.create.planExistingLink', 'Work in {{place}}, no session', { place }),
+ planAsSubtask: (ref: string, plan: string) =>
+ translate('localTasks.create.planAsSubtask', 'As a subtask of {{ref}}: {{plan}}', {
+ ref,
+ plan
+ }),
+ autoName: translate('localTasks.create.autoName', '(named after the task)'),
+ planNewSession: (workspace: string, host: string, agent: string) =>
+ translate(
+ 'localTasks.create.planNewSession',
+ 'New workspace {{workspace}} on {{host}}, then a {{agent}} session',
+ { workspace, host, agent }
+ ),
+ planNewOnly: (workspace: string, host: string) =>
+ translate(
+ 'localTasks.create.planNewOnly',
+ 'New workspace {{workspace}} on {{host}}, no session',
+ { workspace, host }
+ ),
+ createAndStart: translate('localTasks.create.createAndStart', 'Create and start'),
+ nothingCreated: (reason: string) =>
+ translate('localTasks.create.nothingCreated', 'Nothing was created: {{reason}}', { reason }),
+ createdNotLinked: (ref: string, workspace: string, reason: string) =>
+ translate(
+ 'localTasks.create.createdNotLinked',
+ '{{ref}} was created, but {{workspace}} was not linked to it. {{reason}}',
+ { ref, workspace, reason }
+ ),
+ createdNotStarted: (ref: string, reason: string) =>
+ translate('localTasks.create.createdNotStarted', '{{ref}} was created. {{reason}}', {
+ ref,
+ reason
+ })
+ }
+}
+
+export type LocalTaskCreateCopy = ReturnType
diff --git a/mobile/src/local-tasks/local-task-create-kickoff-section.tsx b/mobile/src/local-tasks/local-task-create-kickoff-section.tsx
new file mode 100644
index 000000000000..9a8384b0124e
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-create-kickoff-section.tsx
@@ -0,0 +1,241 @@
+import { useState } from 'react'
+import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
+import { Bot, ChevronRight, Send, X } from 'lucide-react-native'
+import type { LocalTaskCreatePlan } from '../../../src/shared/local-tasks/local-task-create-plan'
+import { PickerModal } from '../components/PickerModal'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { localTasksCopy } from './local-tasks-copy'
+import { localTaskCreateCopy, type LocalTaskCreateCopy } from './local-task-create-copy'
+import {
+ LocalTaskCreateNote,
+ LocalTaskCreateRadio,
+ LocalTaskCreateStepHeading
+} from './local-task-create-where-section'
+import { localTaskStyles } from './local-tasks-styles'
+import type { LocalTaskCreateWhereState } from './use-local-task-create-where'
+
+// Why two: the tiles share one row with 先不开; the rest stay one tap away under Other agents.
+const AGENTS_SHOWN = 2
+
+/** The line above the primary button: what pressing it will do, where. */
+export function localTaskCreatePlanLine(plan: LocalTaskCreatePlan, create: LocalTaskCreateCopy) {
+ switch (plan.kind) {
+ case 'later':
+ return create.planLater
+ case 'pick-workspace':
+ return create.planPickWorkspace
+ case 'pick-repo':
+ return create.planPickRepo
+ case 'finding-agents':
+ return create.planFindingAgents
+ case 'existing': {
+ const place = create.workspaceOnHost(plan.workspace, plan.host)
+ const line = plan.agent
+ ? create.planExistingSession(place, plan.agent)
+ : create.planExistingLink(place)
+ return plan.parentRef ? create.planAsSubtask(plan.parentRef, line) : line
+ }
+ case 'new': {
+ const name = plan.workspace || create.autoName
+ return plan.agent
+ ? create.planNewSession(name, plan.host, plan.agent)
+ : create.planNewOnly(name, plan.host)
+ }
+ }
+}
+
+function agentsStatus(state: LocalTaskCreateWhereState): string | null {
+ const create = localTaskCreateCopy()
+ const work = localTasksCopy().work
+ const { agents } = state
+ if (state.where === 'later') {
+ return null
+ }
+ switch (agents.status) {
+ case 'idle':
+ return create.agentsAfterWhere
+ case 'loading':
+ return work.loadingAgents
+ case 'ready':
+ return agents.agents.length === 0 ? work.noAgents : null
+ case 'failed':
+ return agents.reason === 'offline'
+ ? work.agentsHostOffline(state.targetHostName)
+ : agents.reason === 'unverifiable'
+ ? work.hostUnknown
+ : create.agentsUnreadable
+ }
+}
+
+function Tile(props: {
+ on: boolean
+ disabled: boolean
+ icon: 'bot' | 'none'
+ title: string
+ hint: string | null
+ onPress: () => void
+}) {
+ const Icon = props.icon === 'bot' ? Bot : X
+ return (
+ [
+ styles.tile,
+ props.on && styles.tileOn,
+ pressed && localTaskStyles.pressed
+ ]}
+ disabled={props.disabled}
+ onPress={props.onPress}
+ accessibilityRole="radio"
+ accessibilityState={{ checked: props.on, disabled: props.disabled }}
+ >
+
+
+
+ {props.title}
+
+
+
+ {props.hint ? (
+
+ {props.hint}
+
+ ) : null}
+
+ )
+}
+
+/** ③ 开工 (m8): which agent opens a session there, its first prompt, and how it is sent. */
+export function LocalTaskCreateKickoffSection({ state }: { state: LocalTaskCreateWhereState }) {
+ const create = localTaskCreateCopy()
+ const [moreOpen, setMoreOpen] = useState(false)
+ const off = state.where === 'later'
+ const options = state.agents.status === 'ready' ? state.agents.agents : []
+ const shown = options.slice(0, AGENTS_SHOWN)
+ const picked = options.find((option) => option.agent === state.agent)
+ if (picked && !shown.includes(picked)) {
+ shown[shown.length - 1] = picked
+ }
+ const status = agentsStatus(state)
+ return (
+
+
+
+ {shown.map((option) => (
+ state.setAgentChoice(option.agent)}
+ />
+ ))}
+ state.setAgentChoice('none')}
+ />
+
+ {status ? (
+
+ {state.agents.status === 'loading' && !off ? (
+
+ ) : null}
+ {status}
+
+ ) : null}
+ {options.length > AGENTS_SHOWN && !off ? (
+ [styles.more, pressed && localTaskStyles.pressed]}
+ onPress={() => setMoreOpen(true)}
+ accessibilityRole="button"
+ >
+ {create.moreAgents}
+
+
+ ) : null}
+ {create.firstLine}
+
+
+
+
+
+ {create.sendNow}
+
+ {create.sendFixed}
+
+
+ {create.sendFixedHint}
+
+
+ {state.briefOn ? {create.briefNote} : null}
+ ({ value: option.agent, label: option.label }))}
+ selected={state.agent ?? ''}
+ onSelect={(agent) => {
+ const option = options.find((entry) => entry.agent === agent)
+ if (option) {
+ state.setAgentChoice(option.agent)
+ }
+ setMoreOpen(false)
+ }}
+ onClose={() => setMoreOpen(false)}
+ />
+
+ )
+}
+
+const styles = StyleSheet.create({
+ section: { gap: spacing.sm, marginTop: spacing.lg },
+ tiles: { flexDirection: 'row', gap: spacing.sm },
+ tile: {
+ flex: 1,
+ minWidth: 0,
+ gap: 4,
+ padding: spacing.md,
+ borderRadius: radii.control,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ backgroundColor: colors.bgPanel
+ },
+ tileOn: { borderColor: colors.textSecondary, backgroundColor: colors.bgRaised },
+ tileHead: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ tileTitle: {
+ flex: 1,
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ },
+ status: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
+ more: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-start' },
+ label: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontWeight: '600',
+ marginTop: spacing.sm
+ },
+ sendMode: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: spacing.md,
+ padding: spacing.md
+ },
+ sendHead: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: 2 },
+ sendTitle: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
+ flex: { flex: 1 }
+})
diff --git a/mobile/src/local-tasks/local-task-create-kickoff.test.ts b/mobile/src/local-tasks/local-task-create-kickoff.test.ts
new file mode 100644
index 000000000000..aad3e9eb048e
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-create-kickoff.test.ts
@@ -0,0 +1,288 @@
+import { describe, expect, it, vi } from 'vitest'
+vi.mock('react-native', () => ({ AppState: { currentState: 'active' } }))
+import {
+ AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY,
+ AGENT_LAUNCH_RUNTIME_CAPABILITY
+} from '../../../src/shared/agent-launch-runtime-capability'
+import type { LocalTaskWorkspaceLink } from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
+import { scopeRpcClientToExecutionHost } from '../transport/execution-host-scoped-rpc-client'
+import type { RpcResponse } from '../transport/types'
+import { createLocalTasksApi } from './local-tasks-api'
+import { localTasksCopy } from './local-tasks-copy'
+import { localTaskCreateCopy } from './local-task-create-copy'
+import {
+ kickOffCreatedLocalTask,
+ localTaskCreateKickoffBlocked,
+ type LocalTaskCreateKickoff
+} from './local-task-create-kickoff'
+import { SELF_LOCAL_TASK_ENDPOINT } from './local-task-endpoint'
+import { createLocalTaskOperationIds } from './local-task-operation-ids'
+import type { LocalTaskWorkspaceCandidate } from './local-task-workspace-candidates'
+
+const PANE = 'tab-1:6f1c2b0e-3d4a-4b5c-8d9e-0a1b2c3d4e5f'
+const CAPABILITIES = [
+ AGENT_LAUNCH_RUNTIME_CAPABILITY,
+ AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY
+]
+const words = { copy: localTasksCopy(), create: localTaskCreateCopy() }
+
+function task(id: string, ref: string, parentId: string | null): LocalTask {
+ return {
+ id,
+ projectId: 'shop',
+ number: Number(ref.split('-')[1]),
+ ref,
+ title: 'Filter orders by refund state',
+ description: 'Only refunding and refunded orders.',
+ criteria: [],
+ statusId: 'todo',
+ priority: 0,
+ labels: [],
+ parentId,
+ createdAt: 1,
+ updatedAt: 1,
+ revision: 1
+ }
+}
+
+const parent = task('task-12', 'SHOP-12', null)
+const created = task('task-16', 'SHOP-16', null)
+const refund: LocalTaskWorkspaceCandidate = {
+ worktreeId: 'repo-shop::/refund-webhook',
+ repoId: 'repo-shop',
+ hostId: 'ssh:minizc',
+ displayName: 'refund-webhook',
+ branch: 'refund-webhook',
+ instanceId: null,
+ folderWorkspaceId: null
+}
+const parentLink: LocalTaskWorkspaceLink = {
+ taskId: parent.id,
+ hostId: 'ssh:minizc',
+ kind: 'git-worktree',
+ instanceId: null,
+ worktreeId: 'repo-shop::/shop-12-order-export',
+ folderWorkspaceId: null,
+ repoId: 'repo-shop',
+ branchSnapshot: null,
+ nameSnapshot: 'shop-12-order-export',
+ hostLabelSnapshot: 'minizc',
+ linkedVia: 'start',
+ linkedAt: 1
+}
+
+function answer(method: string, params: Record, linkRefused: boolean): unknown {
+ switch (method) {
+ case 'ssh.getState':
+ return {
+ state: { targetId: params.targetId, status: 'connected', error: null, reconnectAttempt: 0 }
+ }
+ case 'agent.launchReplay':
+ return {
+ worktreeId: 'repo-shop::/w/new',
+ outcome: { kind: 'terminal', handle: 'term_1', paneKey: PANE },
+ receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: '' }
+ }
+ case 'localTasks.links.linkWorkspace':
+ return linkRefused
+ ? { ok: false, code: 'workspace_already_linked', detail: 'SHOP-9' }
+ : { ok: true, workspace: { ...parentLink, ...params, taskId: params.taskId } }
+ case 'localTasks.links.recordSession':
+ return {
+ ok: true,
+ session: {
+ id: 'session-1',
+ taskId: params.taskId,
+ hostId: params.hostId,
+ worktreeId: params.worktreeId,
+ instanceId: null,
+ surface: 'terminal',
+ tabId: params.tabId,
+ paneKey: params.paneKey,
+ sessionId: null,
+ providerSession: null,
+ agent: params.agent,
+ linkedVia: 'start',
+ openedAt: 1
+ }
+ }
+ default:
+ throw new Error(`unexpected ${method}`)
+ }
+}
+
+function pairedHost(linkRefused = false) {
+ const host = new FakeSession('connected')
+ host.sendRequest.mockImplementation(
+ async (method: string, params?: unknown): Promise => ({
+ id: method,
+ ok: true,
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every call here sends an object.
+ result: answer(method, (params ?? {}) as Record, linkRefused)
+ })
+ )
+ return host
+}
+
+function callsTo(host: FakeSession, method: string) {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: vi.fn records the scoped view's third argument too.
+ const calls = host.sendRequest.mock.calls as unknown as [
+ string,
+ Record,
+ { envelope?: { executionHost?: string } } | undefined
+ ][]
+ return calls.filter(([name]) => name === method)
+}
+
+function run(host: FakeSession, kickoff: LocalTaskCreateKickoff, newTask = created) {
+ return kickOffCreatedLocalTask({
+ client: host,
+ capabilities: CAPABILITIES,
+ api: createLocalTasksApi(host),
+ ids: { link: createLocalTaskOperationIds(), record: createLocalTaskOperationIds() },
+ task: newTask,
+ tasks: [parent, newTask],
+ brief: false,
+ kickoff,
+ words
+ })
+}
+
+describe('New task starts work where the user picked (m8)', () => {
+ it('links a free workspace on an SSH host, then opens the session with the first line first', async () => {
+ const host = pairedHost()
+ const kickoff: LocalTaskCreateKickoff = {
+ target: { kind: 'existing', candidate: refund, holder: null, hostLabel: 'minizc' },
+ agent: 'claude',
+ firstLine: 'Start from the task description'
+ }
+ expect(
+ await localTaskCreateKickoffBlocked({
+ client: host,
+ endpoint: SELF_LOCAL_TASK_ENDPOINT,
+ kickoff,
+ words
+ })
+ ).toBeNull()
+ expect(await run(host, kickoff)).toBeNull()
+
+ expect(callsTo(host, 'ssh.getState')[0]?.[1]).toMatchObject({ targetId: 'minizc' })
+ expect(callsTo(host, 'localTasks.links.linkWorkspace')[0]?.[1]).toMatchObject({
+ taskId: created.id,
+ hostId: 'ssh:minizc',
+ worktreeId: refund.worktreeId,
+ hostLabelSnapshot: 'minizc',
+ linkedVia: 'manual'
+ })
+ expect(callsTo(host, 'agent.launchReplay')[0]?.[1]).toMatchObject({
+ target: { kind: 'existing', worktree: `id:${refund.worktreeId}` },
+ prompt: {
+ delivery: 'submit',
+ text: expect.stringMatching(/^Start from the task description\n\nTask SHOP-16: Filter/)
+ }
+ })
+ expect(callsTo(host, 'localTasks.links.recordSession')[0]?.[1]).toMatchObject({
+ taskId: created.id,
+ hostId: 'ssh:minizc'
+ })
+ })
+
+ it('works in a held workspace as its task’s subtask: no new link, the session goes there', async () => {
+ const host = pairedHost()
+ const subtask = task('task-17', 'SHOP-17', parent.id)
+ const message = await run(
+ host,
+ {
+ target: { kind: 'existing', candidate: refund, holder: parentLink, hostLabel: 'minizc' },
+ agent: 'codex',
+ firstLine: ''
+ },
+ subtask
+ )
+ expect(message).toBeNull()
+ expect(callsTo(host, 'localTasks.links.linkWorkspace')).toHaveLength(0)
+ expect(callsTo(host, 'agent.launchReplay')[0]?.[1]).toMatchObject({
+ target: { kind: 'existing', worktree: `id:${parentLink.worktreeId}` },
+ prompt: { text: expect.stringContaining('This is a subtask of SHOP-12') }
+ })
+ })
+
+ it('creates the new workspace in a paired server’s own library, named after the task', async () => {
+ const host = pairedHost()
+ const server = scopeRpcClientToExecutionHost(host, 'runtime:vm')
+ const message = await kickOffCreatedLocalTask({
+ client: server,
+ capabilities: CAPABILITIES,
+ api: createLocalTasksApi(server),
+ ids: { link: createLocalTaskOperationIds(), record: createLocalTaskOperationIds() },
+ task: created,
+ tasks: [created],
+ brief: true,
+ kickoff: {
+ target: {
+ kind: 'new',
+ ownerHostId: 'local',
+ repoId: 'repo-shop',
+ name: '',
+ hostLabel: 'vm'
+ },
+ agent: 'claude',
+ firstLine: 'Start from the task description'
+ },
+ words
+ })
+ expect(message).toBeNull()
+ const [launch] = callsTo(host, 'agent.launchReplay')
+ expect(launch?.[2]?.envelope?.executionHost).toBe('runtime:vm')
+ expect(launch?.[1]).toMatchObject({
+ target: {
+ kind: 'create-worktree',
+ create: { repo: 'id:repo-shop', name: 'shop-16-filter-orders-by-refund-state' }
+ }
+ })
+ const [link] = callsTo(host, 'localTasks.links.linkWorkspace')
+ expect(link?.[2]?.envelope?.executionHost).toBe('runtime:vm')
+ expect(link?.[1]).toMatchObject({
+ hostId: 'local',
+ hostLabelSnapshot: 'vm',
+ linkedVia: 'start'
+ })
+ })
+
+ it('creates nothing when the SSH host is out of contact, and says so', async () => {
+ const host = new FakeSession('connected')
+ host.sendRequest.mockResolvedValue({
+ id: 'state',
+ ok: true,
+ result: {
+ state: { targetId: 'minizc', status: 'disconnected', error: null, reconnectAttempt: 0 }
+ }
+ })
+ const blocked = await localTaskCreateKickoffBlocked({
+ client: host,
+ endpoint: SELF_LOCAL_TASK_ENDPOINT,
+ kickoff: {
+ target: { kind: 'existing', candidate: refund, holder: null, hostLabel: 'minizc' },
+ agent: 'claude',
+ firstLine: ''
+ },
+ words
+ })
+ expect(blocked).toBe(words.create.nothingCreated(words.copy.work.hostOffline('minizc')))
+ expect(host.sendRequest).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps the task and opens no session when the workspace was taken meanwhile', async () => {
+ const host = pairedHost(true)
+ const message = await run(host, {
+ target: { kind: 'existing', candidate: refund, holder: null, hostLabel: 'minizc' },
+ agent: 'claude',
+ firstLine: ''
+ })
+ expect(message).toContain('SHOP-16')
+ expect(message).toContain('refund-webhook')
+ expect(callsTo(host, 'agent.launchReplay')).toHaveLength(0)
+ })
+})
diff --git a/mobile/src/local-tasks/local-task-create-kickoff.ts b/mobile/src/local-tasks/local-task-create-kickoff.ts
new file mode 100644
index 000000000000..2bb6686397f1
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-create-kickoff.ts
@@ -0,0 +1,173 @@
+/**
+ * New task's 开工 (m8), run once the task exists and before the form hands it to its detail: link
+ * the picked workspace (unless its task takes the new one as a subtask) or create a new one, then
+ * open the session there, all on the library's own host (D-008). Nothing navigates away, so the
+ * phone stays on the task. Free of React so every host case tests in node.
+ */
+
+import { buildLocalTaskAgentPrompt } from '../../../src/shared/local-tasks/local-task-agent-prompt'
+import { localTaskKickoffPromptText } from '../../../src/shared/local-tasks/local-task-create-plan'
+import type { LocalTaskWorkspaceLink } from '../../../src/shared/local-tasks/local-task-link-types'
+import { suggestLocalTaskWorkspaceName } from '../../../src/shared/local-tasks/local-task-model'
+import { localTaskAncestors } from '../../../src/shared/local-tasks/local-task-tree'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import type { TuiAgent } from '../../../src/shared/tui-agent'
+import type { RpcClient } from '../transport/rpc-client'
+import type { LocalTasksApi } from './local-tasks-api'
+import type { LocalTasksCopy } from './local-tasks-copy'
+import {
+ openLocalTaskSession,
+ startLocalTaskInNewWorkspace,
+ type LocalTaskLaunchIds
+} from './local-task-agent-launch'
+import type { LocalTaskCreateCopy } from './local-task-create-copy'
+import type { LocalTaskEndpoint } from './local-task-endpoint'
+import { localTaskWriteMessage } from './local-task-failure-message'
+import { readLocalTaskHostContact } from './local-task-host-contact'
+import { localTaskLaunchMessage } from './local-task-launch-message'
+import { sendLocalTaskWrite } from './local-task-operation-ids'
+import {
+ localTaskLinkParamsFor,
+ type LocalTaskWorkspaceCandidate
+} from './local-task-workspace-candidates'
+import { localTaskWorkspaceRouteId } from './local-task-work-navigation'
+
+/** Where the new task works; host ids are owner-relative, labels as the user knows the host. */
+export type LocalTaskCreateKickoffTarget =
+ | {
+ kind: 'existing'
+ candidate: LocalTaskWorkspaceCandidate
+ /** The link of the task already working there: the session opens in it, no new link. */
+ holder: LocalTaskWorkspaceLink | null
+ hostLabel: string
+ }
+ | {
+ kind: 'new'
+ ownerHostId: string
+ repoId: string
+ /** Trimmed; empty takes the task's suggested name. */
+ name: string
+ hostLabel: string
+ }
+
+export type LocalTaskCreateKickoff = {
+ target: LocalTaskCreateKickoffTarget
+ /** Null opens no session: the workspace is linked or created, nothing else. */
+ agent: TuiAgent | null
+ firstLine: string
+}
+
+type Words = { copy: LocalTasksCopy; create: LocalTaskCreateCopy }
+
+function targetHost(target: LocalTaskCreateKickoffTarget): string {
+ if (target.kind === 'new') {
+ return target.ownerHostId
+ }
+ return target.holder?.hostId ?? target.candidate.hostId
+}
+
+/**
+ * Asked before the task is created: a host out of contact creates nothing, so the user can pick
+ * another place or 稍后再定 instead of being left with half of what they asked for.
+ */
+export async function localTaskCreateKickoffBlocked(args: {
+ client: RpcClient | null
+ endpoint: LocalTaskEndpoint
+ kickoff: LocalTaskCreateKickoff
+ words: Words
+}): Promise {
+ const contact = await readLocalTaskHostContact(
+ args.client,
+ args.endpoint,
+ targetHost(args.kickoff.target)
+ )
+ if (contact.kind === 'in-contact') {
+ return null
+ }
+ const { copy, create } = args.words
+ return create.nothingCreated(
+ contact.kind === 'out-of-contact'
+ ? copy.work.hostOffline(args.kickoff.target.hostLabel)
+ : copy.work.hostUnknown
+ )
+}
+
+/** Runs the kickoff for a created task; what to tell the user, or null when it all went through. */
+export async function kickOffCreatedLocalTask(args: {
+ client: RpcClient
+ capabilities: readonly string[]
+ api: LocalTasksApi
+ ids: LocalTaskLaunchIds
+ task: LocalTask
+ /** The library's tasks, for the summary of the new task's ancestors. */
+ tasks: readonly LocalTask[]
+ brief: boolean
+ kickoff: LocalTaskCreateKickoff
+ words: Words
+}): Promise {
+ const { task, kickoff } = args
+ const { copy, create } = args.words
+ const tasksById = new Map(args.tasks.map((entry) => [entry.id, entry]))
+ const prompt = {
+ text: localTaskKickoffPromptText(
+ kickoff.firstLine,
+ buildLocalTaskAgentPrompt(task, localTaskAncestors(task, tasksById), { brief: args.brief })
+ ),
+ // Why always sent: a host never delivers a draft the phone started.
+ delivery: 'submit' as const
+ }
+ const failed = (reason: string | null) =>
+ reason ? create.createdNotStarted(task.ref, reason) : null
+ const { target } = kickoff
+ if (target.kind === 'new') {
+ const outcome = await startLocalTaskInNewWorkspace({
+ launchClient: args.client,
+ hostCapabilities: args.capabilities,
+ api: args.api,
+ ids: args.ids,
+ workspaceTaskId: task.id,
+ sessionTaskId: task.id,
+ setup: { hostId: target.ownerHostId, repoId: target.repoId, label: target.hostLabel },
+ name: target.name || suggestLocalTaskWorkspaceName(task.ref, task.title),
+ agent: kickoff.agent,
+ prompt
+ })
+ return failed(localTaskLaunchMessage(copy, outcome))
+ }
+ let link = target.holder
+ if (!link) {
+ const linked = await sendLocalTaskWrite(
+ args.ids.link,
+ {
+ taskId: task.id,
+ ...localTaskLinkParamsFor(target.candidate, target.hostLabel),
+ linkedVia: 'manual' as const
+ },
+ args.api.linkWorkspace
+ )
+ if (linked.kind !== 'ok') {
+ return create
+ .createdNotLinked(
+ task.ref,
+ target.candidate.displayName,
+ localTaskWriteMessage(copy, linked, args.tasks) ?? ''
+ )
+ .trim()
+ }
+ link = linked.value.workspace
+ }
+ if (!kickoff.agent) {
+ return null
+ }
+ const outcome = await openLocalTaskSession({
+ launchClient: args.client,
+ hostCapabilities: args.capabilities,
+ api: args.api,
+ ids: args.ids.record,
+ sessionTaskId: task.id,
+ workspace: { hostId: link.hostId, routeId: localTaskWorkspaceRouteId(link) ?? '' },
+ agent: kickoff.agent,
+ prompt
+ })
+ return failed(localTaskLaunchMessage(copy, outcome))
+}
diff --git a/mobile/src/local-tasks/local-task-create-source-copy.ts b/mobile/src/local-tasks/local-task-create-source-copy.ts
new file mode 100644
index 000000000000..7bd98afcc4c1
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-create-source-copy.ts
@@ -0,0 +1,29 @@
+import { translate } from '../i18n/mobile-i18n'
+
+/**
+ * Create From → Local's own words, matching the desktop's Local tab; the rest is the task page's.
+ * Getters, so each read follows the phone's current language.
+ */
+export const LOCAL_TASK_CREATE_SOURCE_COPY_EN = {
+ get placeholder(): string {
+ return translate('localTasks.sourceTab.placeholder', 'Search local tasks by title or ref')
+ },
+ offline: (host: string): string =>
+ translate(
+ 'localTasks.sourceTab.offline',
+ '{{host}} is not connected, so tasks can’t be linked now. The tasks below are as last read.',
+ { host }
+ ),
+ get readOnly(): string {
+ return translate(
+ 'localTasks.sourceTab.readOnly',
+ 'This task library is read-only here. Update Orca to link tasks.'
+ )
+ },
+ get unavailable(): string {
+ return translate('localTasks.sourceTab.unavailable', 'The task library is not available now.')
+ },
+ get clearTask(): string {
+ return translate('localTasks.sourceTab.clearTask', 'Clear selected task')
+ }
+}
diff --git a/mobile/src/local-tasks/local-task-create-source-rows.ts b/mobile/src/local-tasks/local-task-create-source-rows.ts
new file mode 100644
index 000000000000..35b7cc59a5de
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-create-source-rows.ts
@@ -0,0 +1,97 @@
+import { LOCAL_TASK_STATUS_CATEGORIES } from '../../../src/shared/local-tasks/local-task-model'
+import {
+ isExactLocalTaskRef,
+ matchesLocalTaskQuery
+} from '../../../src/shared/local-tasks/local-task-grouping'
+import type { LocalTaskLinks } from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTask, LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
+
+/** A row of Create From's Local tab; `task` is null for the notice row saying why none can be picked. */
+export type LocalTaskCreateSourceRow = {
+ kind: 'local-task'
+ value: string
+ task: LocalTask | null
+ disabled: boolean
+ /** Muted text under the title: the workspace the task already has, or the notice itself. */
+ note: string | null
+}
+
+export function isLocalTaskCreateSourceRow(row: { kind: string }): row is LocalTaskCreateSourceRow {
+ return row.kind === 'local-task'
+}
+
+/** One Orca's task library, as the Local tab read it last. */
+export type LocalTaskCreateLibrary = {
+ projects: readonly LocalTaskProject[]
+ tasks: readonly LocalTask[]
+ links: LocalTaskLinks
+}
+
+const ROW_MAX = 50
+
+function categoryRank(project: LocalTaskProject | undefined, task: LocalTask): number {
+ const category = project?.statuses.find((status) => status.id === task.statusId)?.category
+ return category
+ ? LOCAL_TASK_STATUS_CATEGORIES.indexOf(category)
+ : LOCAL_TASK_STATUS_CATEGORIES.length
+}
+
+/**
+ * The tab's rows, ordered as the desktop's Local tab orders them (REQ-009, REQ-011): tasks of any
+ * level matching the query by title or any ref, the exact ref first; then those of task projects
+ * linking the picked repo's Orca project, open categories first, newest first. A task that already
+ * has a workspace is greyed with its name; `blocked` greys every row behind a notice row.
+ */
+export function buildLocalTaskCreateSourceRows(input: {
+ library: LocalTaskCreateLibrary
+ /** The picked repo's Orca project; its task projects rank first. */
+ orcaProjectId: string | null
+ query: string
+ blocked: string | null
+ hasWorkspace: (name: string) => string
+}): LocalTaskCreateSourceRow[] {
+ const { library, orcaProjectId, query, blocked } = input
+ const projects = new Map(library.projects.map((project) => [project.id, project]))
+ const related = (task: LocalTask): boolean =>
+ orcaProjectId !== null && projects.get(task.projectId)?.repos.includes(orcaProjectId) === true
+ const exact = (task: LocalTask): boolean =>
+ isExactLocalTaskRef(task, projects.get(task.projectId), query)
+ const matches = library.tasks
+ .filter(
+ (task) =>
+ projects.has(task.projectId) &&
+ matchesLocalTaskQuery(task, projects.get(task.projectId), query)
+ )
+ .sort(
+ (left, right) =>
+ Number(exact(right)) - Number(exact(left)) ||
+ Number(related(right)) - Number(related(left)) ||
+ categoryRank(projects.get(left.projectId), left) -
+ categoryRank(projects.get(right.projectId), right) ||
+ right.number - left.number
+ )
+ const rows = matches.slice(0, ROW_MAX).map((task): LocalTaskCreateSourceRow => {
+ const link = library.links.workspaces.find((candidate) => candidate.taskId === task.id)
+ const taken = link
+ ? input.hasWorkspace(link.nameSnapshot ?? link.branchSnapshot ?? link.worktreeId ?? '')
+ : null
+ return {
+ kind: 'local-task',
+ value: `local-task:${task.id}`,
+ task,
+ disabled: blocked !== null || taken !== null,
+ note: taken
+ }
+ })
+ if (!blocked) {
+ return rows
+ }
+ const notice: LocalTaskCreateSourceRow = {
+ kind: 'local-task',
+ value: 'local-task:notice',
+ task: null,
+ disabled: true,
+ note: blocked
+ }
+ return [notice, ...rows]
+}
diff --git a/mobile/src/local-tasks/local-task-create-source-start.test.ts b/mobile/src/local-tasks/local-task-create-source-start.test.ts
new file mode 100644
index 000000000000..decc311d2ec1
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-create-source-start.test.ts
@@ -0,0 +1,188 @@
+import { describe, expect, it } from 'vitest'
+import {
+ AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY,
+ AGENT_LAUNCH_RUNTIME_CAPABILITY
+} from '../../../src/shared/agent-launch-runtime-capability'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
+import { scopeRpcClientToExecutionHost } from '../transport/execution-host-scoped-rpc-client'
+import type { RpcResponse } from '../transport/types'
+import { localTasksCopy } from './local-tasks-copy'
+import { createLocalTaskOperationIds } from './local-task-operation-ids'
+import {
+ createLocalTaskSourcedWorkspace,
+ localTaskCreateOwner
+} from './local-task-create-source-start'
+
+const HOST_PANE = 'tab-1:6f1c2b0e-3d4a-4b5c-8d9e-0a1b2c3d4e5f'
+const CAPABILITIES = [
+ AGENT_LAUNCH_RUNTIME_CAPABILITY,
+ AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY
+]
+
+const task: LocalTask = {
+ id: 'task-12',
+ projectId: 'project-shop',
+ number: 12,
+ ref: 'SHOP-12',
+ title: 'Export orders as CSV',
+ description: 'Add a CSV button to the orders page.',
+ criteria: ['Downloads every visible column'],
+ statusId: 'todo',
+ priority: 0,
+ labels: [],
+ parentId: null,
+ createdAt: 1,
+ updatedAt: 1,
+ revision: 1
+}
+
+function result(method: string, params: Record): unknown {
+ switch (method) {
+ case 'agent.launchReplay':
+ return {
+ worktreeId: 'repo-1::/w/shop-12',
+ outcome: { kind: 'terminal', handle: 'term_1', paneKey: HOST_PANE },
+ receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: '' }
+ }
+ case 'localTasks.links.linkWorkspace':
+ return {
+ ok: true,
+ workspace: {
+ taskId: params.taskId,
+ hostId: params.hostId,
+ kind: 'git-worktree',
+ instanceId: null,
+ worktreeId: params.worktreeId,
+ folderWorkspaceId: null,
+ repoId: params.repoId,
+ branchSnapshot: null,
+ nameSnapshot: params.nameSnapshot,
+ hostLabelSnapshot: null,
+ linkedVia: 'start',
+ linkedAt: 1
+ }
+ }
+ case 'localTasks.links.recordSession':
+ return {
+ ok: true,
+ session: {
+ id: 'session-1',
+ taskId: params.taskId,
+ hostId: params.hostId,
+ worktreeId: params.worktreeId,
+ instanceId: null,
+ surface: 'terminal',
+ tabId: params.tabId,
+ paneKey: params.paneKey,
+ sessionId: null,
+ providerSession: null,
+ agent: params.agent,
+ linkedVia: 'start',
+ openedAt: 1
+ }
+ }
+ default:
+ throw new Error(`unexpected ${method}`)
+ }
+}
+
+/** The paired Orca: it answers for itself and, through the envelope, for the server it relays to. */
+function pairedHost() {
+ const host = new FakeSession('connected')
+ host.sendRequest.mockImplementation(
+ async (method: string, params?: unknown): Promise => ({
+ id: method,
+ ok: true,
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every call here sends an object.
+ result: result(method, params as Record)
+ })
+ )
+ return host
+}
+
+function callsTo(host: FakeSession, method: string) {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: vi.fn records the scoped view's third argument too.
+ const calls = host.sendRequest.mock.calls as unknown as [
+ string,
+ Record,
+ { envelope?: { executionHost?: string } } | undefined
+ ][]
+ return calls.filter(([name]) => name === method)
+}
+
+describe('Create From → Local links the new workspace in the library of the Orca that owns it', () => {
+ it.each([
+ {
+ where: "the paired Orca's own machine",
+ repo: { id: 'repo-1' },
+ library: 'self',
+ ownerHostId: 'local',
+ envelope: undefined
+ },
+ {
+ where: 'an SSH target the paired Orca reaches over its relay',
+ repo: { id: 'repo-1', connectionId: 'box' },
+ library: 'self',
+ ownerHostId: 'ssh:box',
+ envelope: undefined
+ },
+ {
+ where: 'a paired server, or an SSH host whose orcad serves it',
+ repo: { id: 'repo-1', executionHostId: 'runtime:env-1' },
+ library: 'runtime:env-1',
+ ownerHostId: 'local',
+ envelope: 'runtime:env-1'
+ }
+ ])('$where', async ({ repo, library, ownerHostId, envelope }) => {
+ const owner = localTaskCreateOwner(repo)
+ expect(owner?.endpoint.key).toBe(library)
+ expect(owner?.ownerHostId).toBe(ownerHostId)
+
+ const host = pairedHost()
+ const client = owner?.endpoint.executionHost
+ ? scopeRpcClientToExecutionHost(host, owner.endpoint.executionHost)
+ : host
+ const created = await createLocalTaskSourcedWorkspace({
+ client,
+ capabilities: CAPABILITIES,
+ ids: { link: createLocalTaskOperationIds(), record: createLocalTaskOperationIds() },
+ task,
+ tasks: [task],
+ repoId: repo.id,
+ ownerHostId: owner?.ownerHostId ?? '',
+ hostLabel: 'box',
+ request: { name: '', agent: 'claude', setupDecision: 'skip', note: 'from the phone' },
+ copy: localTasksCopy()
+ })
+
+ expect(created).toEqual({
+ worktreeId: 'repo-1::/w/shop-12',
+ name: 'shop-12-export-orders-as-csv'
+ })
+ const [launch] = callsTo(host, 'agent.launchReplay')
+ expect(launch?.[2]?.envelope?.executionHost).toBe(envelope)
+ expect(launch?.[1]).toMatchObject({
+ target: {
+ kind: 'create-worktree',
+ create: { repo: 'id:repo-1', setupDecision: 'skip', comment: 'from the phone' }
+ },
+ prompt: {
+ // A host never delivers a draft, so the phone sends the task as the first message.
+ delivery: 'submit',
+ text: expect.stringMatching(
+ /Export orders as CSV[\s\S]*Add a CSV button to the orders page\./
+ )
+ }
+ })
+ const links = callsTo(host, 'localTasks.links.linkWorkspace')
+ expect(links).toHaveLength(1)
+ expect(links[0]?.[2]?.envelope?.executionHost).toBe(envelope)
+ expect(links[0]?.[1]).toMatchObject({
+ taskId: 'task-12',
+ hostId: ownerHostId,
+ worktreeId: 'repo-1::/w/shop-12',
+ linkedVia: 'start'
+ })
+ })
+})
diff --git a/mobile/src/local-tasks/local-task-create-source-start.ts b/mobile/src/local-tasks/local-task-create-source-start.ts
new file mode 100644
index 000000000000..da7e457673b6
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-create-source-start.ts
@@ -0,0 +1,112 @@
+/**
+ * Create From → Local on the phone's new-workspace sheet: the same Start the task page runs
+ * (`startLocalTaskInNewWorkspace`), with the sheet's agent, setup choice and note, the task sent
+ * as the agent's first message, and the link written into the library of the Orca that owns the
+ * new workspace (D-008), the one "Link to Task…" writes for that workspace too. Free of React so
+ * every host case tests in node.
+ */
+
+import { getRepoExecutionHostId } from '../../../src/shared/execution-host'
+import { buildLocalTaskAgentPrompt } from '../../../src/shared/local-tasks/local-task-agent-prompt'
+import { suggestLocalTaskWorkspaceName } from '../../../src/shared/local-tasks/local-task-model'
+import { localTaskAncestors } from '../../../src/shared/local-tasks/local-task-tree'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import type { TuiAgent } from '../../../src/shared/tui-agent'
+import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
+import type { WorktreeCreateResult } from '../tasks/worktree-create-retry'
+import type { RpcClient } from '../transport/rpc-client'
+import { createLocalTasksApi } from './local-tasks-api'
+import type { LocalTasksCopy } from './local-tasks-copy'
+import { startLocalTaskInNewWorkspace, type LocalTaskLaunchIds } from './local-task-agent-launch'
+import {
+ localTaskEndpointFor,
+ localTaskOwnerHostId,
+ type LocalTaskEndpoint
+} from './local-task-endpoint'
+import { localTaskLaunchMessage } from './local-task-launch-message'
+
+/**
+ * Whose library links a repo's new workspace, and the host as that library names it:
+ * - the paired Orca's own machine: its library, `local`;
+ * - an SSH target it reaches over its relay: still its library, `ssh:{target}`;
+ * - a paired server, or an SSH host whose orcad runs it (`runtime:{env}` to the phone): that
+ * Orca's own library, `local`.
+ * Null for a repo no library here can own.
+ */
+export function localTaskCreateOwner(repo: {
+ connectionId?: string | null
+ executionHostId?: string | null
+}): { endpoint: LocalTaskEndpoint; ownerHostId: string } | null {
+ const clientHostId = getRepoExecutionHostId(repo)
+ const endpoint = localTaskEndpointFor(clientHostId)
+ const ownerHostId = localTaskOwnerHostId(endpoint, clientHostId)
+ return ownerHostId === null ? null : { endpoint, ownerHostId }
+}
+
+/** What the sheet chose, as its own create reads it. */
+export type LocalTaskCreateRequest = {
+ /** The typed name, trimmed; empty takes the task's suggested name. */
+ name: string
+ agent: TuiAgent | null
+ setupDecision: WorkspaceCreateSetupDecision
+ note: string | undefined
+}
+
+export type LocalTaskCreateRun = (request: LocalTaskCreateRequest) => Promise
+
+export async function createLocalTaskSourcedWorkspace(args: {
+ /** The workspace host's Orca, which also holds the library: the paired one or a server's view. */
+ client: RpcClient
+ capabilities: readonly string[]
+ ids: LocalTaskLaunchIds
+ task: LocalTask
+ /** The library's tasks when the task was picked, for its ancestors' summary. */
+ tasks: readonly LocalTask[]
+ repoId: string
+ ownerHostId: string
+ hostLabel: string
+ request: LocalTaskCreateRequest
+ copy: LocalTasksCopy
+}): Promise {
+ const { task, request } = args
+ const name = request.name || suggestLocalTaskWorkspaceName(task.ref, task.title)
+ const tasksById = new Map(args.tasks.map((candidate) => [candidate.id, candidate]))
+ const settings = await createLocalTasksApi(args.client)
+ .status()
+ .catch(() => null)
+ const outcome = await startLocalTaskInNewWorkspace({
+ launchClient: args.client,
+ hostCapabilities: args.capabilities,
+ api: createLocalTasksApi(args.client),
+ ids: args.ids,
+ workspaceTaskId: task.id,
+ sessionTaskId: task.id,
+ setup: { hostId: args.ownerHostId, repoId: args.repoId, label: args.hostLabel },
+ name,
+ agent: request.agent,
+ prompt: {
+ text: buildLocalTaskAgentPrompt(task, localTaskAncestors(task, tasksById), {
+ brief: settings?.taskBrief ?? true
+ }),
+ delivery: settings?.sessionSend ?? 'submit'
+ },
+ choices: {
+ setupDecision: request.setupDecision,
+ ...(request.note ? { comment: request.note } : {})
+ }
+ })
+ const message = localTaskLaunchMessage(args.copy, outcome)
+ switch (outcome.kind) {
+ case 'workspace-created':
+ return { worktreeId: outcome.worktreeId, name }
+ case 'done':
+ case 'not-linked':
+ case 'session-not-recorded':
+ // The workspace exists: open it, and say what did not reach the task.
+ return { worktreeId: outcome.worktreeId, name, ...(message ? { warning: message } : {}) }
+ case 'failed':
+ case 'unconfirmed':
+ case 'workspace-unconfirmed':
+ return { error: message ?? args.copy.failureGeneric('agent.launch') }
+ }
+}
diff --git a/mobile/src/local-tasks/local-task-create-source-views.tsx b/mobile/src/local-tasks/local-task-create-source-views.tsx
new file mode 100644
index 000000000000..2532f6f9c4d3
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-create-source-views.tsx
@@ -0,0 +1,115 @@
+import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { ListTodo, X } from 'lucide-react-native'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { colors, pressedStyle, spacing, typography } from '../theme/mobile-theme'
+import { LIST_LEADING_INSET, ListGroup, ListRow } from '../components/list/ListGroup'
+import { LOCAL_TASK_CREATE_SOURCE_COPY_EN } from './local-task-create-source-copy'
+import type { LocalTaskCreateSourceRow } from './local-task-create-source-rows'
+
+/**
+ * A Local row in Create From's results, laid out like upstream's source rows: the title leads, the
+ * ref small and muted beneath it with the reason a greyed row cannot be picked.
+ */
+export function LocalTaskCreateSourceRowView(props: {
+ row: LocalTaskCreateSourceRow
+ onPress: () => void
+}) {
+ const { row } = props
+ if (!row.task) {
+ return {row.note}
+ }
+ const detail = [row.task.ref, row.note].filter(Boolean).join(' · ')
+ return (
+ [styles.row, pressed && !row.disabled && styles.rowPressed]}
+ disabled={row.disabled}
+ accessibilityRole="button"
+ accessibilityState={{ disabled: row.disabled }}
+ accessibilityLabel={`${row.task.title}, ${detail}`}
+ onPress={props.onPress}
+ >
+
+
+
+
+
+ {row.task.title}
+
+
+ {detail}
+
+
+
+ )
+}
+
+/** The picked task in place of the name field, as upstream shows a picked issue: title, then ref. */
+export function LocalTaskCreateSourcePill(props: { task: LocalTask; onClear: () => void }) {
+ return (
+
+ }
+ accessory={
+ [styles.clear, pressed && pressedStyle]}
+ accessibilityRole="button"
+ accessibilityLabel={LOCAL_TASK_CREATE_SOURCE_COPY_EN.clearTask}
+ hitSlop={6}
+ onPress={props.onClear}
+ >
+
+
+ }
+ />
+
+ )
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2
+ },
+ rowPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ icon: {
+ width: 18,
+ alignItems: 'center'
+ },
+ copy: {
+ flex: 1,
+ minWidth: 0
+ },
+ title: {
+ fontSize: typography.bodySize,
+ color: colors.textPrimary
+ },
+ subtitle: {
+ fontSize: typography.metaSize,
+ color: colors.textMuted,
+ marginTop: 1
+ },
+ disabled: {
+ opacity: 0.55
+ },
+ notice: {
+ fontSize: typography.metaSize,
+ color: colors.textSecondary,
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.md + 2
+ },
+ // The 36 pt hit box reaches into the row's padding so the glyph lines up with its edge.
+ clear: {
+ width: 36,
+ height: 36,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: -spacing.sm
+ }
+})
diff --git a/mobile/src/local-tasks/local-task-create-where-section.tsx b/mobile/src/local-tasks/local-task-create-where-section.tsx
new file mode 100644
index 000000000000..334552b90115
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-create-where-section.tsx
@@ -0,0 +1,387 @@
+import { useState, type ReactNode } from 'react'
+import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
+import { Folder, GitBranch, Info, Laptop, Search, Server } from 'lucide-react-native'
+import { MobileSegmentedControl } from '../components/MobileSegmentedControl'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { localTasksCopy } from './local-tasks-copy'
+import type { LocalTaskCreateWorkspaceChoice } from './local-task-create-choices'
+import { localTaskCreateCopy } from './local-task-create-copy'
+import { localTaskReviewLabel } from './local-task-workspace-review-line'
+import { localTaskStyles } from './local-tasks-styles'
+import type { LocalTaskCreateWhereState } from './use-local-task-create-where'
+
+// Why a cap: the step sits above 开工 in one scroll; search or "Show all" reaches the rest.
+const ROWS_SHOWN = 10
+
+export function LocalTaskCreateStepHeading(props: { step: number; title: string }) {
+ return (
+
+
+ {props.step}
+
+ {props.title}
+
+ )
+}
+
+export function LocalTaskCreateNote(props: { children: ReactNode; warn?: boolean }) {
+ return (
+
+
+ {props.children}
+
+ )
+}
+
+export function LocalTaskCreateRadio(props: { on: boolean }) {
+ return (
+
+ {props.on ? : null}
+
+ )
+}
+
+function choiceLine(choice: LocalTaskCreateWorkspaceChoice, hostName: string): string {
+ const create = localTaskCreateCopy()
+ const work = localTasksCopy().work
+ if (choice.block === 'host-offline') {
+ return create.hostNotConnected(hostName)
+ }
+ if (choice.owner) {
+ return create.ownedSubtask(choice.owner.ref)
+ }
+ if (choice.block === 'no-project') {
+ return create.noProjectHere
+ }
+ if (choice.review) {
+ const { checks } = choice.review
+ return [
+ localTaskReviewLabel(choice.review, true),
+ checks && checks !== 'neutral' ? work.checks[checks] : null
+ ]
+ .filter(Boolean)
+ .join(' · ')
+ }
+ if (choice.main) {
+ return create.mainWorkspace
+ }
+ return choice.folder ? create.folderWorkspace : create.free
+}
+
+function WorkspaceRow(props: {
+ choice: LocalTaskCreateWorkspaceChoice
+ picked: boolean
+ hostName: string
+ onPick: () => void
+}) {
+ const { choice } = props
+ const blocked = choice.block !== null
+ return (
+ [
+ localTaskStyles.cardRow,
+ styles.choiceRow,
+ props.picked && styles.picked,
+ pressed && !blocked && localTaskStyles.pressed,
+ blocked && localTaskStyles.disabled
+ ]}
+ disabled={blocked}
+ onPress={props.onPick}
+ accessibilityRole="radio"
+ accessibilityState={{ checked: props.picked, disabled: blocked }}
+ >
+
+
+
+ {choice.candidate.displayName}
+
+
+ {choiceLine(choice, props.hostName)}
+
+
+
+ )
+}
+
+function ExistingWorkspaces({ state }: { state: LocalTaskCreateWhereState }) {
+ const create = localTaskCreateCopy()
+ const [showAll, setShowAll] = useState(false)
+ const total = state.groups.reduce((sum, group) => sum + group.choices.length, 0)
+ let budget = showAll ? total : ROWS_SHOWN
+ let body: ReactNode
+ if (state.workspaces === 'loading') {
+ body = (
+
+
+ {create.loadingWorkspaces}
+
+ )
+ } else if (state.workspaces === 'failed') {
+ body = {create.workspacesUnread}
+ } else if (state.groups.length === 0) {
+ body = (
+
+ {state.hasWorkspaces ? create.noMatches : create.noWorkspaces}
+
+ )
+ } else {
+ body = state.groups.map((group, index) => {
+ // The picked row stays shown, whatever the cap leaves out.
+ const rows = group.choices.filter((choice) => {
+ if (budget > 0 || choice.key === state.picked?.key) {
+ budget -= 1
+ return true
+ }
+ return false
+ })
+ if (rows.length === 0) {
+ return null
+ }
+ const hostName = state.hosts.name(group.ownerHostId)
+ const HostIcon = group.ownerHostId === 'local' ? Laptop : Server
+ return (
+
+
+
+
+ {[group.repoLabel, hostName].filter(Boolean).join(' · ')}
+
+ {group.offline ? (
+ {create.notConnected}
+ ) : null}
+
+ {rows.map((choice) => (
+ state.pickWorkspace(choice)}
+ />
+ ))}
+
+ )
+ })
+ }
+ return (
+ <>
+
+
+
+
+
+ {body}
+ {!showAll && total > ROWS_SHOWN && state.workspaces === 'ready' ? (
+ [localTaskStyles.cardRow, pressed && localTaskStyles.pressed]}
+ onPress={() => setShowAll(true)}
+ accessibilityRole="button"
+ >
+ {create.showAll(total)}
+
+ ) : null}
+
+ >
+ )
+}
+
+function NewWorkspace({ state }: { state: LocalTaskCreateWhereState }) {
+ const create = localTaskCreateCopy()
+ const { repo } = state
+ const repoHost = repo ? state.hosts.name(repo.setup.ownerHostId) : ''
+ const offlineHosts = [
+ ...new Set(
+ state.repos
+ .filter((choice) => choice.block === 'host-offline')
+ .map((choice) => state.hosts.name(choice.setup.ownerHostId))
+ .filter(Boolean)
+ )
+ ]
+ let chips: ReactNode
+ if (state.catalog === 'loading') {
+ chips = (
+ <>
+
+ {create.loadingRepos}
+ >
+ )
+ } else if (state.catalog === 'unavailable') {
+ chips = {create.catalogUnavailable}
+ } else if (state.repos.length === 0) {
+ chips = {create.noRepos}
+ } else {
+ chips = state.repos.map((choice) => {
+ const host = state.hosts.name(choice.setup.ownerHostId)
+ const on = choice.key === repo?.key
+ const offline = choice.block === 'host-offline'
+ return (
+ state.pickRepo(choice)}
+ accessibilityRole="radio"
+ accessibilityState={{ checked: on, disabled: choice.block !== null }}
+ >
+
+
+ {[choice.setup.label, offline ? create.hostNotConnected(host) : host]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+ )
+ })
+ }
+ return (
+ <>
+
+
+ {create.repo}
+ {chips}
+
+
+ {create.name}
+
+
+
+ {create.base}
+
+
+ {create.baseDefault}
+
+ {repo ? (
+
+ {create.repoOnHost(repo.setup.label, repoHost)}
+
+ ) : null}
+
+
+ {repo ? {create.newNote(repoHost)} : null}
+ {offlineHosts.length > 0 ? (
+
+ {create.offlineReposNote(offlineHosts.join(', '))}
+
+ ) : null}
+ >
+ )
+}
+
+/** ② 在哪做 (m8): an existing workspace, a new one from a repository, or decide later. */
+export function LocalTaskCreateWhereSection({ state }: { state: LocalTaskCreateWhereState }) {
+ const create = localTaskCreateCopy()
+ return (
+
+
+ {state.unavailable ? (
+ {state.unavailable}
+ ) : null}
+
+ {state.where === 'existing' ? : null}
+ {state.where === 'new' ? : null}
+ {state.where === 'later' && !state.unavailable ? (
+
+ {create.laterNote}
+
+ ) : null}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ section: { gap: spacing.sm, marginTop: spacing.lg },
+ stepHeading: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
+ stepNumber: {
+ width: 20,
+ height: 20,
+ borderRadius: radii.pill,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ backgroundColor: colors.bgRaised,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ stepNumberText: { color: colors.textSecondary, fontSize: 11, fontWeight: '600' },
+ stepTitle: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
+ note: { flexDirection: 'row', alignItems: 'flex-start', gap: 6 },
+ noteText: { flex: 1 },
+ radio: {
+ width: 18,
+ height: 18,
+ borderRadius: radii.pill,
+ borderWidth: 1.5,
+ borderColor: colors.textMuted,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ radioOn: { borderColor: colors.textPrimary },
+ radioDot: { width: 8, height: 8, borderRadius: radii.pill, backgroundColor: colors.textPrimary },
+ search: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
+ searchInput: { flex: 1 },
+ status: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, padding: spacing.md },
+ groupHead: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ backgroundColor: colors.bgRaised,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.borderSubtle
+ },
+ groupText: {
+ flex: 1,
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ },
+ warn: { color: colors.statusAmber },
+ choiceRow: { gap: spacing.md },
+ picked: { backgroundColor: colors.bgRaised },
+ rowText: { flex: 1, minWidth: 0, gap: 2 },
+ top: { alignItems: 'flex-start' },
+ chips: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
+ repoChip: { maxWidth: '100%' },
+ chipOn: { borderColor: colors.textSecondary, backgroundColor: colors.bgRaised },
+ mono: { fontFamily: typography.monoFamily },
+ flex: { flex: 1 },
+ laterNote: { marginHorizontal: 0, marginBottom: 0 }
+})
diff --git a/mobile/src/local-tasks/local-task-detail-head.tsx b/mobile/src/local-tasks/local-task-detail-head.tsx
new file mode 100644
index 000000000000..1d3c0032757b
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-detail-head.tsx
@@ -0,0 +1,112 @@
+import type { ReactNode } from 'react'
+import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { CornerDownRight, GitBranch, Plus } from 'lucide-react-native'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import type { LocalTaskCardFacts } from './local-task-card-facts'
+import { localTasksCopy } from './local-tasks-copy'
+import { localTaskStyles } from './local-tasks-styles'
+import type { LocalTaskWorkControls } from './local-task-work-section'
+
+/** The parents above a subtask, each one tap back up. */
+export function LocalTaskAncestorPath(props: {
+ ancestors: readonly LocalTask[]
+ onOpenTask: (taskId: string) => void
+}) {
+ if (props.ancestors.length === 0) {
+ return null
+ }
+ return (
+
+
+ {props.ancestors.map((ancestor, index) => (
+ props.onOpenTask(ancestor.id)}
+ accessibilityRole="button"
+ accessibilityLabel={`${ancestor.title} ${ancestor.ref}`}
+ >
+ {index > 0 ? › : null}
+
+ {ancestor.title}
+
+
+ ))}
+
+ )
+}
+
+/**
+ * Where the task works (workspace · host · PR) and the head's one main action (m4), then whatever
+ * the workspace controls have to say: why a button is off, what is starting, what failed.
+ */
+export function LocalTaskDetailWhere(props: {
+ facts: LocalTaskCardFacts
+ reviewDetail: string | null
+ primary: LocalTaskWorkControls['primary']
+ notices: ReactNode
+ readOnly: boolean
+}) {
+ const card = localTasksCopy().work.card
+ const { facts, primary } = props
+ return (
+ <>
+ {facts.workspace ? (
+
+
+
+ {[
+ facts.workspace.name,
+ facts.offline ? card.hostOffline(facts.hostName) : facts.hostName,
+ props.reviewDetail
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+ ) : null}
+ {primary && !props.readOnly ? (
+
+
+ {primary.label}
+
+ ) : null}
+ {props.notices}
+ >
+ )
+}
+
+const styles = StyleSheet.create({
+ parentRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ gap: 6,
+ marginTop: spacing.md
+ },
+ ancestor: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 },
+ parentTitle: { flexShrink: 1, color: colors.textSecondary, fontSize: typography.metaSize },
+ whereRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: spacing.md },
+ shrink: { flexShrink: 1 },
+ // The light pill the prototype draws for the head's one main action.
+ primary: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ alignSelf: 'flex-start',
+ gap: 6,
+ marginTop: spacing.md,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ borderRadius: radii.pill,
+ backgroundColor: colors.surfaceBright
+ },
+ primaryText: { color: colors.bgBase, fontSize: typography.bodySize, fontWeight: '600' },
+ notices: { gap: 4, marginTop: spacing.sm }
+})
diff --git a/mobile/src/local-tasks/local-task-detail-screen.tsx b/mobile/src/local-tasks/local-task-detail-screen.tsx
new file mode 100644
index 000000000000..32a403d1ade7
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-detail-screen.tsx
@@ -0,0 +1,355 @@
+import { useState } from 'react'
+import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { ChevronLeft, Ellipsis, Layers, Pencil, Trash2 } from 'lucide-react-native'
+import { buildLocalTaskIndex } from '../../../src/shared/local-tasks/local-task-grouping'
+import { localTaskPathFromRoot } from '../../../src/shared/local-tasks/local-task-hierarchy'
+import { localTaskDescendantIds } from '../../../src/shared/local-tasks/local-task-tree'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { localTaskWritebacks } from '../../../src/shared/local-tasks/local-task-writebacks'
+import { ActionSheetModal } from '../components/ActionSheetModal'
+import { ConfirmModal } from '../components/ConfirmModal'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+import { formatLocalTaskAgo, localTasksCopy } from './local-tasks-copy'
+import { LocalTaskActivitySection } from './local-task-activity-section'
+import { localTaskWriteMessage } from './local-task-failure-message'
+import { localTaskStatusLookup } from './local-task-list-projection'
+import { LocalTaskAncestorPath, LocalTaskDetailWhere } from './local-task-detail-head'
+import { LocalTaskDetailSections } from './local-task-detail-sections'
+import type { LocalTaskDetailProps } from './local-task-detail-view'
+import { createLocalTaskOperationIds, sendLocalTaskWrite } from './local-task-operation-ids'
+import { LocalTaskPriorityPicker, LocalTaskStatusPicker } from './local-task-pickers'
+import { localTaskRowSessionStates, localTaskSessionViews } from './local-task-progress-projection'
+import { localTaskCardFacts } from './local-task-card-facts'
+import { localTaskLatestProgress } from './local-task-latest-line'
+import { LocalTaskNowCard } from './local-task-now-card'
+import { LocalTaskPreviewDrawer } from './local-task-preview-drawer'
+import { LocalTaskReviewActions } from './local-task-review-actions'
+import { localTaskChatPreviewTarget } from './local-task-session-open'
+import { useLocalTaskCardContext } from './use-local-task-card-context'
+import { localTaskStyles } from './local-tasks-styles'
+import { LocalTaskPriorityIcon, LocalTaskStatusIcon } from './local-tasks-visuals'
+import { useLocalTaskEvents } from './use-local-tasks-library'
+import { useLocalTaskWorkControls } from './local-task-work-section'
+
+/**
+ * A task's page as prototype m4 lays it out: where it works and the main "New session" button in
+ * the head, acceptance, "Now" with the latest progress and every session, then the description,
+ * subtasks and activity. Workspace actions sit in the "…" menu.
+ */
+export function LocalTaskDetailScreen(props: LocalTaskDetailProps & { task: LocalTask }) {
+ const copy = localTasksCopy()
+ const insets = useSafeAreaInsets()
+ const [ids] = useState(() => ({
+ update: createLocalTaskOperationIds(),
+ remove: createLocalTaskOperationIds()
+ }))
+ const [picker, setPicker] = useState<'status' | 'priority' | null>(null)
+ const [menuOpen, setMenuOpen] = useState(false)
+ const [confirmDelete, setConfirmDelete] = useState(false)
+ const [error, setError] = useState(null)
+ const [previewSessionId, setPreviewSessionId] = useState(null)
+ const { library, task } = props
+ const project = library.projects.find((entry) => entry.id === task.projectId) ?? null
+ const lookup = localTaskStatusLookup(library.projects)
+ const status = lookup.get(task.statusId) ?? null
+ const index = buildLocalTaskIndex(library.projects, library.tasks)
+ const ancestors = localTaskPathFromRoot(task, index.tasksById)
+ const subtasks = index.childrenByParentId.get(task.id) ?? []
+ const descendants = localTaskDescendantIds(task.id, index.childrenByParentId).flatMap((id) => {
+ const child = index.tasksById.get(id)
+ return child ? [child] : []
+ })
+ const inContact = props.site.connState === 'connected'
+ const cards = useLocalTaskCardContext({
+ site: props.site,
+ library,
+ reviewTasks: status?.category === 'review' ? [task] : []
+ })
+ const facts = localTaskCardFacts({
+ task,
+ index,
+ links: library.work.links,
+ snapshot: library.work.progress,
+ inContact,
+ hostName: cards.context.hostName,
+ hostConnected: cards.context.hostConnected
+ })
+ const reviewDetail = cards.context.reviewDetail(task)
+ const events = useLocalTaskEvents(
+ props.canRead ? props.api : null,
+ task.id,
+ library.eventsVersion
+ )
+ const work = useLocalTaskWorkControls({
+ task,
+ library,
+ api: props.api,
+ site: props.site,
+ canWrite: props.canRead && !props.readOnly,
+ onNavigate: props.onNavigate
+ })
+ // Q2: a chat session is answered in the drawer; a terminal one opens its own page.
+ const openSession = (id: string) => {
+ if (localTaskChatPreviewTarget(library, id)) {
+ setPreviewSessionId(id)
+ return null
+ }
+ return props.onOpenSession(id)
+ }
+
+ const update = async (patch: { statusId?: string; priority?: LocalTask['priority'] }) => {
+ const outcome = await sendLocalTaskWrite(
+ ids.update,
+ { taskId: task.id, expectedRevision: task.revision, patch },
+ props.api.updateTask
+ )
+ setError(localTaskWriteMessage(copy, outcome))
+ if (outcome.kind === 'ok') {
+ library.acceptTasks([outcome.value.task])
+ }
+ library.reload(['tasks'])
+ return outcome.kind === 'ok'
+ }
+
+ const remove = async () => {
+ setConfirmDelete(false)
+ const outcome = await sendLocalTaskWrite(ids.remove, { taskId: task.id }, props.api.removeTask)
+ setError(localTaskWriteMessage(copy, outcome))
+ library.reload(['tasks'])
+ if (outcome.kind === 'ok') {
+ props.onBack()
+ }
+ }
+
+ return (
+
+
+
+
+ {copy.title}
+
+
+
+ {task.ref}
+
+ setMenuOpen(true)}
+ accessibilityRole="button"
+ accessibilityLabel={copy.moreActions}
+ testID="local-task-detail-menu"
+ >
+
+
+
+
+
+
+
+ {project?.name ?? ''} ·{' '}
+ {copy.updatedAgo(formatLocalTaskAgo(copy, task.updatedAt, props.now))}
+
+
+ {task.title}
+
+ setPicker('status')}
+ >
+
+ {status?.name ?? copy.unknownStatus}
+
+ setPicker('priority')}
+ >
+
+ {copy.priorities[task.priority]}
+
+ {task.labels.map((label) => (
+
+ {label}
+
+ ))}
+
+
+
+ {error ? {error} : null}
+ update({ statusId })}
+ onError={setError}
+ />
+ setError(openSession(id))}
+ onLongPressSession={
+ work.requestUnlinkSession
+ ? (id) => {
+ const link = library.work.links.sessions.find((entry) => entry.id === id)
+ if (link) {
+ work.requestUnlinkSession?.(link)
+ }
+ }
+ : null
+ }
+ />
+
+ localTaskRowSessionStates({
+ task: child,
+ index,
+ links: library.work.links,
+ snapshot: library.work.progress,
+ inContact
+ })
+ }
+ onEdit={props.onEdit}
+ onAddSubtask={props.onAddSubtask}
+ onOpenTask={props.onOpenTask}
+ />
+ library.reload(['events'])}
+ />
+
+ void update({ statusId })}
+ onClose={() => setPicker(null)}
+ />
+ void update({ priority })}
+ onClose={() => setPicker(null)}
+ />
+ props.onEdit(task) },
+ {
+ label: copy.delete,
+ icon: Trash2,
+ destructive: true,
+ closeBeforePress: true,
+ onPress: () => setConfirmDelete(true)
+ }
+ ])
+ ]}
+ onClose={() => setMenuOpen(false)}
+ />
+ {work.sheets}
+ void remove()}
+ onCancel={() => setConfirmDelete(false)}
+ />
+ setError(props.onOpenSession(id))}
+ onClose={() => setPreviewSessionId(null)}
+ />
+
+ )
+}
+
+const styles = StyleSheet.create({
+ titleCenter: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 6
+ },
+ // The ref only says where the page is; the title below is the task's name (§4), so it stays muted.
+ ref: {
+ color: colors.textMuted,
+ fontFamily: typography.monoFamily,
+ fontSize: 13
+ },
+ content: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
+ metaRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ title: { color: colors.textPrimary, fontSize: 20, fontWeight: '700', marginTop: spacing.sm },
+ chips: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ gap: spacing.sm,
+ marginTop: spacing.md
+ },
+ error: { marginTop: spacing.sm }
+})
diff --git a/mobile/src/local-tasks/local-task-detail-sections.tsx b/mobile/src/local-tasks/local-task-detail-sections.tsx
index 7c270b19516a..80c9adda3ca1 100644
--- a/mobile/src/local-tasks/local-task-detail-sections.tsx
+++ b/mobile/src/local-tasks/local-task-detail-sections.tsx
@@ -4,27 +4,20 @@ import type {
LocalTask,
LocalTaskStatusDef
} from '../../../src/shared/local-tasks/local-task-types'
-import { colors, spacing } from '../theme/mobile-theme'
+import { colors } from '../theme/mobile-theme'
import { localTasksCopy } from './local-tasks-copy'
+import type { LocalTaskRowSession } from './local-task-progress-projection'
import { LocalTaskSubtaskRow } from './local-tasks-list-view'
import { localTaskStyles } from './local-tasks-styles'
-/** Keys a read-only line by its text and how many identical lines precede it, not by position. */
-function keyedLines(lines: readonly string[]): { key: string; text: string }[] {
- const seen = new Map()
- return lines.map((text) => {
- const occurrence = (seen.get(text) ?? 0) + 1
- seen.set(text, occurrence)
- return { key: `${occurrence}:${text}`, text }
- })
-}
-
-/** Description, done criteria and the task's direct subtasks; any level may have them (D-008). */
+/** Description and the task's direct subtasks; any level may have them. */
export function LocalTaskDetailSections(props: {
task: LocalTask
subtasks: LocalTask[]
statuses: Map
readOnly: boolean
+ /** The sessions a subtask's line shows as dots (REQ-012). */
+ sessionsFor: (task: LocalTask) => LocalTaskRowSession[]
onEdit: (task: LocalTask) => void
onAddSubtask: (parent: LocalTask) => void
onOpenTask: (taskId: string) => void
@@ -48,18 +41,6 @@ export function LocalTaskDetailSections(props: {
{task.description || copy.noDescription}
- {task.criteria.length > 0 ? (
- <>
- {copy.criteria}
- {keyedLines(task.criteria).map((line) => (
- // Hanging indent: the bullet keeps its own column, wrapped lines align with the text.
-
- •
- {line.text}
-
- ))}
- >
- ) : null}
{props.subtasks.length > 0
@@ -81,6 +62,7 @@ export function LocalTaskDetailSections(props: {
task={child}
status={props.statuses.get(child.statusId) ?? null}
inCard
+ sessions={props.sessionsFor(child)}
onOpen={props.onOpenTask}
/>
))}
@@ -91,9 +73,5 @@ export function LocalTaskDetailSections(props: {
}
const styles = StyleSheet.create({
- sectionTitle: { color: colors.textMuted, fontSize: 12, fontWeight: '600' },
- sectionGap: { marginTop: spacing.lg, marginBottom: spacing.sm },
- bulletRow: { flexDirection: 'row', marginBottom: 4 },
- bulletMark: { width: 14 },
- bulletText: { flex: 1 }
+ sectionTitle: { color: colors.textMuted, fontSize: 12, fontWeight: '600' }
})
diff --git a/mobile/src/local-tasks/local-task-detail-view.tsx b/mobile/src/local-tasks/local-task-detail-view.tsx
index cc75eef49a42..5c813693fc33 100644
--- a/mobile/src/local-tasks/local-task-detail-view.tsx
+++ b/mobile/src/local-tasks/local-task-detail-view.tsx
@@ -1,29 +1,16 @@
-import { useState } from 'react'
-import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
+import { Pressable, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import { ChevronLeft, CornerDownRight, Ellipsis, Layers, Pencil, Trash2 } from 'lucide-react-native'
-import { buildLocalTaskIndex } from '../../../src/shared/local-tasks/local-task-grouping'
-import { localTaskPathFromRoot } from '../../../src/shared/local-tasks/local-task-hierarchy'
-import { localTaskDescendantIds } from '../../../src/shared/local-tasks/local-task-tree'
+import { ChevronLeft } from 'lucide-react-native'
import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
-import { ActionSheetModal } from '../components/ActionSheetModal'
-import { ConfirmModal } from '../components/ConfirmModal'
-import { colors, spacing, typography } from '../theme/mobile-theme'
+import { colors } from '../theme/mobile-theme'
import type { LocalTasksApi } from './local-tasks-api'
-import { formatLocalTaskAgo, localTasksCopy } from './local-tasks-copy'
-import { LocalTaskActivitySection } from './local-task-activity-section'
-import { localTaskWriteMessage } from './local-task-failure-message'
-import { localTaskStatusLookup } from './local-task-list-projection'
-import { LocalTaskDetailSections } from './local-task-detail-sections'
-import { createLocalTaskOperationIds, sendLocalTaskWrite } from './local-task-operation-ids'
-import { LocalTaskPriorityPicker, LocalTaskStatusPicker } from './local-task-pickers'
+import { localTasksCopy } from './local-tasks-copy'
+import { LocalTaskDetailScreen } from './local-task-detail-screen'
+import type { LocalTaskSite } from './local-task-endpoint'
import { localTaskStyles } from './local-tasks-styles'
-import { LocalTaskPriorityIcon, LocalTaskStatusIcon } from './local-tasks-visuals'
import type { LocalTasksLibrary } from './use-local-tasks-library'
-import type { LocalTaskSite } from './local-task-endpoint'
-import { LocalTaskWorkSection } from './local-task-work-section'
-type Props = {
+export type LocalTaskDetailProps = {
library: LocalTasksLibrary
api: LocalTasksApi
taskId: string
@@ -35,64 +22,19 @@ type Props = {
onEdit: (task: LocalTask) => void
onAddSubtask: (parent: LocalTask) => void
site: LocalTaskSite
- onOpenSession: (taskSessionId: string) => void
+ /** Opens a linked session's screen; returns why it can't, when it can't. */
+ onOpenSession: (taskSessionId: string) => string | null
onNavigate: (href: string) => void
}
-export function LocalTaskDetailView(props: Props) {
+/** A task's page, or why there is none: it was deleted while the page was open. */
+export function LocalTaskDetailView(props: LocalTaskDetailProps) {
const copy = localTasksCopy()
const insets = useSafeAreaInsets()
- const [ids] = useState(() => ({
- update: createLocalTaskOperationIds(),
- remove: createLocalTaskOperationIds()
- }))
- const [picker, setPicker] = useState<'status' | 'priority' | null>(null)
- const [menuOpen, setMenuOpen] = useState(false)
- const [confirmDelete, setConfirmDelete] = useState(false)
- const [error, setError] = useState(null)
- const { library } = props
- const task = library.tasks.find((candidate) => candidate.id === props.taskId) ?? null
- const project = task
- ? (library.projects.find((entry) => entry.id === task.projectId) ?? null)
- : null
- const lookup = localTaskStatusLookup(library.projects)
- const status = task ? (lookup.get(task.statusId) ?? null) : null
- const index = buildLocalTaskIndex(library.projects, library.tasks)
- const ancestors = task ? localTaskPathFromRoot(task, index.tasksById) : []
- const subtasks = task ? (index.childrenByParentId.get(task.id) ?? []) : []
- const descendantCount = task
- ? localTaskDescendantIds(task.id, index.childrenByParentId).length
- : 0
-
- const update = async (patch: { statusId?: string; priority?: LocalTask['priority'] }) => {
- if (!task) {
- return
- }
- const outcome = await sendLocalTaskWrite(
- ids.update,
- { taskId: task.id, expectedRevision: task.revision, patch },
- props.api.updateTask
- )
- setError(localTaskWriteMessage(copy, outcome))
- if (outcome.kind === 'ok') {
- library.acceptTasks([outcome.value.task])
- }
- library.reload(['tasks'])
+ const task = props.library.tasks.find((candidate) => candidate.id === props.taskId) ?? null
+ if (task) {
+ return
}
-
- const remove = async () => {
- setConfirmDelete(false)
- if (!task) {
- return
- }
- const outcome = await sendLocalTaskWrite(ids.remove, { taskId: task.id }, props.api.removeTask)
- setError(localTaskWriteMessage(copy, outcome))
- library.reload(['tasks'])
- if (outcome.kind === 'ok') {
- props.onBack()
- }
- }
-
return (
@@ -104,198 +46,10 @@ export function LocalTaskDetailView(props: Props) {
{copy.title}
-
- {task ? (
- <>
-
- {task.ref}
- >
- ) : null}
-
- setMenuOpen(true)}
- accessibilityRole="button"
- accessibilityLabel={copy.moreActions}
- >
-
-
- {!task ? (
-
- {copy.taskMissing}
-
- ) : (
-
-
-
-
- {project?.name ?? ''} ·{' '}
- {copy.updatedAgo(formatLocalTaskAgo(copy, task.updatedAt, props.now))}
-
-
- {task.title}
-
- setPicker('status')}
- >
-
- {status?.name ?? copy.unknownStatus}
-
- setPicker('priority')}
- >
-
- {copy.priorities[task.priority]}
-
- {task.labels.map((label) => (
-
- {label}
-
- ))}
-
- {ancestors.length > 0 ? (
-
-
- {ancestors.map((ancestor, index) => (
- props.onOpenTask(ancestor.id)}
- accessibilityRole="button"
- accessibilityLabel={`${ancestor.title} ${ancestor.ref}`}
- >
- {index > 0 ? › : null}
-
- {ancestor.title}
-
-
- ))}
-
- ) : null}
- {error ? {error} : null}
-
-
- library.reload(['events'])}
- />
-
- )}
- void update({ statusId })}
- onClose={() => setPicker(null)}
- />
- void update({ priority })}
- onClose={() => setPicker(null)}
- />
- task && props.onEdit(task) },
- {
- label: copy.delete,
- icon: Trash2,
- destructive: true,
- onPress: () => setConfirmDelete(true)
- }
- ]}
- onClose={() => setMenuOpen(false)}
- />
- void remove()}
- onCancel={() => setConfirmDelete(false)}
- />
+
+ {copy.taskMissing}
+
)
}
-
-const styles = StyleSheet.create({
- titleCenter: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: 6
- },
- ref: {
- color: colors.textPrimary,
- fontWeight: '700',
- fontFamily: typography.monoFamily,
- fontSize: 13
- },
- content: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
- metaRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
- title: { color: colors.textPrimary, fontSize: 20, fontWeight: '700', marginTop: spacing.sm },
- chips: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- alignItems: 'center',
- gap: spacing.sm,
- marginTop: spacing.md
- },
- parentRow: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- alignItems: 'center',
- gap: 6,
- marginTop: spacing.md
- },
- ancestor: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 },
- parentTitle: { flexShrink: 1, color: colors.textSecondary, fontSize: 12 },
- error: { marginTop: spacing.sm }
-})
diff --git a/mobile/src/local-tasks/local-task-dispatch-composer.tsx b/mobile/src/local-tasks/local-task-dispatch-composer.tsx
new file mode 100644
index 000000000000..c1c2d55e4c18
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-dispatch-composer.tsx
@@ -0,0 +1,145 @@
+import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
+import { AtSign, Laptop, Send, X } from 'lucide-react-native'
+import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { localTasksCopy } from './local-tasks-copy'
+import { localTaskStyles } from './local-tasks-styles'
+
+export type LocalTaskDispatchChip = { key: string; label: string; clear: () => void }
+
+/**
+ * The dispatch box: what @ picked as chips, the sentence, @ and send; under it which host runs the
+ * work, and what sending will do.
+ */
+export function LocalTaskDispatchComposer(props: {
+ text: string
+ onChangeText: (text: string) => void
+ chips: readonly LocalTaskDispatchChip[]
+ sendDisabled: boolean
+ onSend: () => void
+ /** The library's host as the user knows it. */
+ hostLabel: string
+ onSendLine: string | null
+ hint: string | null
+ error: string | null
+}) {
+ const copy = localTasksCopy()
+ const dispatch = copy.work.dispatch
+ return (
+
+
+ {props.chips.length > 0 ? (
+
+ {props.chips.map((chip) => (
+
+
+ {chip.label}
+
+
+
+ ))}
+
+ ) : null}
+
+
+
+ props.onChangeText(/(^|\s)$/.test(props.text) ? `${props.text}@` : `${props.text} @`)
+ }
+ accessibilityRole="button"
+ accessibilityLabel={dispatch.mention}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {dispatch.runBy(props.hostLabel)}
+
+
+ {props.onSendLine ? {props.onSendLine} : null}
+ {props.hint ? {props.hint} : null}
+ {props.error ? {props.error} : null}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, gap: spacing.xs },
+ composer: {
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ borderRadius: radii.surface,
+ paddingHorizontal: spacing.md,
+ paddingTop: spacing.sm,
+ paddingBottom: spacing.sm,
+ gap: spacing.xs
+ },
+ chips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
+ chip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ maxWidth: '100%',
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 3,
+ borderRadius: radii.pill,
+ backgroundColor: colors.skillTokenSurface
+ },
+ chipText: { flexShrink: 1, color: colors.skillToken, fontSize: typography.metaSize },
+ input: {
+ minHeight: 44,
+ maxHeight: 120,
+ paddingVertical: spacing.xs,
+ color: colors.textPrimary,
+ fontSize: TEXT_INPUT_FONT_SIZE
+ },
+ toolbar: { flexDirection: 'row', alignItems: 'center' },
+ tool: {
+ width: 32,
+ height: 32,
+ borderRadius: radii.pill,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ spacer: { flex: 1 },
+ send: {
+ width: 32,
+ height: 32,
+ borderRadius: radii.pill,
+ backgroundColor: colors.textPrimary,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ execLine: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 2 },
+ shrink: { flexShrink: 1 }
+})
diff --git a/mobile/src/local-tasks/local-task-dispatch-input.test.tsx b/mobile/src/local-tasks/local-task-dispatch-input.test.tsx
new file mode 100644
index 000000000000..bc095e2ce5fa
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-dispatch-input.test.tsx
@@ -0,0 +1,330 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { LocalTask, LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
+import type {
+ LocalTaskSessionLink,
+ LocalTaskWorkspaceLink
+} from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTaskSite } from './local-task-endpoint'
+import { SELF_LOCAL_TASK_ENDPOINT } from './local-task-endpoint'
+import { createLocalTasksApi, type LocalTasksApi } from './local-tasks-api'
+import type { LocalTasksLibrary } from './use-local-tasks-library'
+import type { RpcClient } from '../transport/rpc-client'
+
+vi.mock('react-native', () => ({
+ Pressable: 'Pressable',
+ TextInput: 'TextInput',
+ Text: 'Text',
+ View: 'View',
+ StyleSheet: { create: (styles: T) => styles }
+}))
+vi.mock('lucide-react-native', () => ({ AtSign: 'AtSign', Laptop: 'Laptop', Send: 'Send', X: 'X' }))
+vi.mock('./local-task-choice-sheet', () => ({ LocalTaskChoiceSheet: 'LocalTaskChoiceSheet' }))
+vi.mock('./local-task-launch-sheet', () => ({
+ LocalTaskLaunchSheet: () => null,
+ localTaskHostLabel: (hostId: string) => hostId,
+ localTaskSetupLabel: () => 'Local repo'
+}))
+const choices: { setups: Record[]; workspaces: Record[] } =
+ vi.hoisted(() => ({ setups: [], workspaces: [] }))
+vi.mock('./use-local-task-project-catalog', () => ({
+ useLocalTaskProjectCatalog: () => ({
+ state: 'ready',
+ catalog: { repos: [], setups: choices.setups }
+ })
+}))
+vi.mock('./use-local-task-workspace-candidates', () => ({
+ useLocalTaskWorkspaceCandidates: () => ({ status: 'ready', candidates: choices.workspaces })
+}))
+vi.mock('./use-local-task-host-labels', () => ({ useLocalTaskHostLabels: () => new Map() }))
+const sent = vi.hoisted(() => ({ reply: vi.fn(), open: vi.fn() }))
+vi.mock('./local-task-reply-send', () => ({ sendLocalTaskReply: sent.reply }))
+vi.mock('./use-local-task-launch', () => ({
+ useLocalTaskLaunch: () => ({
+ request: null,
+ agents: { status: 'ready', agents: [] },
+ busy: false,
+ notice: null,
+ open: sent.open,
+ close: vi.fn(),
+ chooseSetup: vi.fn(),
+ launch: vi.fn()
+ })
+}))
+
+import { LocalTaskDispatchInput } from './local-task-dispatch-input'
+
+const project = {
+ id: 'p',
+ name: 'Shop',
+ prefix: 'SHOP',
+ aliases: [],
+ repos: [],
+ defaultRepoId: null,
+ lastExecutionHostId: null,
+ rules: { startMovesToInProgress: false, prMergedCompletes: false },
+ statuses: [],
+ createdAt: 0,
+ updatedAt: 0,
+ revision: 1
+} satisfies LocalTaskProject
+const parent = {
+ id: 'parent',
+ projectId: 'p',
+ number: 1,
+ ref: 'SHOP-1',
+ title: 'Parent',
+ description: '',
+ criteria: [],
+ statusId: 'started',
+ priority: 0,
+ labels: [],
+ parentId: null,
+ createdAt: 0,
+ updatedAt: 0,
+ revision: 1
+} satisfies LocalTask
+const child = { ...parent, id: 'child', number: 2, ref: 'SHOP-2', parentId: parent.id }
+const workspace = {
+ taskId: parent.id,
+ hostId: 'local',
+ kind: 'git-worktree',
+ instanceId: null,
+ worktreeId: 'wt-1',
+ folderWorkspaceId: null,
+ repoId: 'repo-1',
+ branchSnapshot: null,
+ nameSnapshot: 'Workspace',
+ hostLabelSnapshot: null,
+ linkedVia: 'start',
+ linkedAt: 1
+} satisfies LocalTaskWorkspaceLink
+const session = {
+ id: 'session-1',
+ taskId: parent.id,
+ hostId: 'local',
+ worktreeId: 'wt-1',
+ instanceId: null,
+ surface: 'chat',
+ tabId: 'tab-1',
+ paneKey: null,
+ sessionId: 'agent-1',
+ providerSession: null,
+ agent: 'claude',
+ linkedVia: 'start',
+ openedAt: 2
+} satisfies LocalTaskSessionLink
+const client: RpcClient = {
+ sendRequest: async () => ({ id: 'test', ok: true, result: {} }),
+ subscribe: () => () => {},
+ updateTerminalSubscriptionViewport: () => {},
+ getState: () => 'connected',
+ getReconnectAttempt: () => 0,
+ getLastConnectedAt: () => null,
+ onStateChange: () => () => {},
+ notifyForeground: () => {},
+ close: () => {}
+}
+const site: LocalTaskSite = {
+ hostId: 'host-1',
+ pairedClient: null,
+ client,
+ endpoint: SELF_LOCAL_TASK_ENDPOINT,
+ connState: 'connected',
+ capabilities: [],
+ label: 'Desktop'
+}
+
+function mount(
+ state: 'done' | 'working',
+ createTask: LocalTasksApi['createTask'] = vi.fn(),
+ addComment: LocalTasksApi['addComment'] = vi.fn()
+) {
+ const library: LocalTasksLibrary = {
+ api: null,
+ tasks: [parent],
+ projects: [project],
+ status: null,
+ loaded: true,
+ error: null,
+ eventsVersion: 0,
+ live: true,
+ streamEnded: false,
+ work: {
+ links: { workspaces: [workspace], sessions: [session] },
+ progress: {
+ observedAt: 1,
+ sessions: [
+ { taskSessionId: session.id, state, lastMessage: null, stateSince: 1, unread: false }
+ ],
+ items: []
+ },
+ error: null
+ },
+ acceptTasks: vi.fn(),
+ reload: vi.fn(),
+ retryFailed: vi.fn(),
+ resubscribe: vi.fn()
+ }
+ const api: LocalTasksApi = { ...createLocalTasksApi(client), createTask, addComment }
+ let tree!: ReactTestRenderer
+ act(() => {
+ tree = create(
+ createElement(LocalTaskDispatchInput, { site, api, library, project, onNavigate: vi.fn() })
+ )
+ })
+ return { tree, library }
+}
+
+async function chooseParentAndSend(tree: ReactTestRenderer) {
+ await act(async () => {
+ tree.root.find((node) => String(node.type) === 'TextInput').props.onChangeText('@SHOP-1')
+ })
+ const sheet = tree.root.find((node) => String(node.type) === 'LocalTaskChoiceSheet')
+ await act(async () => {
+ sheet.props.choices.find((choice: { key: string }) => choice.key === 'task:parent').onPress()
+ })
+ await act(async () => {
+ tree.root.find((node) => String(node.type) === 'TextInput').props.onChangeText('Fix the export')
+ })
+ await act(async () => {
+ tree.root
+ .findAll((node) => String(node.type) === 'Pressable')
+ .find((button) => button.props.accessibilityLabel === 'Start task')
+ ?.props.onPress()
+ })
+}
+
+beforeEach(() => {
+ sent.reply.mockReset()
+ sent.open.mockReset()
+ sent.open.mockResolvedValue(undefined)
+ choices.setups = []
+ choices.workspaces = []
+})
+
+describe('direct dispatch to an existing task', () => {
+ it('filters workspace choices by selected host and repository', async () => {
+ choices.setups = [
+ {
+ id: 'a',
+ projectId: 'repo-a',
+ hostId: 'local',
+ repoId: 'id:a',
+ displayName: 'shop-a',
+ setupMethod: 'cloned'
+ },
+ {
+ id: 'b',
+ projectId: 'repo-b',
+ hostId: 'ssh:box',
+ repoId: 'id:b',
+ displayName: 'shop-b',
+ setupMethod: 'cloned'
+ }
+ ]
+ choices.workspaces = [
+ {
+ worktreeId: 'wt-a',
+ repoId: 'id:a',
+ hostId: 'local',
+ displayName: 'work-a',
+ branch: '',
+ instanceId: null,
+ folderWorkspaceId: null
+ },
+ {
+ worktreeId: 'wt-b',
+ repoId: 'id:b',
+ hostId: 'ssh:box',
+ displayName: 'work-b',
+ branch: '',
+ instanceId: null,
+ folderWorkspaceId: null
+ }
+ ]
+ const { tree } = mount('done')
+ await act(async () =>
+ tree.root.find((node) => String(node.type) === 'TextInput').props.onChangeText('@')
+ )
+ let sheet = tree.root.find((node) => String(node.type) === 'LocalTaskChoiceSheet')
+ expect(sheet.props.choices.map((choice: { key: string }) => choice.key)).toContain(
+ 'workspace:ssh:box:wt-b'
+ )
+ await act(async () => sheet.props.search.onChange('work-b'))
+ sheet = tree.root.find((node) => String(node.type) === 'LocalTaskChoiceSheet')
+ expect(sheet.props.choices.map((choice: { key: string }) => choice.key)).toContain(
+ 'workspace:ssh:box:wt-b'
+ )
+ expect(sheet.props.choices.map((choice: { key: string }) => choice.key)).not.toContain(
+ 'workspace:local:wt-a'
+ )
+ await act(async () => sheet.props.search.onChange(''))
+ sheet = tree.root.find((node) => String(node.type) === 'LocalTaskChoiceSheet')
+ await act(async () =>
+ sheet.props.choices.find((choice: { key: string }) => choice.key === 'host:local').onPress()
+ )
+ sheet = tree.root.find((node) => String(node.type) === 'LocalTaskChoiceSheet')
+ expect(sheet.props.choices.map((choice: { key: string }) => choice.key)).not.toContain(
+ 'workspace:ssh:box:wt-b'
+ )
+ await act(async () =>
+ sheet.props.choices.find((choice: { key: string }) => choice.key === 'repo:id:a').onPress()
+ )
+ sheet = tree.root.find((node) => String(node.type) === 'LocalTaskChoiceSheet')
+ expect(sheet.props.choices.map((choice: { key: string }) => choice.key)).toContain(
+ 'workspace:local:wt-a'
+ )
+ })
+ it('sends into a completed turn without creating another task', async () => {
+ sent.reply.mockResolvedValue({ kind: 'delivered' })
+ const createTask = vi.fn()
+ const addComment = vi.fn().mockResolvedValue({ kind: 'ok', value: { ok: true } })
+ const { tree, library } = mount('done', createTask, addComment)
+ await chooseParentAndSend(tree)
+ expect(sent.reply).toHaveBeenCalledWith(
+ expect.objectContaining({ session, text: 'Fix the export' })
+ )
+ expect(createTask).not.toHaveBeenCalled()
+ expect(addComment).toHaveBeenCalledWith(
+ expect.objectContaining({ taskId: parent.id, body: 'Fix the export' })
+ )
+ expect(library.reload).toHaveBeenCalledWith(['events'])
+ })
+
+ it.each(['not-sent', 'unconfirmed'] as const)(
+ 'does not write an activity record when the reply is %s',
+ async (kind) => {
+ sent.reply.mockResolvedValue({ kind, reason: 'Not connected' })
+ const addComment = vi.fn()
+ const { tree } = mount('done', vi.fn(), addComment)
+ await chooseParentAndSend(tree)
+ expect(addComment).not.toHaveBeenCalled()
+ }
+ )
+
+ it('does not resend a delivered instruction when its activity record fails', async () => {
+ sent.reply.mockResolvedValue({ kind: 'delivered' })
+ const addComment = vi.fn().mockResolvedValue({ kind: 'error', message: 'Write failed' })
+ const { tree } = mount('done', vi.fn(), addComment)
+ await chooseParentAndSend(tree)
+ expect(sent.reply).toHaveBeenCalledTimes(1)
+ expect(addComment).toHaveBeenCalledTimes(1)
+ expect(tree.root.find((node) => String(node.type) === 'TextInput').props.value).toBe('')
+ })
+
+ it('creates a child and opens a new session while the existing one works', async () => {
+ const createTask = vi.fn().mockResolvedValue({ kind: 'ok', value: { ok: true, task: child } })
+ const { tree } = mount('working', createTask)
+ await chooseParentAndSend(tree)
+ expect(createTask).toHaveBeenCalledWith(
+ expect.objectContaining({ parentId: parent.id, description: 'Fix the export' })
+ )
+ expect(sent.open).toHaveBeenCalledWith(
+ expect.objectContaining({ mode: 'session', sessionTask: child, workspace }),
+ expect.any(String)
+ )
+ expect(sent.reply).not.toHaveBeenCalled()
+ })
+})
diff --git a/mobile/src/local-tasks/local-task-dispatch-input.tsx b/mobile/src/local-tasks/local-task-dispatch-input.tsx
new file mode 100644
index 000000000000..f73610e55c8a
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-dispatch-input.tsx
@@ -0,0 +1,380 @@
+import { useState } from 'react'
+import {
+ LOCAL_TASK_TITLE_MAX,
+ suggestLocalTaskWorkspaceName
+} from '../../../src/shared/local-tasks/local-task-model'
+import type { LocalTask, LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
+import type { LocalTasksApi } from './local-tasks-api'
+import { localTasksCopy } from './local-tasks-copy'
+import { localTaskListedHostId, type LocalTaskSite } from './local-task-endpoint'
+import { localTaskWriteMessage } from './local-task-failure-message'
+import { LocalTaskChoiceSheet } from './local-task-choice-sheet'
+import { LocalTaskDispatchComposer } from './local-task-dispatch-composer'
+import { localTaskMentionChoices } from './local-task-dispatch-mentions'
+import {
+ LocalTaskLaunchSheet,
+ localTaskHostLabel,
+ localTaskSetupLabel
+} from './local-task-launch-sheet'
+import { createLocalTaskOperationIds, sendLocalTaskWrite } from './local-task-operation-ids'
+import { localTaskSetupsFor, pickLocalTaskStartSetup } from './local-task-project-setups'
+import { localTaskSessionViews, localTaskWorkspaceLinkFor } from './local-task-progress-projection'
+import { sendLocalTaskReply } from './local-task-reply-send'
+import {
+ localTaskLinkHolding,
+ localTaskLinkParamsFor,
+ type LocalTaskWorkspaceCandidate
+} from './local-task-workspace-candidates'
+import { useLocalTaskHostLabels } from './use-local-task-host-labels'
+import { useLocalTaskLaunch } from './use-local-task-launch'
+import { useLocalTaskProjectCatalog } from './use-local-task-project-catalog'
+import { useLocalTaskWorkspaceCandidates } from './use-local-task-workspace-candidates'
+import type { LocalTasksLibrary } from './use-local-tasks-library'
+
+export function LocalTaskDispatchInput(props: {
+ site: LocalTaskSite
+ api: LocalTasksApi
+ library: LocalTasksLibrary
+ project: LocalTaskProject
+ /** The paired host's word on its SSH hosts, keyed as the phone lists them. */
+ hostConnected?: ReadonlyMap
+ /** The page shows all projects: `project` is only the fallback for a bare sentence. */
+ allProjects?: boolean
+ onNavigate: (href: string) => void
+}) {
+ const copy = localTasksCopy()
+ const [text, setText] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+ const [ids] = useState(() => createLocalTaskOperationIds())
+ const [linkIds] = useState(() => createLocalTaskOperationIds())
+ const [commentIds] = useState(() => createLocalTaskOperationIds())
+ const [hostFilter, setHostFilter] = useState(null)
+ const [repoFilter, setRepoFilter] = useState(null)
+ const [target, setTarget] = useState<
+ | { kind: 'task'; task: LocalTask }
+ | { kind: 'workspace'; workspace: LocalTaskWorkspaceCandidate }
+ | null
+ >(null)
+ const mention = text.match(/(?:^|\s)@([^\s]*)$/)?.[1] ?? null
+ const catalog = useLocalTaskProjectCatalog(props.site.pairedClient)
+ // With all projects in view, every project's repositories are offered (a task-only project
+ // offers every repository on the host).
+ const scopeRepos = props.allProjects
+ ? props.library.projects.some((entry) => entry.repos.length === 0)
+ ? []
+ : [...new Set(props.library.projects.flatMap((entry) => entry.repos))]
+ : props.project.repos
+ const setups =
+ catalog.state === 'ready'
+ ? localTaskSetupsFor(catalog.catalog, props.site.endpoint, scopeRepos)
+ : []
+ // The project a task dispatched into a repository belongs to.
+ const projectForRepo = (repoId: string) => {
+ const owner = setups.find((entry) => entry.repoId === repoId)
+ return (
+ (owner
+ ? props.library.projects.find((entry) => entry.repos.includes(owner.orcaProjectId))
+ : undefined) ??
+ props.library.projects.find((entry) => entry.repos.length === 0) ??
+ props.project
+ )
+ }
+ const filteredSetups = setups.filter(
+ (entry) =>
+ (hostFilter === null || entry.ownerHostId === hostFilter) &&
+ (repoFilter === null || entry.repoId === repoFilter)
+ )
+ const setup = pickLocalTaskStartSetup(filteredSetups, props.project)
+ const labels = useLocalTaskHostLabels(
+ props.site.pairedClient,
+ setups.map((entry) => entry.hostId)
+ )
+ const workspaceState = useLocalTaskWorkspaceCandidates({
+ client: props.site.connState === 'connected' ? props.site.client : null,
+ key: props.site.endpoint.executionHost ?? props.site.hostId,
+ open: mention !== null
+ })
+ const launch = useLocalTaskLaunch({
+ site: props.site,
+ api: props.api,
+ library: props.library,
+ copy,
+ onNavigate: props.onNavigate
+ })
+ // Every repository on the host, only to name a workspace's repository.
+ const repoLabels = new Map(
+ (catalog.state === 'ready'
+ ? localTaskSetupsFor(catalog.catalog, props.site.endpoint, [])
+ : setups
+ ).map((entry) => [entry.repoId, entry.label])
+ )
+ const listedHost = (ownerHostId: string) =>
+ localTaskListedHostId(props.site.endpoint, ownerHostId) ?? ownerHostId
+ const hostName = (ownerHostId: string) =>
+ ownerHostId === 'local'
+ ? props.site.label
+ : (labels.get(listedHost(ownerHostId)) ?? localTaskHostLabel(listedHost(ownerHostId), null))
+ const closeMention = () => setText((previous) => previous.replace(/(?:^|\s)@[^\s]*$/, ''))
+ const keepMentioning = () => setText((previous) => previous.replace(/@[^\s]*$/, '@'))
+ const mentionChoices = localTaskMentionChoices({
+ query: mention ?? '',
+ setups,
+ candidates: workspaceState.status === 'ready' ? workspaceState.candidates : [],
+ tasks: props.library.tasks,
+ links: props.library.work.links,
+ hostFilter,
+ repoFilter,
+ repoLabels,
+ hosts: {
+ name: hostName,
+ connected: (ownerHostId) => props.hostConnected?.get(listedHost(ownerHostId)) ?? null
+ },
+ onHost: (ownerHostId) => {
+ setHostFilter(ownerHostId)
+ setTarget(null)
+ keepMentioning()
+ },
+ onRepo: (repoId) => {
+ setRepoFilter(repoId)
+ setTarget(null)
+ keepMentioning()
+ },
+ onWorkspace: (workspace) => {
+ setTarget({ kind: 'workspace', workspace })
+ closeMention()
+ },
+ onTask: (task) => {
+ setTarget({ kind: 'task', task })
+ closeMention()
+ }
+ })
+ const dispatch = async () => {
+ const message = text.trim()
+ if (!message || busy || props.site.connState !== 'connected') {
+ return
+ }
+ setBusy(true)
+ try {
+ const workspaceTarget = target?.kind === 'workspace' ? target.workspace : null
+ const holder = workspaceTarget
+ ? localTaskLinkHolding(props.library.work.links, workspaceTarget)
+ : null
+ const targetTask =
+ target?.kind === 'task'
+ ? (props.library.tasks.find((entry) => entry.id === target.task.id) ?? null)
+ : holder
+ ? (props.library.tasks.find((entry) => entry.id === holder.taskId) ?? null)
+ : null
+ const tasksById = new Map(props.library.tasks.map((entry) => [entry.id, entry]))
+ const linkedWorkspace = targetTask
+ ? localTaskWorkspaceLinkFor(targetTask, tasksById, props.library.work.links)
+ : null
+ const sessions = targetTask
+ ? localTaskSessionViews({
+ task: targetTask,
+ tasks: props.library.tasks,
+ links: props.library.work.links,
+ snapshot: props.library.work.progress,
+ inContact: true
+ })
+ : []
+ const ownSessions = sessions.filter((session) => session.link.taskId === targetTask?.id)
+ const resumable =
+ ownSessions.find(
+ (session) =>
+ session.link.id === targetTask?.latestProgress?.sessionLinkId &&
+ (session.state === 'done' || session.state === 'idle')
+ ) ?? ownSessions.find((session) => session.state === 'done' || session.state === 'idle')
+ if (targetTask && resumable) {
+ const work = copy.work
+ const sent = await sendLocalTaskReply({
+ client: props.site.client,
+ session: resumable.link,
+ text: message,
+ reasons: {
+ missing: work.replySessionMissing,
+ notConnected: work.replyNotConnected,
+ inputNotCleared: work.replyInputNotCleared,
+ inputRefused: work.replyInputRefused,
+ chatNotReady: work.replyChatNotReady
+ }
+ })
+ if (sent.kind === 'not-sent') {
+ setError(work.replyNotSentBecause(sent.reason ?? ''))
+ return
+ }
+ setText('')
+ if (sent.kind === 'delivered') {
+ const recorded = await sendLocalTaskWrite(
+ commentIds,
+ { taskId: targetTask.id, body: message },
+ props.api.addComment
+ )
+ setError(localTaskWriteMessage(copy, recorded))
+ if (recorded.kind === 'ok') {
+ props.library.reload(['events'])
+ }
+ } else {
+ setError(null)
+ }
+ return
+ }
+ const parentId = targetTask ? targetTask.id : null
+ const scopedRepo = repoFilter ?? workspaceTarget?.repoId ?? null
+ const project = targetTask
+ ? (props.library.projects.find((entry) => entry.id === targetTask.projectId) ??
+ props.project)
+ : props.allProjects && scopedRepo
+ ? projectForRepo(scopedRepo)
+ : props.project
+ const projectSetups =
+ catalog.state === 'ready'
+ ? localTaskSetupsFor(catalog.catalog, props.site.endpoint, project.repos).filter(
+ (entry) =>
+ (hostFilter === null || entry.ownerHostId === hostFilter) &&
+ (repoFilter === null || entry.repoId === repoFilter)
+ )
+ : []
+ const selectedSetup = pickLocalTaskStartSetup(projectSetups, project)
+ if (!linkedWorkspace && !workspaceTarget && !selectedSetup) {
+ setError(copy.dispatchNoSetup)
+ return
+ }
+ const created = await sendLocalTaskWrite(
+ ids,
+ {
+ projectId: project.id,
+ title: message.split(/\r?\n/)[0].slice(0, LOCAL_TASK_TITLE_MAX),
+ description: message,
+ criteria: [],
+ priority: 0 as const,
+ labels: [],
+ parentId
+ },
+ props.api.createTask
+ )
+ setError(localTaskWriteMessage(copy, created))
+ if (created.kind !== 'ok') {
+ return
+ }
+ const task = created.value.task
+ props.library.acceptTasks([task])
+ setText('')
+ setTarget(null)
+ if (linkedWorkspace) {
+ await launch.open(
+ { mode: 'session', sessionTask: task, workspace: linkedWorkspace },
+ linkedWorkspace.hostLabelSnapshot ?? ''
+ )
+ return
+ }
+ if (workspaceTarget) {
+ const linked = await sendLocalTaskWrite(
+ linkIds,
+ {
+ taskId: task.id,
+ ...localTaskLinkParamsFor(workspaceTarget, labels.get(workspaceTarget.hostId) ?? ''),
+ linkedVia: 'manual' as const
+ },
+ props.api.linkWorkspace
+ )
+ setError(localTaskWriteMessage(copy, linked, props.library.tasks))
+ if (linked.kind === 'ok') {
+ props.library.reload(['links'])
+ await launch.open(
+ { mode: 'session', sessionTask: task, workspace: linked.value.workspace },
+ labels.get(workspaceTarget.hostId) ?? ''
+ )
+ }
+ return
+ }
+ if (!selectedSetup) {
+ setError(copy.dispatchNoSetup)
+ return
+ }
+ await launch.open(
+ {
+ mode: 'start',
+ workspaceTask: task,
+ sessionTask: task,
+ name: suggestLocalTaskWorkspaceName(task.ref, task.title),
+ setups: projectSetups,
+ setup: selectedSetup
+ },
+ localTaskSetupLabel(selectedSetup, labels)
+ )
+ } finally {
+ setBusy(false)
+ }
+ }
+ const canDispatch = Boolean(setup || target)
+ const dispatchCopy = copy.work.dispatch
+ const sendDisabled = !canDispatch || busy || !text.trim() || props.site.connState !== 'connected'
+ const chips = [
+ ...(target
+ ? [
+ {
+ key: 'target',
+ label:
+ target.kind === 'task'
+ ? `${target.task.ref} ${target.task.title}`
+ : [repoLabels.get(target.workspace.repoId), target.workspace.displayName]
+ .filter(Boolean)
+ .join('/'),
+ clear: () => setTarget(null)
+ }
+ ]
+ : []),
+ ...(hostFilter
+ ? [{ key: 'host', label: hostName(hostFilter), clear: () => setHostFilter(null) }]
+ : []),
+ ...(repoFilter
+ ? [
+ {
+ key: 'repo',
+ label: repoLabels.get(repoFilter) ?? copy.dispatchRepo,
+ clear: () => setRepoFilter(null)
+ }
+ ]
+ : [])
+ ]
+ const onSend = target
+ ? target.kind === 'task'
+ ? dispatchCopy.onSendTask(target.task.ref)
+ : dispatchCopy.onSendWorkspace(
+ target.workspace.displayName,
+ hostName(target.workspace.hostId)
+ )
+ : null
+ return (
+ <>
+ void dispatch()}
+ hostLabel={props.site.label}
+ onSendLine={onSend}
+ hint={!target && !setup && catalog.state === 'ready' ? copy.dispatchNoSetup : null}
+ error={error ?? launch.notice}
+ />
+
+
+ setText((previous) => previous.replace(/@[^\s]*$/, `@${query.replace(/\s/g, '')}`))
+ }}
+ onClose={closeMention}
+ />
+ >
+ )
+}
diff --git a/mobile/src/local-tasks/local-task-dispatch-mentions.ts b/mobile/src/local-tasks/local-task-dispatch-mentions.ts
new file mode 100644
index 000000000000..c18a6f2fac90
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-dispatch-mentions.ts
@@ -0,0 +1,131 @@
+import type { LocalTaskLinks } from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import type { LocalTaskChoice } from './local-task-choice-sheet'
+import { localTasksCopy } from './local-tasks-copy'
+import { localTaskWorkspaceLinkFor } from './local-task-progress-projection'
+import type { LocalTaskSetup } from './local-task-project-setups'
+import {
+ localTaskLinkHolding,
+ type LocalTaskWorkspaceCandidate
+} from './local-task-workspace-candidates'
+
+/** How the picker names hosts; every id here is owner-relative (`local`, `ssh:{t}`). */
+export type LocalTaskMentionHosts = {
+ name: (ownerHostId: string) => string
+ /** The paired host's word: true connected, false not, null when it gave none. */
+ connected: (ownerHostId: string) => boolean | null
+}
+
+/**
+ * The @ picker's rows, grouped Workspaces, Repositories, Tasks, Hosts. Each says which repository
+ * and host it is on, which task already holds it, and greys out what sits on a host the paired
+ * host says is not connected. Host and repository rows narrow the others, as before.
+ */
+export function localTaskMentionChoices(args: {
+ query: string
+ setups: readonly LocalTaskSetup[]
+ candidates: readonly LocalTaskWorkspaceCandidate[]
+ tasks: readonly LocalTask[]
+ links: LocalTaskLinks
+ hostFilter: string | null
+ repoFilter: string | null
+ repoLabels: ReadonlyMap
+ hosts: LocalTaskMentionHosts
+ onHost: (ownerHostId: string) => void
+ onRepo: (repoId: string) => void
+ onWorkspace: (workspace: LocalTaskWorkspaceCandidate) => void
+ onTask: (task: LocalTask) => void
+}): LocalTaskChoice[] {
+ const copy = localTasksCopy().work.mention
+ const { hostFilter, repoFilter, hosts } = args
+ const matches = (value: string) => value.toLowerCase().includes(args.query.toLowerCase())
+ const offline = (hostId: string) => hosts.connected(hostId) === false
+ const hostLine = (hostId: string) =>
+ offline(hostId) ? `${hosts.name(hostId)} ${copy.hostNotConnected}` : hosts.name(hostId)
+ const tasksById = new Map(args.tasks.map((task) => [task.id, task]))
+ const workspaces = args.candidates
+ .filter(
+ (entry) =>
+ (hostFilter === null || entry.hostId === hostFilter) &&
+ (repoFilter === null || entry.repoId === repoFilter) &&
+ matches(`${entry.displayName} ${entry.branch}`)
+ )
+ .map((entry): LocalTaskChoice => {
+ const holder = localTaskLinkHolding(args.links, entry)
+ const holderTask = holder ? tasksById.get(holder.taskId) : undefined
+ return {
+ key: `workspace:${entry.hostId}:${entry.worktreeId}`,
+ section: copy.workspaces,
+ label: entry.displayName,
+ subtitle: [args.repoLabels.get(entry.repoId), hostLine(entry.hostId)]
+ .filter(Boolean)
+ .join(' · '),
+ tag: holderTask ? copy.belongsTo(holderTask.ref) : undefined,
+ disabled: offline(entry.hostId),
+ onPress: () => args.onWorkspace(entry)
+ }
+ })
+ const repos = Array.from(new Map(args.setups.map((entry) => [entry.repoId, entry])).values())
+ .filter(
+ (entry) => (hostFilter === null || entry.ownerHostId === hostFilter) && matches(entry.label)
+ )
+ .map((entry): LocalTaskChoice => ({
+ key: `repo:${entry.repoId}`,
+ section: copy.repositories,
+ label: entry.label,
+ subtitle: [hostLine(entry.ownerHostId), copy.newWorkspaceHere].join(' · '),
+ disabled: offline(entry.ownerHostId),
+ onPress: () => args.onRepo(entry.repoId)
+ }))
+ const tasks = args.tasks.flatMap((task): LocalTaskChoice[] => {
+ if (!matches(`${task.ref} ${task.title}`)) {
+ return []
+ }
+ const workspace = localTaskWorkspaceLinkFor(task, tasksById, args.links)
+ if (
+ (hostFilter !== null && workspace?.hostId !== hostFilter) ||
+ (repoFilter !== null && workspace?.repoId !== repoFilter)
+ ) {
+ return []
+ }
+ return [
+ {
+ key: `task:${task.id}`,
+ section: copy.tasks,
+ label: task.title,
+ subtitle: [
+ task.ref,
+ ...(workspace ? [workspace.nameSnapshot, hostLine(workspace.hostId)] : [copy.noWorkspace])
+ ]
+ .filter(Boolean)
+ .join(' · '),
+ disabled: workspace ? offline(workspace.hostId) : false,
+ onPress: () => args.onTask(task)
+ }
+ ]
+ })
+ const hostRows = Array.from(
+ new Map(args.setups.map((entry) => [entry.ownerHostId, entry])).values()
+ )
+ .filter((entry) => matches(hosts.name(entry.ownerHostId) || entry.ownerHostId))
+ .map((entry): LocalTaskChoice => {
+ const connected = hosts.connected(entry.ownerHostId)
+ return {
+ key: `host:${entry.ownerHostId}`,
+ section: copy.hosts,
+ label: hosts.name(entry.ownerHostId) || entry.ownerHostId,
+ subtitle:
+ entry.ownerHostId === 'local'
+ ? copy.libraryHost
+ : [
+ copy.sshHost,
+ connected === null ? null : connected ? copy.hostConnected : copy.hostNotConnected
+ ]
+ .filter(Boolean)
+ .join(' · '),
+ disabled: connected === false,
+ onPress: () => args.onHost(entry.ownerHostId)
+ }
+ })
+ return [...workspaces, ...repos, ...tasks, ...hostRows]
+}
diff --git a/mobile/src/local-tasks/local-task-event-copy.test.ts b/mobile/src/local-tasks/local-task-event-copy.test.ts
new file mode 100644
index 000000000000..974e9d53cca2
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-event-copy.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest'
+import type { LocalTaskEvent } from '../../../src/shared/local-tasks/local-task-types'
+import { localTasksCopy } from './local-tasks-copy'
+import { describeLocalTaskEvent } from './local-task-event-copy'
+
+const copy = localTasksCopy()
+
+function line(event: Pick) {
+ return describeLocalTaskEvent(
+ copy,
+ { id: 'e', taskId: 't', body: null, createdAt: 0, editedAt: null, ...event },
+ new Map(),
+ new Map([['t12', { title: 'Export orders as CSV' }]])
+ )
+}
+
+describe('a task activity line on the phone', () => {
+ it('names the rule behind an automatic status change, with no actor in front', () => {
+ expect(
+ line({
+ kind: 'status',
+ source: 'rule',
+ payload: { fromName: 'Todo', toName: 'Done', rule: 'pr-merged', prNumber: 72 }
+ })
+ ).toEqual({ actor: null, text: copy.eventRulePrMerged('72', 'Todo', 'Done') })
+ })
+
+ it('says where Start work ran, and names a parent by its title', () => {
+ expect(
+ line({
+ kind: 'workspace-linked',
+ source: 'mobile',
+ payload: { name: 'shop-12', via: 'start', hostLabel: 'minizc' }
+ })
+ ).toEqual({ actor: 'You (phone)', text: copy.eventWorkspaceStarted('shop-12', 'minizc') })
+ expect(line({ kind: 'parent', source: 'desktop', payload: { to: 't12' } }).text).toBe(
+ copy.eventParentSet('Export orders as CSV')
+ )
+ })
+})
diff --git a/mobile/src/local-tasks/local-task-event-copy.ts b/mobile/src/local-tasks/local-task-event-copy.ts
index 5bb9f2072107..913946d8f7ee 100644
--- a/mobile/src/local-tasks/local-task-event-copy.ts
+++ b/mobile/src/local-tasks/local-task-event-copy.ts
@@ -4,78 +4,160 @@ import {
localTaskEventPriority
} from '../../../src/shared/local-tasks/local-task-event-payload'
import type {
+ LocalTask,
LocalTaskEvent,
LocalTaskStatusDef
} from '../../../src/shared/local-tasks/local-task-types'
import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label'
import type { LocalTasksCopy } from './local-tasks-copy'
+type Payload = Record | null
+
+/** One activity line: who did it (null when nobody did, e.g. a host move), then what happened. */
+export type LocalTaskEventLine = { actor: string | null; text: string }
+
/** "Claude", as the rest of the app names it, not the payload's `claude`. */
-function agentName(agent: string | null): string {
- return agent ? formatAgentTypeLabel(agent) : '?'
+function agentName(copy: LocalTasksCopy, payload: Payload): string {
+ const agent = text(payload, 'agent')
+ return agent ? formatAgentTypeLabel(agent) : copy.eventUnknownAgent
}
-function priorityName(
- copy: LocalTasksCopy,
- payload: Record | null,
- key: string
-): string {
+function workspaceName(copy: LocalTasksCopy, payload: Payload): string {
+ return text(payload, 'name') ?? text(payload, 'branch') ?? copy.eventUnnamedWorkspace
+}
+
+function priorityName(copy: LocalTasksCopy, payload: Payload, key: string): string {
const priority = localTaskEventPriority(payload, key)
return priority === null ? '?' : (copy.priorities[priority] ?? '?')
}
+/** A parent named by its title, the task's main text; a deleted one is said so. */
+function taskTitle(
+ copy: LocalTasksCopy,
+ id: string | null,
+ tasksById: ReadonlyMap>
+): string {
+ return (id ? tasksById.get(id)?.title : undefined) ?? copy.eventDeletedTask
+}
+
+function describeLabels(copy: LocalTasksCopy, payload: Payload): string {
+ const added = localTaskEventTexts(payload, 'added').join(', ')
+ const removed = localTaskEventTexts(payload, 'removed').join(', ')
+ if (added && removed) {
+ return copy.eventLabelsChanged(added, removed)
+ }
+ return added ? copy.eventLabelsAdded(added) : copy.eventLabelsRemoved(removed)
+}
+
+function describeStatus(
+ copy: LocalTasksCopy,
+ payload: Payload,
+ statuses: ReadonlyMap,
+ actor: string
+): LocalTaskEventLine {
+ const name = (nameKey: string, idKey: string) =>
+ text(payload, nameKey) ??
+ statuses.get(text(payload, idKey) ?? '')?.name ??
+ copy.eventRemovedStatus
+ const from = name('fromName', 'from')
+ const to = name('toName', 'to')
+ switch (text(payload, 'rule')) {
+ case 'start':
+ return { actor: null, text: copy.eventRuleStart(from, to) }
+ case 'pr-merged': {
+ const pr = payload?.prNumber
+ return {
+ actor: null,
+ text: copy.eventRulePrMerged(typeof pr === 'number' ? String(pr) : '?', from, to)
+ }
+ }
+ case null:
+ default:
+ return { actor, text: copy.eventStatus(from, to) }
+ }
+}
+
+type LinkEventKind = Extract<
+ LocalTaskEvent['kind'],
+ 'workspace-linked' | 'workspace-unlinked' | 'session-linked' | 'session-unlinked'
+>
+
+function describeLink(copy: LocalTasksCopy, kind: LinkEventKind, payload: Payload): string {
+ const started = text(payload, 'via') === 'start'
+ switch (kind) {
+ case 'workspace-linked':
+ return started
+ ? copy.eventWorkspaceStarted(workspaceName(copy, payload), text(payload, 'hostLabel') ?? '')
+ : copy.eventWorkspaceLinked(workspaceName(copy, payload))
+ case 'workspace-unlinked':
+ return copy.eventWorkspaceUnlinked(workspaceName(copy, payload))
+ case 'session-linked':
+ return started
+ ? copy.eventSessionOpened(agentName(copy, payload))
+ : copy.eventSessionLinked(agentName(copy, payload))
+ case 'session-unlinked':
+ return copy.eventSessionUnlinked(agentName(copy, payload))
+ }
+}
+
/**
- * One activity line. Payloads are read defensively: they are free-form JSON on the wire, and a
- * desktop that writes a field differently should cost a detail, never the whole timeline.
+ * One activity line, worded as the desktop words it (REQ-005): a rule's change names the rule, a
+ * start names where it ran. Payloads are read defensively: they are free-form JSON on the wire,
+ * and a desktop that writes a field differently should cost a detail, never the whole timeline.
*/
export function describeLocalTaskEvent(
copy: LocalTasksCopy,
event: LocalTaskEvent,
- statuses: Map
-): string {
+ statuses: ReadonlyMap,
+ tasksById: ReadonlyMap>
+): LocalTaskEventLine {
const payload = event.payload
+ const actor = copy.eventSources[event.source]
+ const said = (line: string): LocalTaskEventLine => ({ actor, text: line })
switch (event.kind) {
+ case 'turn-ended':
+ return { actor: null, text: '' }
case 'comment':
- return copy.eventComment
+ return said(copy.eventComment)
case 'created':
- return copy.eventCreated
+ return said(copy.eventCreated)
case 'title':
- return copy.eventTitle
+ return said(copy.eventTitle(text(payload, 'to') ?? ''))
case 'description':
- return copy.eventDescription
+ return said(copy.eventDescription)
case 'criteria':
- return copy.eventCriteria
- case 'status': {
- const name = (nameKey: string, idKey: string) =>
- text(payload, nameKey) ?? statuses.get(text(payload, idKey) ?? '')?.name ?? '?'
- return copy.eventStatus(name('fromName', 'from'), name('toName', 'to'))
- }
+ return said(copy.eventCriteria)
+ case 'status':
+ return describeStatus(copy, payload, statuses, actor)
case 'priority':
- return copy.eventPriority(
- priorityName(copy, payload, 'from'),
- priorityName(copy, payload, 'to')
+ return said(
+ copy.eventPriority(priorityName(copy, payload, 'from'), priorityName(copy, payload, 'to'))
+ )
+ case 'labels':
+ return said(describeLabels(copy, payload))
+ case 'parent': {
+ const to = text(payload, 'to')
+ return said(
+ to
+ ? copy.eventParentSet(taskTitle(copy, to, tasksById))
+ : copy.eventParentRemoved(taskTitle(copy, text(payload, 'from'), tasksById))
)
- case 'labels': {
- const added = localTaskEventTexts(payload, 'added').map((label) => `+${label}`)
- const removed = localTaskEventTexts(payload, 'removed').map((label) => `−${label}`)
- const changes = [...added, ...removed].join(' ')
- return changes ? `${copy.eventLabels} ${changes}` : copy.eventLabels
}
- case 'parent':
- return copy.eventParent
case 'moved':
- return copy.eventMoved(text(payload, 'from') ?? '?', text(payload, 'to') ?? '?')
+ return said(copy.eventMoved(text(payload, 'from') ?? '?', text(payload, 'to') ?? '?'))
+ case 'workspace-host':
+ return {
+ actor: null,
+ text: copy.eventWorkspaceHost(
+ workspaceName(copy, payload),
+ text(payload, 'toHostLabel') ?? text(payload, 'toHostId') ?? '?',
+ text(payload, 'fromHostLabel') ?? text(payload, 'fromHostId') ?? '?'
+ )
+ }
case 'workspace-linked':
- return copy.eventWorkspaceLinked(text(payload, 'name') ?? text(payload, 'branch') ?? '?')
case 'workspace-unlinked':
- return copy.eventWorkspaceUnlinked(text(payload, 'name') ?? text(payload, 'branch') ?? '?')
- case 'workspace-host':
- return copy.eventWorkspaceHost(
- text(payload, 'toHostLabel') ?? text(payload, 'toHostId') ?? '?'
- )
case 'session-linked':
- return copy.eventSessionLinked(agentName(text(payload, 'agent')))
case 'session-unlinked':
- return copy.eventSessionUnlinked(agentName(text(payload, 'agent')))
+ return said(describeLink(copy, event.kind, payload))
}
}
diff --git a/mobile/src/local-tasks/local-task-failure-message.test.ts b/mobile/src/local-tasks/local-task-failure-message.test.ts
index 0ec37deed9dc..ca44a7d69ed6 100644
--- a/mobile/src/local-tasks/local-task-failure-message.test.ts
+++ b/mobile/src/local-tasks/local-task-failure-message.test.ts
@@ -19,4 +19,20 @@ describe('mobile local task refusal copy', () => {
localTaskWriteMessage(copy, { kind: 'refused', code: 'future_code', detail: null })
).toBe(copy.failureGeneric('future_code'))
})
+
+ it('names the task holding a workspace by its title, and keeps the ref when none is loaded', () => {
+ const copy = localTasksCopy()
+ const refused = {
+ kind: 'refused' as const,
+ code: 'workspace_already_linked',
+ detail: 'SHOP-12'
+ }
+ const tasks = [{ ref: 'SHOP-12', title: 'Export orders as CSV' }]
+ expect(localTaskWriteMessage(copy, refused, tasks)).toBe(
+ copy.failures.workspace_already_linked('Export orders as CSV')
+ )
+ expect(localTaskWriteMessage(copy, refused, [])).toBe(
+ copy.failures.workspace_already_linked('SHOP-12')
+ )
+ })
})
diff --git a/mobile/src/local-tasks/local-task-failure-message.ts b/mobile/src/local-tasks/local-task-failure-message.ts
index 9b8ed2b774d8..eb8eda1fc4e5 100644
--- a/mobile/src/local-tasks/local-task-failure-message.ts
+++ b/mobile/src/local-tasks/local-task-failure-message.ts
@@ -1,3 +1,4 @@
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
import type { LocalTaskWriteOutcome } from './local-tasks-api'
import type { LocalTasksCopy } from './local-tasks-copy'
@@ -8,10 +9,20 @@ function isKnownCode(
return Object.hasOwn(failures, code)
}
-/** What to tell the user about a write, or null when there is nothing to say. */
+// The host names the task holding a workspace or session by its ref; the message names it by title.
+const NAMES_A_TASK: ReadonlySet = new Set([
+ 'workspace_already_linked',
+ 'session_already_linked'
+])
+
+/**
+ * What to tell the user about a write, or null when there is nothing to say. `tasks` are the ones
+ * the caller has loaded; a ref the host names is shown as that task's title when one matches.
+ */
export function localTaskWriteMessage(
copy: LocalTasksCopy,
- outcome: LocalTaskWriteOutcome
+ outcome: LocalTaskWriteOutcome,
+ tasks?: readonly Pick[]
): string | null {
switch (outcome.kind) {
case 'ok':
@@ -25,7 +36,11 @@ export function localTaskWriteMessage(
return copy.failureGeneric(outcome.code)
}
const entry = copy.failures[outcome.code]
- return typeof entry === 'function' ? entry(outcome.detail ?? '') : entry
+ const detail = outcome.detail ?? ''
+ const named = NAMES_A_TASK.has(outcome.code)
+ ? (tasks?.find((task) => task.ref === detail)?.title ?? detail)
+ : detail
+ return typeof entry === 'function' ? entry(named) : entry
}
}
}
diff --git a/mobile/src/local-tasks/local-task-filter.test.ts b/mobile/src/local-tasks/local-task-filter.test.ts
index 11205a7e6468..d0b43e82d2d9 100644
--- a/mobile/src/local-tasks/local-task-filter.test.ts
+++ b/mobile/src/local-tasks/local-task-filter.test.ts
@@ -144,4 +144,21 @@ describe('task page search and filter', () => {
['review', 1]
])
})
+
+ it('narrows "A session is waiting on me" to the tasks Needs you holds, and none without', () => {
+ const waiting = (needingYou?: ReadonlySet) =>
+ buildLocalTaskGroups({
+ tasks,
+ projects: [project],
+ projectId: 'mall',
+ segment: 'all',
+ filter: { ...EMPTY_LOCAL_TASK_FILTER, needsYou: true },
+ needingYou,
+ nestSubtasks: true,
+ keepEmpty: false
+ }).flatMap((group) => group.rows.map((row) => row.task.ref))
+ expect(waiting(new Set(['t13', 't9']))).toEqual(['MALL-13', 'MALL-9'])
+ // Out of contact the page passes no set: nothing can be confirmed as waiting.
+ expect(waiting()).toEqual([])
+ })
})
diff --git a/mobile/src/local-tasks/local-task-filter.ts b/mobile/src/local-tasks/local-task-filter.ts
index 6746e0e17fac..4307b8ea82ae 100644
--- a/mobile/src/local-tasks/local-task-filter.ts
+++ b/mobile/src/local-tasks/local-task-filter.ts
@@ -10,6 +10,8 @@ import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types
*/
export type LocalTaskFilter = {
query: string
+ /** Only tasks with an item in "Needs you", as the desktop's "A session is waiting on me". */
+ needsYou: boolean
priorities: readonly LocalTaskPriority[]
labels: readonly string[]
categories: readonly LocalTaskStatusCategory[]
@@ -17,14 +19,20 @@ export type LocalTaskFilter = {
export const EMPTY_LOCAL_TASK_FILTER: LocalTaskFilter = {
query: '',
+ needsYou: false,
priorities: [],
labels: [],
categories: []
}
-/** How many of the three pickers narrow the list; the search box shows itself. */
+/** How many choices in the filter sheet narrow the list; the search box shows itself. */
export function localTaskFilterCount(filter: LocalTaskFilter): number {
- return filter.priorities.length + filter.labels.length + filter.categories.length
+ return (
+ Number(filter.needsYou) +
+ filter.priorities.length +
+ filter.labels.length +
+ filter.categories.length
+ )
}
export function isLocalTaskFilterActive(filter: LocalTaskFilter): boolean {
diff --git a/mobile/src/local-tasks/local-task-form-fields.tsx b/mobile/src/local-tasks/local-task-form-fields.tsx
index 56ab5ba01c2b..dcb22b6a5362 100644
--- a/mobile/src/local-tasks/local-task-form-fields.tsx
+++ b/mobile/src/local-tasks/local-task-form-fields.tsx
@@ -1,11 +1,8 @@
import { useRef, useState, type ReactNode } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
-import { ChevronRight, ListTodo, Pencil, Plus, Trash2, X } from 'lucide-react-native'
-import {
- LOCAL_TASK_CRITERIA_MAX,
- LOCAL_TASK_CRITERION_MAX,
- LOCAL_TASK_TITLE_MAX
-} from '../../../src/shared/local-tasks/local-task-model'
+import { ChevronRight, ListTodo, Pencil, Trash2, X } from 'lucide-react-native'
+import { LOCAL_TASK_TITLE_MAX } from '../../../src/shared/local-tasks/local-task-model'
+import type { LocalTask, LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
import { ActionSheetModal } from '../components/ActionSheetModal'
import { ConfirmModal } from '../components/ConfirmModal'
import { colors, spacing } from '../theme/mobile-theme'
@@ -13,61 +10,15 @@ import { localTasksCopy } from './local-tasks-copy'
import {
isMultiLinePaste,
localTaskDraftEntries,
- newLocalTaskDraftEntry,
splitPastedLines,
+ type LocalTaskDraft,
type LocalTaskDraftEntry
} from './local-task-form-state'
import { LocalTaskGrowingInput } from './local-task-growing-input'
+import { localTaskStatusLookup, orderedLocalTaskStatuses } from './local-task-list-projection'
+import type { LocalTaskDraftPicker } from './local-task-pickers'
import { localTaskStyles } from './local-tasks-styles'
-
-export function LocalTaskCriteriaEditor(props: {
- criteria: LocalTaskDraftEntry[]
- onChange: (criteria: LocalTaskDraftEntry[]) => void
-}) {
- const copy = localTasksCopy()
- const set = (id: string, text: string) =>
- props.onChange(props.criteria.map((entry) => (entry.id === id ? { ...entry, text } : entry)))
- return (
-
-
- {copy.criteria} {copy.criteriaOptional}
-
- {props.criteria.map((entry) => (
-
- •
- set(entry.id, text.replace(/\r?\n/g, ' '))}
- placeholder={copy.criterionPlaceholder}
- placeholderTextColor={colors.textMuted}
- />
- props.onChange(props.criteria.filter((other) => other.id !== entry.id))}
- accessibilityRole="button"
- accessibilityLabel={copy.delete}
- >
-
-
-
- ))}
- {props.criteria.length < LOCAL_TASK_CRITERIA_MAX ? (
- props.onChange([...props.criteria, newLocalTaskDraftEntry()])}
- accessibilityRole="button"
- >
-
- {copy.addCriterion}
-
- ) : null}
-
- )
-}
+import { LocalTaskPriorityIcon, LocalTaskStatusIcon } from './local-tasks-visuals'
/** Subtasks typed into a new task. A paste of several lines adds one entry per line. */
export function LocalTaskSubtaskEntries(props: {
@@ -147,6 +98,74 @@ export function LocalTaskPropertyRow(props: {
)
}
+/** Status, priority, labels and (outside a subtask form) parent, each opening its picker. */
+export function LocalTaskPropertiesCard(props: {
+ draft: LocalTaskDraft
+ projects: readonly LocalTaskProject[]
+ tasks: readonly LocalTask[]
+ showParent: boolean
+ hidden: boolean
+ onPick: (picker: LocalTaskDraftPicker) => void
+ onPatch: (next: Partial) => void
+}) {
+ const copy = localTasksCopy()
+ const { draft } = props
+ const project = props.projects.find((entry) => entry.id === draft.projectId) ?? null
+ const status = draft.statusId
+ ? (localTaskStatusLookup(props.projects).get(draft.statusId) ?? null)
+ : null
+ // An unset status is the project's first Unstarted one, which the desktop applies on create.
+ const shownStatus =
+ status ??
+ (project
+ ? (orderedLocalTaskStatuses(project).find((entry) => entry.category === 'unstarted') ?? null)
+ : null)
+ const parent = draft.parentId
+ ? (props.tasks.find((task) => task.id === draft.parentId) ?? null)
+ : null
+ return (
+
+ props.onPick('status')}>
+
+
+ {shownStatus?.name ?? copy.defaultStatusNames.unstarted}
+
+
+ props.onPick('priority')}>
+
+ {copy.priorities[draft.priority]}
+
+
+
+ props.onPatch({ labelsText: labelsText.replace(/\r?\n/g, ' ') })
+ }
+ placeholder={copy.labelsPlaceholder}
+ placeholderTextColor={colors.textMuted}
+ />
+
+ {props.showParent ? (
+ props.onPick('parent')}>
+
+ {parent ? parent.title : copy.none}
+
+
+ ) : null}
+
+ )
+}
+
/** The choice a multi-line title paste offers in a subtask form. */
export function LocalTaskPasteChoice(props: {
lines: string[] | null
@@ -233,16 +252,9 @@ export function LocalTaskFormPrompts(props: {
)
}
-const CRITERION_LINE_HEIGHT = 21
-
const styles = StyleSheet.create({
lineRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: 6 },
lineInput: { flex: 1 },
- // A criterion wraps, so the bullet and the remove button stay on its first line.
- criterionRow: { alignItems: 'flex-start' },
- criterionBullet: { lineHeight: CRITERION_LINE_HEIGHT },
- criterionInput: { lineHeight: CRITERION_LINE_HEIGHT },
- criterionRemove: { marginTop: (CRITERION_LINE_HEIGHT - 32) / 2 },
// Why 32pt with negative margins: big enough to hit (the page ignores hitSlop) without
// making the row taller than its text.
removeButton: {
@@ -254,5 +266,7 @@ const styles = StyleSheet.create({
marginRight: -9
},
addRow: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: spacing.sm },
+ labels: { flex: 1, lineHeight: 21 },
+ hidden: { display: 'none' },
propertyValue: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' }
})
diff --git a/mobile/src/local-tasks/local-task-form-state.ts b/mobile/src/local-tasks/local-task-form-state.ts
index 5617290c0ee8..ec7e0642606d 100644
--- a/mobile/src/local-tasks/local-task-form-state.ts
+++ b/mobile/src/local-tasks/local-task-form-state.ts
@@ -46,7 +46,7 @@ export function initialLocalTaskDraft(mode: LocalTaskFormMode): LocalTaskDraft {
projectId: task.projectId,
title: task.title,
description: task.description,
- criteria: localTaskDraftEntries(task.criteria),
+ criteria: [],
statusId: task.statusId,
priority: task.priority,
labelsText: task.labels.join(', '),
@@ -106,7 +106,7 @@ export function createLocalTaskPayload(draft: LocalTaskDraft) {
projectId: draft.projectId,
title: localTaskTitleOf(draft),
description: draft.description.trim(),
- criteria: localTaskDraftTexts(draft.criteria),
+ criteria: [],
...(draft.statusId ? { statusId: draft.statusId } : {}),
priority: draft.priority,
labels: parseLocalTaskLabels(draft.labelsText),
@@ -138,10 +138,6 @@ export function localTaskPatchFor(draft: LocalTaskDraft, task: LocalTask): Local
if (draft.description.trim() !== task.description) {
patch.description = draft.description.trim()
}
- const criteria = localTaskDraftTexts(draft.criteria)
- if (!sameList(criteria, task.criteria)) {
- patch.criteria = criteria
- }
if (draft.statusId && draft.statusId !== task.statusId) {
patch.statusId = draft.statusId
}
diff --git a/mobile/src/local-tasks/local-task-form-view.tsx b/mobile/src/local-tasks/local-task-form-view.tsx
index db5ace3303e2..a5c1e01ad065 100644
--- a/mobile/src/local-tasks/local-task-form-view.tsx
+++ b/mobile/src/local-tasks/local-task-form-view.tsx
@@ -1,7 +1,15 @@
import { useState } from 'react'
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import { Check, ChevronDown, CornerDownRight, Layers } from 'lucide-react-native'
+import {
+ Check,
+ ChevronDown,
+ ChevronRight,
+ CornerDownRight,
+ Info,
+ Layers,
+ Play
+} from 'lucide-react-native'
import {
LOCAL_TASK_DESCRIPTION_MAX,
LOCAL_TASK_TITLE_MAX
@@ -12,11 +20,20 @@ import { MobileSwitch } from '../components/MobileSwitch'
import { colors, spacing } from '../theme/mobile-theme'
import type { LocalTasksApi } from './local-tasks-api'
import { localTasksCopy } from './local-tasks-copy'
+import type { LocalTaskSite } from './local-task-endpoint'
+import { localTaskCreateCopy } from './local-task-create-copy'
+import {
+ LocalTaskCreateKickoffSection,
+ localTaskCreatePlanLine
+} from './local-task-create-kickoff-section'
+import {
+ LocalTaskCreateStepHeading,
+ LocalTaskCreateWhereSection
+} from './local-task-create-where-section'
import {
- LocalTaskCriteriaEditor,
LocalTaskFormPrompts,
LocalTaskPasteChoice,
- LocalTaskPropertyRow,
+ LocalTaskPropertiesCard,
LocalTaskSubtaskEntries
} from './local-task-form-fields'
import {
@@ -27,11 +44,10 @@ import {
type LocalTaskDraft,
type LocalTaskFormMode
} from './local-task-form-state'
-import { localTaskStatusLookup, orderedLocalTaskStatuses } from './local-task-list-projection'
import { LocalTaskGrowingInput } from './local-task-growing-input'
import { LocalTaskDraftPickers, type LocalTaskDraftPicker } from './local-task-pickers'
import { localTaskStyles } from './local-tasks-styles'
-import { LocalTaskPriorityIcon, LocalTaskStatusIcon } from './local-tasks-visuals'
+import { useLocalTaskCreateWhere } from './use-local-task-create-where'
import { useLocalTaskFormSubmit } from './use-local-task-form-submit'
import type { LocalTasksLibrary } from './use-local-tasks-library'
@@ -40,6 +56,9 @@ type Props = {
api: LocalTasksApi
mode: LocalTaskFormMode
readOnly: boolean
+ /** The library's host and its routes, for where a new task is worked on and its first session. */
+ site?: LocalTaskSite
+ onNavigate?: (href: string) => void
onClose: () => void
/** The task the form created or saved, which the caller then shows. */
onDone: (taskId: string) => void
@@ -47,25 +66,32 @@ type Props = {
export function LocalTaskFormView(props: Props) {
const copy = localTasksCopy()
+ const create = localTaskCreateCopy()
const insets = useSafeAreaInsets()
const { library, mode } = props
+ const creating = mode.kind === 'create'
const [draft, setDraft] = useState(() => initialLocalTaskDraft(mode))
const [picker, setPicker] = useState(null)
const [continueCreating, setContinueCreating] = useState(false)
const [pasted, setPasted] = useState(null)
+ // A new task opens on its three steps (m8); its properties stay one tap away.
+ const [propertiesOpen, setPropertiesOpen] = useState(!creating)
const submit = useLocalTaskFormSubmit(props.api, library)
const patch = (next: Partial) =>
setDraft((previous) => ({ ...previous, ...next }))
+ const where = useLocalTaskCreateWhere({
+ active: creating && !props.readOnly,
+ site: props.site,
+ api: props.api,
+ library,
+ openedProjectId: mode.kind === 'create' ? mode.projectId : draft.projectId,
+ draft,
+ patch
+ })
+ // Why only for 稍后再定: a task that starts work opens on its detail, as the desktop does.
+ const keepCreating = continueCreating && (!creating || where.where === 'later')
const project = library.projects.find((entry) => entry.id === draft.projectId) ?? null
- const lookup = localTaskStatusLookup(library.projects)
- const status = draft.statusId ? (lookup.get(draft.statusId) ?? null) : null
- // An unset status is the project's first Unstarted one, which the desktop applies on create.
- const shownStatus =
- status ??
- (project
- ? (orderedLocalTaskStatuses(project).find((entry) => entry.category === 'unstarted') ?? null)
- : null)
const editing = mode.kind === 'edit' ? mode.task : null
const parentCandidates = localTaskParentCandidates(
editing,
@@ -73,13 +99,23 @@ export function LocalTaskFormView(props: Props) {
library.tasks,
buildLocalTaskIndex([], library.tasks)
)
- const parent = draft.parentId
- ? (library.tasks.find((task) => task.id === draft.parentId) ?? null)
- : null
const title =
mode.kind === 'edit' ? copy.editTask : mode.kind === 'subtask' ? copy.newSubtask : copy.newTask
const canSubmit =
- !props.readOnly && draft.title.trim().length > 0 && !submit.busy && !submit.subtaskRetryPending
+ !props.readOnly &&
+ draft.title.trim().length > 0 &&
+ !submit.busy &&
+ !submit.subtaskRetryPending &&
+ (!creating || where.planReady)
+ const primaryLabel =
+ mode.kind === 'edit'
+ ? copy.save
+ : creating && where.startsSession
+ ? create.createAndStart
+ : copy.create
+ // Why not read-only: a library that takes no writes has nowhere to start work either.
+ const steps = creating && !props.readOnly
+ const planLine = steps ? localTaskCreatePlanLine(where.plan, create) : null
const leaveNotice = () => {
const taskId = submit.dismissNotice()
@@ -88,7 +124,7 @@ export function LocalTaskFormView(props: Props) {
}
}
const finish = (taskId: string) => {
- if (mode.kind !== 'edit' && continueCreating) {
+ if (mode.kind !== 'edit' && keepCreating) {
setDraft(nextLocalTaskDraft)
return
}
@@ -115,19 +151,29 @@ export function LocalTaskFormView(props: Props) {
) : (
- setPicker('project')}
- accessibilityRole="button"
- accessibilityLabel={copy.project}
- >
-
-
- {project?.name ?? ''}
-
- {project?.prefix ?? ''}
-
-
+ <>
+ {creating ? : null}
+
+ setPicker('project')}
+ accessibilityRole="button"
+ accessibilityLabel={copy.project}
+ >
+
+
+ {project?.name ?? ''}
+
+ {project?.prefix ?? ''}
+
+
+ {creating && where.projectFollows ? (
+
+ {create.projectFollows}
+
+ ) : null}
+
+ >
)}
{
- if (mode.kind === 'subtask' && isMultiLinePaste(draft.title, next)) {
+ // Read-only can't create subtasks, so a paste stays as one title instead of a choice
+ // that would never show.
+ if (!props.readOnly && mode.kind === 'subtask' && isMultiLinePaste(draft.title, next)) {
setPasted(splitPastedLines(next))
return
}
@@ -156,56 +204,45 @@ export function LocalTaskFormView(props: Props) {
onChangeText={(description) => patch({ description })}
/>
)}
- patch({ criteria })}
- />
- {mode.kind === 'create' ? (
+ {creating ? (
+ [styles.toggle, pressed && localTaskStyles.pressed]}
+ onPress={() => setPropertiesOpen((open) => !open)}
+ accessibilityRole="button"
+ accessibilityState={{ expanded: propertiesOpen }}
+ >
+ {propertiesOpen ? (
+
+ ) : (
+
+ )}
+ {create.moreProperties}
+
+ ) : null}
+ {creating && propertiesOpen ? (
patch({ subtasks })}
/>
) : null}
- {copy.properties}
-
- setPicker('status')}>
-
-
- {shownStatus?.name ?? copy.defaultStatusNames.unstarted}
-
-
- setPicker('priority')}>
-
- {copy.priorities[draft.priority]}
-
-
-
- patch({ labelsText: labelsText.replace(/\r?\n/g, ' ') })
- }
- placeholder={copy.labelsPlaceholder}
- placeholderTextColor={colors.textMuted}
- />
-
- {mode.kind === 'subtask' ? null : (
- setPicker('parent')}>
-
- {parent ? parent.title : copy.none}
-
-
- )}
-
+ {propertiesOpen ? (
+ {copy.properties}
+ ) : null}
+
+ {steps ? (
+ <>
+
+
+ >
+ ) : null}
{submit.error ? (
{submit.error}
) : null}
@@ -223,10 +260,11 @@ export function LocalTaskFormView(props: Props) {
>
{copy.retrySavingSubtasks}
+ {/* Viewing them writes nothing, so a read-only library still lets the user go look. */}
{submit.partialTaskId ? (
props.onDone(mode.parent.id)}
accessibilityRole="button"
>
@@ -235,45 +273,74 @@ export function LocalTaskFormView(props: Props) {
) : null}
) : null}
- {mode.kind === 'edit' ? null : (
+ {mode.kind === 'subtask' ? (
{copy.formHint}
- )}
+ ) : null}
-
- {mode.kind === 'edit' ? (
-
- ) : (
-
-
- {copy.continueCreating}
+
+ {planLine ? (
+
+
+
+ {planLine}
+
- )}
- void submit.save(mode, draft).then((taskId) => taskId && finish(taskId))}
- accessibilityRole="button"
- >
- {submit.busy ? (
-
- ) : (
-
- )}
-
- {mode.kind === 'edit' ? copy.save : copy.create}
-
-
+ ) : null}
+
+ {mode.kind === 'edit' ? (
+
+ ) : !creating || where.where === 'later' ? (
+
+
+ {copy.continueCreating}
+
+ ) : null}
+
+ void submit
+ .save(mode, draft, creating ? where.kickoff : null)
+ .then((taskId) => taskId && finish(taskId))
+ }
+ accessibilityRole="button"
+ accessibilityLabel={primaryLabel}
+ accessibilityHint={planLine ?? undefined}
+ >
+ {submit.busy ? (
+
+ ) : creating && where.startsSession ? (
+
+ ) : (
+
+ )}
+ {primaryLabel}
+
+
{
+ where.draftChanged(next)
+ patch(next)
+ }}
onClose={() => setPicker(null)}
/>
{mode.kind === 'subtask' ? (
@@ -311,9 +378,19 @@ const styles = StyleSheet.create({
title: { lineHeight: TITLE_LINE_HEIGHT },
topBarBalance: { width: 40 },
content: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xl, gap: spacing.sm },
- contextChip: { alignSelf: 'flex-start', marginBottom: spacing.sm, maxWidth: '100%' },
+ contextChip: { alignSelf: 'flex-start', maxWidth: '100%' },
+ chipRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ marginBottom: spacing.sm
+ },
+ flex: { flex: 1 },
+ toggle: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-start' },
+ footer: { flexDirection: 'column', alignItems: 'stretch', gap: spacing.sm },
+ footerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
+ planLine: { flexDirection: 'row', alignItems: 'center', gap: 6 },
description: { minHeight: 60, lineHeight: 21, textAlignVertical: 'top' },
- labels: { flex: 1, lineHeight: 21 },
gap: { marginTop: spacing.md },
continueRow: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
partialActions: { gap: spacing.sm }
diff --git a/mobile/src/local-tasks/local-task-home-attention.ts b/mobile/src/local-tasks/local-task-home-attention.ts
index 415e61dbfa6e..14ac25f839b2 100644
--- a/mobile/src/local-tasks/local-task-home-attention.ts
+++ b/mobile/src/local-tasks/local-task-home-attention.ts
@@ -1,10 +1,14 @@
+import {
+ buildLocalTaskIndex,
+ localTaskCategory
+} from '../../../src/shared/local-tasks/local-task-grouping'
import type { LocalTaskAttentionItem } from '../../../src/shared/local-tasks/local-task-link-types'
import type { LocalTasksStatus } from '../../../src/shared/local-tasks/local-task-types'
import { localTaskLibraryHasProjects, localTaskSourceShown } from './local-tasks-access'
import type { LocalTasksApi } from './local-tasks-api'
import { countLocalTasksInCategory } from './local-task-list-projection'
import {
- localTaskAttentionItemCount,
+ localTaskAttentionTaskCount,
localTaskAttentionRows
} from './local-task-progress-projection'
@@ -14,12 +18,28 @@ export type LocalTaskHomeAttentionRow = {
taskId: string
ref: string
title: string
+ latestLine: string | null
/** The item waiting longest in this task, which is what the row says. */
- item: LocalTaskAttentionItem
+ item: LocalTaskAttentionItem | null
since: number
}
-export type LocalTaskHomeAttention = { count: number; rows: LocalTaskHomeAttentionRow[] }
+/** The newest writeback of a task in progress that "Needs you" does not already show. */
+export type LocalTaskHomeLatest = {
+ taskId: string
+ ref: string
+ body: string
+ at: number
+ writeback: boolean
+}
+
+export type LocalTaskHomeAttention = {
+ count: number
+ rows: LocalTaskHomeAttentionRow[]
+ latest: LocalTaskHomeLatest | null
+ /** Top-level tasks not completed or canceled: what "View all" opens. */
+ openCount: number
+}
/** One library as home counts it: each half is null until read, `error` when its read failed. */
export type LocalTaskHomeLibrary = {
@@ -38,18 +58,47 @@ export type LocalTaskHomeLibrary = {
export async function readLocalTaskHomeAttention(
api: LocalTasksApi
): Promise {
- const snapshot = await api.progress()
- if (snapshot.items.length === 0) {
- return { count: 0, rows: [] }
- }
- const rows = localTaskAttentionRows({ snapshot, tasks: await api.tasks(), projectId: null })
+ const [snapshot, tasks, projects] = await Promise.all([
+ api.progress(),
+ api.tasks(),
+ api.projects()
+ ])
+ const rows = localTaskAttentionRows({ snapshot, tasks, projects, projectId: null })
+ const index = buildLocalTaskIndex(projects, tasks)
+ const shown = new Set(rows.map((row) => row.task.id))
+ const latest = tasks
+ .filter(
+ (task) =>
+ !shown.has(task.id) &&
+ task.latestProgress?.body &&
+ localTaskCategory(task, index) === 'started'
+ )
+ .sort((a, b) => (b.latestProgress?.at ?? 0) - (a.latestProgress?.at ?? 0))[0]
return {
- count: localTaskAttentionItemCount(rows),
+ latest: latest?.latestProgress
+ ? {
+ taskId: latest.id,
+ ref: latest.ref,
+ body: latest.latestProgress.body.replace(/\s*\n+\s*/g, ' '),
+ at: latest.latestProgress.at,
+ writeback: latest.latestProgress.kind !== 'turn-ended'
+ }
+ : null,
+ openCount: tasks.filter((task) => {
+ const category = localTaskCategory(task, index)
+ return task.parentId === null && category !== 'completed' && category !== 'canceled'
+ }).length,
+ count: localTaskAttentionTaskCount(rows),
rows: rows.slice(0, HOME_ROWS).map((row) => ({
taskId: row.task.id,
ref: row.task.ref,
title: row.task.title,
- item: row.items[0],
+ latestLine:
+ row.task.latestProgress?.body ??
+ snapshot.sessions.find((session) => session.taskSessionId === row.items[0]?.taskSessionId)
+ ?.lastMessage ??
+ null,
+ item: row.items[0] ?? null,
since: row.since
}))
}
diff --git a/mobile/src/local-tasks/local-task-host-rows.test.tsx b/mobile/src/local-tasks/local-task-host-rows.test.tsx
index bd9a89d2538e..0a0e0a3533ba 100644
--- a/mobile/src/local-tasks/local-task-host-rows.test.tsx
+++ b/mobile/src/local-tasks/local-task-host-rows.test.tsx
@@ -439,7 +439,7 @@ describe("the host list's local tasks", () => {
sheetFor: linked
})
expect(glyphs()).toContain('Local task: Export API')
- expect(texts()).toContain("SHOP-12 · Can't confirm")
+ expect(texts()).toContain('SHOP-12 · Can’t confirm')
// Only the shared watch releasing its subscription; nothing is read.
expect(desktop.calls.slice(readsInContact)).toEqual(['desktop localTasks.unsubscribeChanges'])
const readsOutOfContact = desktop.calls.length
diff --git a/mobile/src/local-tasks/local-task-latest-line.test.ts b/mobile/src/local-tasks/local-task-latest-line.test.ts
new file mode 100644
index 000000000000..ab6400827b55
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-latest-line.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { localTaskLatestLine, localTaskLatestProgress } from './local-task-latest-line'
+
+const task = {
+ id: 'parent',
+ projectId: 'p',
+ number: 1,
+ ref: 'P-1',
+ title: 'Parent',
+ description: '',
+ criteria: [],
+ statusId: 'started',
+ priority: 0,
+ labels: [],
+ parentId: null,
+ createdAt: 0,
+ updatedAt: 0,
+ revision: 1
+} satisfies LocalTask
+
+describe('latest line on a parent task', () => {
+ it('uses the newest writeback in its descendant tree before session fallback', () => {
+ const parent = {
+ ...task,
+ latestProgress: {
+ kind: 'progress' as const,
+ body: 'Parent update',
+ at: 10,
+ sessionLinkId: 'parent-session'
+ }
+ }
+ const child = {
+ ...task,
+ id: 'child',
+ parentId: task.id,
+ latestProgress: {
+ kind: 'progress' as const,
+ body: 'Child update',
+ at: 20,
+ sessionLinkId: 'child-session'
+ }
+ }
+ expect(localTaskLatestLine(parent, [{ lastMessage: 'older session line' }], [child])).toBe(
+ 'Child update'
+ )
+ expect(localTaskLatestProgress(parent, [child])).toMatchObject({
+ taskId: 'child',
+ body: 'Child update',
+ at: 20,
+ sessionLinkId: 'child-session'
+ })
+ expect(localTaskLatestLine(task, [{ lastMessage: 'session line' }])).toBe('session line')
+ expect(localTaskLatestProgress(task, [child])).toMatchObject({
+ taskId: 'child',
+ body: 'Child update',
+ at: 20,
+ sessionLinkId: 'child-session'
+ })
+ })
+})
diff --git a/mobile/src/local-tasks/local-task-latest-line.ts b/mobile/src/local-tasks/local-task-latest-line.ts
new file mode 100644
index 000000000000..3beafd5c5a38
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-latest-line.ts
@@ -0,0 +1,26 @@
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import type { LocalTaskSessionView } from './local-task-progress-projection'
+
+export function localTaskLatestProgress(
+ task: LocalTask,
+ descendants: readonly LocalTask[] = []
+): { taskId: string; body: string; at: number; sessionLinkId: string | null } | null {
+ const newest = [task, ...descendants]
+ .flatMap((entry) =>
+ entry.latestProgress ? [{ taskId: entry.id, ...entry.latestProgress }] : []
+ )
+ .sort((a, b) => b.at - a.at)[0]
+ return newest?.body ? newest : null
+}
+
+export function localTaskLatestLine(
+ task: LocalTask,
+ sessions: readonly Pick[],
+ descendants: readonly LocalTask[] = []
+): string | null {
+ return (
+ localTaskLatestProgress(task, descendants)?.body ||
+ sessions.find((session) => session.lastMessage)?.lastMessage ||
+ null
+ )
+}
diff --git a/mobile/src/local-tasks/local-task-launch-message.ts b/mobile/src/local-tasks/local-task-launch-message.ts
index efb164d4d9e0..d828d12ae9a7 100644
--- a/mobile/src/local-tasks/local-task-launch-message.ts
+++ b/mobile/src/local-tasks/local-task-launch-message.ts
@@ -9,6 +9,7 @@ export function localTaskLaunchMessage(
): string | null {
switch (outcome.kind) {
case 'done':
+ return outcome.promptNotDelivered ? copy.work.promptNotDelivered : null
case 'workspace-created':
return null
case 'failed':
@@ -24,6 +25,8 @@ export function localTaskLaunchMessage(
return why ? `${copy.work.startedNotLinked} ${why}` : copy.work.startedNotLinked
}
case 'session-not-recorded':
- return copy.work.sessionNotRecorded
+ return outcome.promptNotDelivered
+ ? `${copy.work.sessionNotRecorded} ${copy.work.promptNotDelivered}`
+ : copy.work.sessionNotRecorded
}
}
diff --git a/mobile/src/local-tasks/local-task-launch-sheet.tsx b/mobile/src/local-tasks/local-task-launch-sheet.tsx
index 9add4c6a680c..949dae528fb4 100644
--- a/mobile/src/local-tasks/local-task-launch-sheet.tsx
+++ b/mobile/src/local-tasks/local-task-launch-sheet.tsx
@@ -1,18 +1,42 @@
import { useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
-import { Bot, ChevronRight, FolderPlus, MapPin, Send } from 'lucide-react-native'
+import { Bot, ChevronRight, FolderPlus, MapPin } from 'lucide-react-native'
import { ActionSheetContent, type ActionSheetAction } from '../components/ActionSheetModal'
import { BottomDrawer } from '../components/bottom-drawer/BottomDrawer'
-import { MobileSwitch } from '../components/MobileSwitch'
import { PickerModal } from '../components/PickerModal'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { localTasksCopy } from './local-tasks-copy'
import type { LocalTaskSetup } from './local-task-project-setups'
import type { LocalTaskLaunchController } from './use-local-task-launch'
-/** The host a launch runs on, as the user knows it. */
+/**
+ * The host a launch runs on, as the user knows it; empty when no name for it has been read, which
+ * callers word without one rather than show the host id (REQ-017).
+ */
export function localTaskHostLabel(hostId: string, label: string | null | undefined): string {
- return label?.trim() || (hostId === 'local' ? localTasksCopy().work.hostLocal : hostId)
+ return label?.trim() || (hostId === 'local' ? localTasksCopy().work.hostLocal : '')
+}
+
+/** What the sheet says in place of the agent list when there is none to pick from. */
+export function localTaskAgentsNotice(
+ agents: LocalTaskLaunchController['agents'],
+ hostLabel: string
+): string | null {
+ const work = localTasksCopy().work
+ if (agents.status === 'ready') {
+ return agents.agents.length === 0 ? work.noAgents : null
+ }
+ if (agents.status === 'loading') {
+ return null
+ }
+ switch (agents.reason) {
+ case 'offline':
+ return work.agentsHostOffline(hostLabel)
+ case 'unverifiable':
+ return work.hostUnknown
+ case 'read':
+ return work.agentsUnreadable
+ }
}
/** A checkout as Start lists it: its repo, and the host it runs on. */
@@ -69,11 +93,15 @@ export function LocalTaskLaunchSheet(props: {
onPress: () => void launch.launch(option.agent, hostLabel)
})
}
- if (
- request?.mode !== 'start' &&
- (launch.agents.status === 'failed' || launch.agents.agents.length === 0)
- ) {
- actions.push({ label: work.noAgents, disabled: true, onPress: () => {} })
+ // Why on both paths: Start still offers the workspace alone, but says why no agent is listed.
+ const notice = localTaskAgentsNotice(
+ launch.agents,
+ request?.mode === 'start'
+ ? localTaskHostLabel(request.setup.hostId, props.hostLabels.get(request.setup.hostId))
+ : props.workspaceHostLabel
+ )
+ if (notice) {
+ actions.push({ label: notice, disabled: true, onPress: () => {} })
}
}
if (request?.mode === 'start') {
@@ -104,41 +132,25 @@ export function LocalTaskLaunchSheet(props: {
actions={actions}
onClose={launch.close}
>
-
- {canChangeLocation ? (
- <>
- [styles.setting, pressed && styles.settingPressed]}
- onPress={() => setPhase('to-picker')}
- accessibilityRole="button"
- >
-
-
- {work.changeLocation}
-
- {hostLabel}
-
-
-
-
-
- >
- ) : null}
-
-
-
- {work.sendNow}
-
- {launch.delivery === 'submit' ? work.sendNowOnHint : work.sendNowOffHint}
-
-
- launch.toggleDelivery()}
- accessibilityLabel={work.sendNow}
- />
+ {/* No send-now switch: a host never delivers a draft, so the task is always sent. */}
+ {canChangeLocation ? (
+
+ [styles.setting, pressed && styles.settingPressed]}
+ onPress={() => setPhase('to-picker')}
+ accessibilityRole="button"
+ >
+
+
+ {work.changeLocation}
+
+ {hostLabel}
+
+
+
+
-
+ ) : null}
{
expect(sheet.props?.choices).toEqual([])
expect(sheet.props?.message).toBe(localTasksCopy().work.workspaceNotListed)
})
+
+ it('names the task already holding the workspace by its title, not its ref', async () => {
+ relay.hosts = [server('ready')]
+ const links = RESULTS['localTasks.links.list']
+ RESULTS['localTasks.links.list'] = {
+ workspaces: [
+ {
+ taskId: task.id,
+ hostId: 'local',
+ kind: 'git-worktree',
+ instanceId: null,
+ worktreeId: 'ops::/w/vm-12',
+ folderWorkspaceId: null,
+ repoId: 'ops',
+ branchSnapshot: null,
+ nameSnapshot: 'vm-12',
+ hostLabelSnapshot: null,
+ linkedVia: 'manual',
+ linkedAt: 1
+ }
+ ],
+ sessions: []
+ }
+ try {
+ await open(listedOnServer('ops::/w/vm-12'))
+ expect(sheet.props?.message).toBe(localTasksCopy().work.workspaceTaken('Rotate keys'))
+ } finally {
+ RESULTS['localTasks.links.list'] = links
+ }
+ })
})
diff --git a/mobile/src/local-tasks/local-task-link-to-task-sheet.tsx b/mobile/src/local-tasks/local-task-link-to-task-sheet.tsx
index db583497481b..24336db64fe1 100644
--- a/mobile/src/local-tasks/local-task-link-to-task-sheet.tsx
+++ b/mobile/src/local-tasks/local-task-link-to-task-sheet.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
+import { matchesLocalTaskQuery } from '../../../src/shared/local-tasks/local-task-grouping'
import type { LocalTaskLinks } from '../../../src/shared/local-tasks/local-task-link-types'
import type { LocalTask, LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
import { taskRuntimeStatusRead } from '../tasks/mobile-task-runtime-operations'
@@ -51,7 +52,7 @@ export function localTaskLinkChoices(library: Library, related: ReadonlySet task.id === holder.taskId)?.ref
+ const holderTitle = holder
+ ? ready?.library.tasks.find((task) => task.id === holder.taskId)?.title
: null
const tasks = ready && ownTarget ? localTaskLinkChoices(ready.library, ready.related) : []
+ const [query, setQuery] = useState('')
+ const projectsById = new Map(ready?.library.projects.map((project) => [project.id, project]))
+ const shown = tasks.filter((task) =>
+ matchesLocalTaskQuery(task, projectsById.get(task.projectId), query)
+ )
const link = async (task: LocalTask) => {
if (!ownTarget || !libraryClient) {
return
@@ -175,23 +181,24 @@ export function LocalTaskLinkToTaskSheet(props: {
},
createLocalTasksApi(libraryClient).linkWorkspace
)
- setError(localTaskWriteMessage(copy, outcome))
+ setError(localTaskWriteMessage(copy, outcome, ready?.library.tasks))
if (outcome.kind === 'ok') {
props.onLinked?.()
props.onClose()
}
}
+ // Why no host id fallback: an unnamed host is worded without one (REQ-017).
const message =
contact === 'offline'
- ? copy.work.hostOffline(props.hostLabel || relay?.label || (target?.hostId ?? ''))
+ ? copy.work.hostOffline(props.hostLabel || relay?.label || '')
: reading.status === 'failed' || (endpoint.executionHost && serverRows.status === 'failed')
? copy.loadFailed
: reading.status === 'unsupported'
? copy.unsupported
: target && !ownPending && !ownTarget
? copy.work.workspaceNotListed
- : holderRef
- ? copy.work.workspaceTaken(holderRef)
+ : holder
+ ? copy.work.workspaceTaken(holderTitle ?? '')
: ready && tasks.length === 0
? copy.work.noOpenTasks
: null
@@ -206,7 +213,10 @@ export function LocalTaskLinkToTaskSheet(props: {
}
message={message}
error={error}
- choices={tasks.map((task) => {
+ search={
+ tasks.length > 0 ? { query, placeholder: copy.searchPlaceholder, onChange: setQuery } : null
+ }
+ choices={shown.map((task) => {
const held = ready?.library.links.workspaces.find((entry) => entry.taskId === task.id)
return {
key: task.id,
@@ -220,6 +230,7 @@ export function LocalTaskLinkToTaskSheet(props: {
})}
onClose={() => {
setError(null)
+ setQuery('')
props.onClose()
}}
/>
diff --git a/mobile/src/local-tasks/local-task-list-projection.ts b/mobile/src/local-tasks/local-task-list-projection.ts
index d3fb62c3f924..4eff213c7841 100644
--- a/mobile/src/local-tasks/local-task-list-projection.ts
+++ b/mobile/src/local-tasks/local-task-list-projection.ts
@@ -70,6 +70,8 @@ type GroupInput = {
projectId: string | null
segment: LocalTaskSegment
filter?: LocalTaskFilter
+ /** The tasks "Needs you" holds, which the filter's `needsYou` narrows to. */
+ needingYou?: ReadonlySet
}
export function buildLocalTaskGroups(
@@ -78,7 +80,12 @@ export function buildLocalTaskGroups(
const index = buildLocalTaskIndex(input.projects, input.tasks)
const scope = tasksInScope(input.tasks, input.projectId)
const filter = input.filter ?? EMPTY_LOCAL_TASK_FILTER
- const visibility = { view: input.segment, filters: filter, query: filter.query }
+ const visibility = {
+ view: input.segment,
+ filters: filter,
+ query: filter.query,
+ ...(filter.needsYou ? { onlyTaskIds: input.needingYou ?? new Set() } : {})
+ }
const row = (task: LocalTask, subtasks: LocalTaskTreeLine[]): LocalTaskListRow => {
const progress = localTaskSubtaskProgress(task, index)
return { task, subtasks, subtaskDone: progress?.done ?? 0, subtaskTotal: progress?.total ?? 0 }
diff --git a/mobile/src/local-tasks/local-task-needs-you-list.tsx b/mobile/src/local-tasks/local-task-needs-you-list.tsx
new file mode 100644
index 000000000000..b53e6a1e2f95
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-needs-you-list.tsx
@@ -0,0 +1,194 @@
+import { useState } from 'react'
+import { View } from 'react-native'
+import { buildLocalTaskIndex } from '../../../src/shared/local-tasks/local-task-grouping'
+import type { LocalTaskAttentionItem } from '../../../src/shared/local-tasks/local-task-link-types'
+import {
+ LOCAL_TASK_SNOOZE_PRESETS,
+ localTaskSnoozeUntil
+} from '../../../src/shared/local-tasks/local-task-snooze-presets'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { ActionSheetModal } from '../components/ActionSheetModal'
+import type { LocalTasksApi } from './local-tasks-api'
+import { localTasksCopy } from './local-tasks-copy'
+import { LocalTaskAttentionCard, localTaskAttentionLine } from './local-task-attention-row'
+import { localTaskCardStyles } from './local-task-card'
+import { localTaskCardFacts } from './local-task-card-facts'
+import type { LocalTaskSite } from './local-task-endpoint'
+import { localTaskStatusLookup } from './local-task-list-projection'
+import type { LocalTaskAttentionRow } from './local-task-progress-projection'
+import { LocalTaskReplyDrawer } from './local-task-reply-drawer'
+import { LocalTaskSwipeRow } from './local-task-swipe-row'
+import { localTaskStyles } from './local-tasks-styles'
+import {
+ useLocalTaskAttentionActions,
+ type LocalTaskAttentionActions
+} from './use-local-task-attention-actions'
+import type { LocalTasksLibrary } from './use-local-tasks-library'
+
+/** How cards name hosts and PRs: from what the page read, never by asking a host per card. */
+export type LocalTaskCardContext = {
+ hostName: (ownerHostId: string, snapshotLabel: string | null) => string
+ hostConnected: (ownerHostId: string) => boolean | null
+ reviewDetail: (task: LocalTask) => string | null
+}
+
+/** Names only the library's own machine; other hosts by the label their link recorded. */
+export function localTaskBasicCardContext(libraryLabel: string): LocalTaskCardContext {
+ return {
+ hostName: (hostId, snapshot) => (hostId === 'local' ? libraryLabel : (snapshot ?? '')),
+ hostConnected: () => null,
+ reviewDetail: () => null
+ }
+}
+
+/**
+ * The "Needs you" cards with everything they open: snooze choices, the long-press menu, and the
+ * reply box for a finished turn. Swiping a card marks it seen or snoozes it whole.
+ */
+export function LocalTaskNeedsYouList(props: {
+ library: LocalTasksLibrary
+ api: LocalTasksApi
+ site: Pick
+ rows: readonly LocalTaskAttentionRow[]
+ now: number
+ canWrite: boolean
+ context: LocalTaskCardContext
+ onOpenTask: (taskId: string) => void
+ onPreview: (taskSessionId: string) => void
+ onOpenSession: (taskSessionId: string) => void
+}) {
+ const copy = localTasksCopy()
+ const work = copy.work
+ const { library } = props
+ const actions = useLocalTaskAttentionActions({
+ api: props.api,
+ library,
+ client: props.site.client,
+ copy
+ })
+ const [snoozing, setSnoozing] = useState(null)
+ const [menuFor, setMenuFor] = useState(null)
+ const [replying, setReplying] = useState<{ item: LocalTaskAttentionItem; title: string } | null>(
+ null
+ )
+ // Why kept per item in memory: a reply the agent never got must not lose what the user wrote.
+ const [drafts, setDrafts] = useState>({})
+ const connected = props.site.connState === 'connected'
+ const index = buildLocalTaskIndex(library.projects, library.tasks)
+ const lookup = localTaskStatusLookup(library.projects)
+ const handlers = {
+ errors: actions.errors,
+ replyFor: (itemKey: string) => actions.replies[itemKey] ?? null,
+ onPreview: props.onPreview,
+ onOpenSession: props.onOpenSession,
+ onReply: (item: LocalTaskAttentionItem) => {
+ const row = props.rows.find((entry) => entry.items.includes(item))
+ setReplying({ item, title: row?.task.title ?? '' })
+ },
+ canWrite: props.canWrite,
+ onSnooze: (items: LocalTaskAttentionItem[]) => setSnoozing(items)
+ }
+ const now = new Date(props.now)
+ const swipeActions = (row: LocalTaskAttentionRow) =>
+ props.canWrite && row.items.length > 0
+ ? [
+ {
+ label: work.markSeen,
+ tone: 'neutral' as const,
+ onPress: () => void actions.markSeen(row.items)
+ },
+ { label: work.snooze, tone: 'accent' as const, onPress: () => setSnoozing(row.items) }
+ ]
+ : []
+ return (
+ <>
+
+ {props.rows.map((row, at) => (
+
+ (props.canWrite && row.items.length > 0 ? setMenuFor(row) : undefined)}
+ />
+
+ ))}
+
+ menuFor && void actions.markSeen(menuFor.items) },
+ { label: work.snooze, onPress: () => menuFor && setSnoozing(menuFor.items) }
+ ]}
+ onClose={() => setMenuFor(null)}
+ />
+ {/* No "Pick a time…" here: the phone has no date-time picker to offer one with. */}
+ localTaskSnoozeUntil(choice, now) !== null
+ ).map((choice) => ({
+ label: work.snoozeChoices[choice],
+ onPress: () => snoozing && void actions.snooze(snoozing, choice)
+ }))}
+ onClose={() => setSnoozing(null)}
+ />
+ {
+ const key = replying?.item.key
+ if (key) {
+ setDrafts((previous) => ({ ...previous, [key]: draft }))
+ }
+ }}
+ onSend={(text) => {
+ const target = replying
+ setReplying(null)
+ if (target) {
+ sendReply(actions, target.item, text, setDrafts)
+ }
+ }}
+ onClose={() => setReplying(null)}
+ />
+ >
+ )
+}
+
+/** Empties the draft as the send goes out; only a send nothing received puts the text back. */
+function sendReply(
+ actions: LocalTaskAttentionActions,
+ item: LocalTaskAttentionItem,
+ text: string,
+ setDrafts: (update: (previous: Record) => Record) => void
+) {
+ const key = item.key
+ setDrafts(({ [key]: _sent, ...rest }) => rest)
+ void actions.reply(item, text).then((outcome) => {
+ if (outcome.kind === 'not-sent') {
+ setDrafts((previous) => ({
+ ...previous,
+ [key]: previous[key] ? `${text}\n${previous[key]}` : text
+ }))
+ }
+ })
+}
diff --git a/mobile/src/local-tasks/local-task-now-card.tsx b/mobile/src/local-tasks/local-task-now-card.tsx
new file mode 100644
index 000000000000..2cae4adbe144
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-now-card.tsx
@@ -0,0 +1,266 @@
+import { useState } from 'react'
+import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { ChevronDown, ChevronRight, CircleDot, CornerDownRight, List } from 'lucide-react-native'
+import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label'
+import type {
+ LocalTaskLinks,
+ LocalTaskProgressSnapshot
+} from '../../../src/shared/local-tasks/local-task-link-types'
+import { parseLocalTaskProgressSections } from '../../../src/shared/local-tasks/local-task-progress-sections'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import {
+ findLocalTaskWriteback,
+ type LocalTaskWriteback
+} from '../../../src/shared/local-tasks/local-task-writebacks'
+import { AgentStateDot } from '../components/AgentStateDot'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+import { LocalTaskCardFoot } from './local-task-card'
+import type { LocalTaskCardFacts } from './local-task-card-facts'
+import { formatLocalTaskAgo, localTasksCopy } from './local-tasks-copy'
+import { localTaskLatestProgress } from './local-task-latest-line'
+import { localTasksPageCopy } from './local-tasks-page-copy'
+import type { LocalTaskSessionView } from './local-task-progress-projection'
+import { LOCAL_TASK_SESSION_DOT } from './local-task-session-dots'
+import { localTaskStyles } from './local-tasks-styles'
+
+/** A writeback's body, as its labelled lines when it was written that way. */
+function ProgressBody(props: { body: string }) {
+ const sections = parseLocalTaskProgressSections(props.body)
+ if (!sections) {
+ return (
+ <>
+ {props.body
+ .split(/\n+/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line, at) => (
+
+ {line}
+
+ ))}
+ >
+ )
+ }
+ return (
+ <>
+ {sections.map((section, at) => (
+
+ {section.label}
+ {section.text}
+
+ ))}
+ >
+ )
+}
+
+/**
+ * The task's "Now" (m4): the latest progress headed by its writeback number, agent and time, kept in
+ * its labelled lines; every earlier writeback one tap away; then each session, which opens as its
+ * kind opens (Q2). Out of contact it says so and keeps what was last read.
+ */
+export function LocalTaskNowCard(props: {
+ task: LocalTask
+ descendants: readonly LocalTask[]
+ facts: LocalTaskCardFacts
+ /** The task's own writebacks, oldest first; empty until its events are read. */
+ writebacks: readonly LocalTaskWriteback[]
+ sessions: readonly LocalTaskSessionView[]
+ links: LocalTaskLinks
+ snapshot: LocalTaskProgressSnapshot | null
+ now: number
+ onOpenSession: (taskSessionId: string) => void
+ onLongPressSession: ((sessionLinkId: string) => void) | null
+}) {
+ const copy = localTasksCopy()
+ const page = localTasksPageCopy()
+ const work = copy.work
+ const { facts } = props
+ const [showAll, setShowAll] = useState(false)
+ const written = localTaskLatestProgress(props.task, props.descendants)
+ const writer = written
+ ? (props.descendants.find((child) => child.id === written.taskId) ?? null)
+ : null
+ const numbered = written && !writer ? findLocalTaskWriteback(props.writebacks, written) : null
+ const writerSessionId = numbered?.sessionLinkId ?? written?.sessionLinkId ?? null
+ const writerAgent =
+ props.links.sessions.find((link) => link.id === writerSessionId)?.agent ?? null
+ const turnEnded = (writer ?? props.task).latestProgress?.kind === 'turn-ended'
+ const earlier = props.writebacks
+ .filter((entry) => entry !== numbered)
+ .sort((a, b) => b.number - a.number)
+ return (
+
+
+
+ {copy.now}
+
+
+ {page.latestProgress}
+ {written ? (
+ <>
+
+ {[
+ numbered
+ ? page.writebackNumber(numbered.number)
+ : turnEnded
+ ? work.card.lastLine
+ : work.card.writeback,
+ writer ? writer.ref : null,
+ writerAgent ? formatAgentTypeLabel(writerAgent) : null,
+ formatLocalTaskAgo(copy, written.at, props.now)
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+ >
+ ) : (
+ {page.noWriteback}
+ )}
+
+ {props.writebacks.length >= 2 ? (
+ setShowAll((shown) => !shown)}
+ accessibilityRole="button"
+ accessibilityState={{ expanded: showAll }}
+ testID="local-task-all-progress"
+ >
+ {showAll ? (
+
+ ) : (
+
+ )}
+
+ {showAll ? page.collapse : page.allProgress(props.writebacks.length)}
+
+
+ ) : null}
+ {showAll
+ ? earlier.map((entry) => (
+
+
+ {page.writebackNumber(entry.number)} ·{' '}
+ {formatLocalTaskAgo(copy, entry.event.createdAt, props.now)}
+
+
+
+ ))
+ : null}
+ {facts.offline && facts.workspace ? (
+
+
+
+ ) : null}
+ {props.sessions.map((view) => {
+ const item =
+ view.state === 'unverifiable'
+ ? null
+ : (props.snapshot?.items.find((entry) => entry.taskSessionId === view.link.id) ?? null)
+ const stateSince =
+ props.snapshot?.sessions.find((entry) => entry.taskSessionId === view.link.id)
+ ?.stateSince ?? null
+ return (
+ [styles.session, pressed && styles.pressed]}
+ onPress={() => props.onOpenSession(view.link.id)}
+ onLongPress={
+ props.onLongPressSession ? () => props.onLongPressSession?.(view.link.id) : undefined
+ }
+ delayLongPress={350}
+ accessibilityRole="button"
+ accessibilityHint={props.onLongPressSession ? work.sessionActionsHint : undefined}
+ testID={`local-task-session-${view.link.id}`}
+ >
+
+
+
+ {formatAgentTypeLabel(view.link.agent)}
+ {work.surfaces[view.link.surface]}
+ {view.task && view.task.id !== props.task.id ? (
+
+
+
+ {view.task.title}
+
+
+ ) : null}
+
+ {[
+ item ? work.attentionKinds[item.kind] : work.sessionStates[view.state],
+ view.state === 'working' && stateSince !== null
+ ? work.card.duration(props.now - stateSince)
+ : null
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+ {view.lastMessage ? (
+
+ {view.lastMessage}
+
+ ) : null}
+
+
+
+ )
+ })}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ card: { marginTop: spacing.lg },
+ label: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: colors.borderSubtle
+ },
+ labelText: { color: colors.textSecondary, fontSize: typography.metaSize, fontWeight: '600' },
+ section: { padding: spacing.md, gap: 6 },
+ heading: { color: colors.textPrimary, fontSize: typography.metaSize, fontWeight: '600' },
+ sectionLabel: { color: colors.textSecondary },
+ allProgress: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 6,
+ paddingVertical: spacing.sm,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.borderSubtle
+ },
+ allProgressText: { color: colors.textSecondary, fontSize: typography.metaSize },
+ earlier: {
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ gap: 4,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.borderSubtle
+ },
+ offline: { paddingHorizontal: spacing.md, paddingBottom: spacing.sm },
+ session: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.md,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.borderSubtle
+ },
+ pressed: { backgroundColor: colors.bgRaised },
+ sessionMain: { flex: 1, minWidth: 0, gap: 4 },
+ sessionLine: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ subtask: { flexDirection: 'row', alignItems: 'center', gap: 2, flexShrink: 1 },
+ agent: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
+ state: { flexShrink: 1, color: colors.textSecondary, fontSize: typography.metaSize },
+ attention: { color: colors.statusAmber }
+})
diff --git a/mobile/src/local-tasks/local-task-preview-drawer.tsx b/mobile/src/local-tasks/local-task-preview-drawer.tsx
new file mode 100644
index 000000000000..13add260aead
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-preview-drawer.tsx
@@ -0,0 +1,55 @@
+import { buildLocalTaskIndex } from '../../../src/shared/local-tasks/local-task-grouping'
+import type { LocalTasksApi } from './local-tasks-api'
+import { localTaskSessionSummary } from './local-task-card-facts'
+import type { LocalTaskSite } from './local-task-endpoint'
+import { LocalTaskSessionDrawer } from './local-task-session-drawer'
+import { localTaskChatPreviewTarget } from './local-task-session-open'
+import type { LocalTasksLibrary } from './use-local-tasks-library'
+
+/**
+ * A chat session's preview (Q2): the drawer with its head (agent, state, task, where it runs) and
+ * a way into the full session. Closed while `taskSessionId` is null or names no routable chat.
+ */
+export function LocalTaskPreviewDrawer(props: {
+ site: Pick
+ api: LocalTasksApi
+ library: LocalTasksLibrary
+ taskSessionId: string | null
+ now: number
+ hostName: (ownerHostId: string, snapshotLabel: string | null) => string
+ onOpenFull: (taskSessionId: string) => void
+ onClose: () => void
+}) {
+ const { library } = props
+ const target = props.taskSessionId
+ ? localTaskChatPreviewTarget(library, props.taskSessionId)
+ : null
+ const summary = target
+ ? localTaskSessionSummary({
+ taskSessionId: target.session.id,
+ index: buildLocalTaskIndex(library.projects, library.tasks),
+ links: library.work.links,
+ snapshot: library.work.progress,
+ inContact: props.site.connState === 'connected',
+ hostName: props.hostName
+ })
+ : null
+ return (
+ {
+ if (target) {
+ props.onClose()
+ props.onOpenFull(target.session.id)
+ }
+ }}
+ onClose={props.onClose}
+ />
+ )
+}
diff --git a/mobile/src/local-tasks/local-task-progress-projection.test.ts b/mobile/src/local-tasks/local-task-progress-projection.test.ts
new file mode 100644
index 000000000000..eadfeecafbc9
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-progress-projection.test.ts
@@ -0,0 +1,113 @@
+import { expect, it } from 'vitest'
+import type { LocalTaskProgressSnapshot } from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTask, LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
+import {
+ localTaskAttentionRows,
+ localTaskAttentionTaskCount,
+ localTaskNeedsYou
+} from './local-task-progress-projection'
+
+const project: LocalTaskProject = {
+ id: 'p',
+ name: 'Project',
+ prefix: 'P',
+ aliases: [],
+ repos: [],
+ defaultRepoId: null,
+ lastExecutionHostId: null,
+ rules: { startMovesToInProgress: true, prMergedCompletes: false },
+ statuses: [
+ {
+ id: 'review',
+ projectId: 'p',
+ category: 'review',
+ name: 'Ready',
+ color: 'yellow',
+ position: 0
+ }
+ ],
+ createdAt: 0,
+ updatedAt: 0,
+ revision: 0
+}
+function task(id: string, statusId: string): LocalTask {
+ return {
+ id,
+ projectId: 'p',
+ number: Number(id),
+ ref: `P-${id}`,
+ title: id,
+ description: '',
+ criteria: [],
+ statusId,
+ priority: 0,
+ labels: [],
+ parentId: null,
+ createdAt: 0,
+ updatedAt: 100,
+ revision: 0
+ }
+}
+
+it('counts one task with multiple waiting sessions once and includes review tasks once', () => {
+ const snapshot: LocalTaskProgressSnapshot = {
+ observedAt: 200,
+ sessions: [],
+ items: ['one', 'two'].map((key) => ({
+ key,
+ taskId: '1',
+ taskSessionId: key,
+ kind: 'unread-done' as const,
+ agent: 'claude',
+ lastMessage: null,
+ since: 50,
+ replyable: true
+ }))
+ }
+ const rows = localTaskAttentionRows({
+ snapshot,
+ tasks: [task('1', 'review'), task('2', 'review')],
+ projects: [project],
+ projectId: null
+ })
+ expect(rows.map((row) => [row.task.id, row.items.length, row.review])).toEqual([
+ ['1', 2, true],
+ ['2', 0, true]
+ ])
+ expect(localTaskAttentionTaskCount(rows)).toBe(2)
+})
+
+it('groups "Needs you" as the desktop does: waiting first, review wins, subtasks ride along', () => {
+ const started = { ...project.statuses[0]!, id: 'started', category: 'started' as const }
+ const withStarted = { ...project, statuses: [...project.statuses, started] }
+ const item = (taskId: string, since: number) => ({
+ key: `${taskId}@${since}`,
+ taskId,
+ taskSessionId: `s-${taskId}`,
+ kind: 'question' as const,
+ agent: 'codex',
+ lastMessage: 'Top or bottom?',
+ since,
+ replyable: false
+ })
+ const parent = task('1', 'review')
+ const child = { ...task('2', 'started'), parentId: '1' }
+ const other = task('3', 'started')
+ const { rows, ids } = localTaskNeedsYou({
+ snapshot: {
+ observedAt: 200,
+ sessions: [],
+ items: [item('1', 10), item('2', 20), item('3', 30)]
+ },
+ tasks: [parent, child, other],
+ projects: [withStarted],
+ projectId: null
+ })
+ expect(rows.map((row) => [row.task.id, row.review])).toEqual([
+ ['3', false],
+ ['1', true]
+ ])
+ // The parent's own question stays on its review row; the subtask's stays off the band.
+ expect(rows[1]?.items.map((entry) => entry.taskId)).toEqual(['1'])
+ expect([...ids].sort()).toEqual(['1', '3'])
+})
diff --git a/mobile/src/local-tasks/local-task-progress-projection.ts b/mobile/src/local-tasks/local-task-progress-projection.ts
index ca65a846b55b..bc523151b936 100644
--- a/mobile/src/local-tasks/local-task-progress-projection.ts
+++ b/mobile/src/local-tasks/local-task-progress-projection.ts
@@ -6,12 +6,17 @@ import type {
LocalTaskSessionState,
LocalTaskWorkspaceLink
} from '../../../src/shared/local-tasks/local-task-link-types'
-import { buildLocalTaskIndex } from '../../../src/shared/local-tasks/local-task-grouping'
+import {
+ buildLocalTaskIndex,
+ type LocalTaskIndex
+} from '../../../src/shared/local-tasks/local-task-grouping'
+import { localTaskWorkspaceFamilyIds } from '../../../src/shared/local-tasks/local-task-hierarchy'
import {
localTaskDescendantIds,
localTaskEffectiveWorkspace
} from '../../../src/shared/local-tasks/local-task-tree'
-import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { buildLocalTaskNeedsYouBand } from '../../../src/shared/local-tasks/local-task-needs-you-band'
+import type { LocalTask, LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
// What the phone shows of the library host's judgement. Every state here is the host's; the phone
// only groups, filters and orders it, and says "can't confirm" out of contact.
@@ -19,39 +24,55 @@ import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types
/** One task's "Needs you" items, merged into a single row. */
export type LocalTaskAttentionRow = {
task: LocalTask
+ review: boolean
/** Oldest first: the one waiting longest leads. */
items: LocalTaskAttentionItem[]
since: number
}
-/** Items of tasks in scope, one row per task, the task waiting longest first. */
-export function localTaskAttentionRows(args: {
+/**
+ * "Needs you" of the tasks in scope, grouped as the desktop groups it (the shared band): sessions
+ * waiting on the user, newest first, then tasks awaiting acceptance. `visibleIds` narrows it to
+ * what a filtered list shows.
+ */
+export function localTaskNeedsYou(args: {
snapshot: LocalTaskProgressSnapshot | null
tasks: readonly LocalTask[]
+ projects?: readonly LocalTaskProject[]
projectId: string | null
-}): LocalTaskAttentionRow[] {
- if (!args.snapshot) {
- return []
- }
- const tasksById = new Map(args.tasks.map((task) => [task.id, task]))
- const rows = new Map()
- for (const item of args.snapshot.items) {
- const task = tasksById.get(item.taskId)
- if (!task || (args.projectId !== null && task.projectId !== args.projectId)) {
- continue
+ visibleIds?: ReadonlySet
+}): { rows: LocalTaskAttentionRow[]; ids: ReadonlySet } {
+ const index = buildLocalTaskIndex(args.projects ?? [], args.tasks)
+ const scoped = args.tasks.filter(
+ (task) =>
+ (args.projectId === null || task.projectId === args.projectId) &&
+ (!args.visibleIds || args.visibleIds.has(task.id))
+ )
+ const band = buildLocalTaskNeedsYouBand({
+ progress: args.snapshot,
+ index,
+ visibleIds: new Set(scoped.map((task) => task.id))
+ })
+ const rows = [...band.waiting, ...band.review].map((row) => {
+ const items = [...row.items].sort((a, b) => a.since - b.since)
+ return {
+ task: row.task,
+ review: row.group === 'review',
+ items,
+ since: items[0]?.since ?? row.task.updatedAt
}
- const row = rows.get(task.id) ?? { task, items: [], since: item.since }
- row.items.push(item)
- row.since = Math.min(row.since, item.since)
- rows.set(task.id, row)
- }
- return [...rows.values()]
- .map((row) => ({ ...row, items: [...row.items].sort((a, b) => a.since - b.since) }))
- .sort((a, b) => a.since - b.since || a.task.number - b.task.number)
+ })
+ return { rows, ids: band.ids }
+}
+
+export function localTaskAttentionRows(
+ args: Parameters[0]
+): LocalTaskAttentionRow[] {
+ return localTaskNeedsYou(args).rows
}
-export function localTaskAttentionItemCount(rows: readonly LocalTaskAttentionRow[]): number {
- return rows.reduce((sum, row) => sum + row.items.length, 0)
+export function localTaskAttentionTaskCount(rows: readonly LocalTaskAttentionRow[]): number {
+ return rows.length
}
/** The workspace a task works in: its own, else the nearest ancestor's (REQ-009). */
@@ -106,6 +127,44 @@ export function localTaskSessionViews(args: {
})
}
+export type LocalTaskRowSession = { id: string; state: LocalTaskSessionState }
+
+/**
+ * The states a list row or board card shows as dots (REQ-012), oldest session first, picked as the
+ * desktop picks them: a task with its own workspace shows every session in it, those of descendants
+ * without one included; a task working in an ancestor's shows only the sessions it opened. Read from
+ * the snapshot already loaded, so it asks the host nothing; out of contact each is unverifiable.
+ */
+export function localTaskRowSessionStates(args: {
+ task: LocalTask
+ index: Pick
+ links: LocalTaskLinks
+ snapshot: LocalTaskProgressSnapshot | null
+ inContact: boolean
+}): LocalTaskRowSession[] {
+ const owners = new Set(args.links.workspaces.map((link) => link.taskId))
+ const family = new Set(
+ owners.has(args.task.id)
+ ? localTaskWorkspaceFamilyIds(args.task.id, args.index, (taskId) => owners.has(taskId))
+ : [args.task.id]
+ )
+ const progress = new Map(
+ (args.snapshot?.sessions ?? []).map((entry) => [entry.taskSessionId, entry.state])
+ )
+ return args.links.sessions
+ .filter((link) => family.has(link.taskId))
+ .sort((a, b) => a.openedAt - b.openedAt)
+ .map((link) => ({
+ id: link.id,
+ state: args.inContact ? (progress.get(link.id) ?? 'unknown') : 'unverifiable'
+ }))
+}
+
+/** The tasks with an item in "Needs you", for the "A session is waiting on me" filter. */
+export function localTaskIdsNeedingYou(rows: readonly LocalTaskAttentionRow[]): Set {
+ return new Set(rows.map((row) => row.task.id))
+}
+
/** The items one session holds, which opening that session marks seen (技术方案 §9.2). */
export function localTaskItemsForSession(
snapshot: LocalTaskProgressSnapshot | null,
diff --git a/mobile/src/local-tasks/local-task-reply-drawer.tsx b/mobile/src/local-tasks/local-task-reply-drawer.tsx
index 3278e6c71c4a..fe033db9195c 100644
--- a/mobile/src/local-tasks/local-task-reply-drawer.tsx
+++ b/mobile/src/local-tasks/local-task-reply-drawer.tsx
@@ -6,7 +6,8 @@ import { localTasksCopy } from './local-tasks-copy'
/**
* The reply box of one "Needs you" item. The draft is the caller's, so closing keeps it; out of
- * contact the box is disabled and says it is reconnecting, as the phone's chat composer does.
+ * contact the box is disabled, says it is reconnecting as the phone's chat composer does, and says
+ * above it why Send is off.
*/
export function LocalTaskReplyDrawer(props: {
visible: boolean
@@ -14,6 +15,8 @@ export function LocalTaskReplyDrawer(props: {
message?: string
draft: string
connected: boolean
+ /** The library's Orca, named in the out-of-contact line. */
+ hostLabel: string
onChangeDraft: (draft: string) => void
onSend: (text: string) => void
onClose: () => void
@@ -26,6 +29,11 @@ export function LocalTaskReplyDrawer(props: {
{props.title}
{props.message ? {props.message} : null}
+ {props.connected ? null : (
+
+ {copy.work.replyDisconnected(props.hostLabel)}
+
+ )}
({
import type { LocalTaskSessionLink } from '../../../src/shared/local-tasks/local-task-link-types'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
import type { RpcResponse } from '../transport/types'
-import { sendLocalTaskReply } from './local-task-reply-send'
+import { sendLocalTaskReply, type LocalTaskReplyReasons } from './local-task-reply-send'
+
+const reasons: LocalTaskReplyReasons = {
+ missing: 'gone',
+ notConnected: 'offline',
+ inputNotCleared: 'stale input',
+ inputRefused: 'refused',
+ chatNotReady: 'no fence'
+}
const LEAF_A = '11111111-1111-4111-8111-111111111111'
const LEAF_B = '22222222-2222-4222-8222-222222222222'
@@ -61,7 +69,7 @@ describe('a "Needs you" reply into a terminal session', () => {
client,
session: terminalSession({}),
text: 'run the tests',
- missing: 'gone'
+ reasons
})
expect(outcome).toEqual({ kind: 'delivered' })
const sends = client.sendRequest.mock.calls.filter(([method]) => method === 'terminal.send')
@@ -76,7 +84,7 @@ describe('a "Needs you" reply into a terminal session', () => {
client,
session: terminalSession({ paneKey: null }),
text: 'x',
- missing: 'gone'
+ reasons
})
).toEqual({ kind: 'not-sent', reason: 'gone' })
expect(client.sendRequest.mock.calls.some(([method]) => method === 'terminal.send')).toBe(false)
@@ -86,8 +94,16 @@ describe('a "Needs you" reply into a terminal session', () => {
client: refusing,
session: terminalSession({}),
text: 'x',
- missing: 'gone'
+ reasons
})
- ).toMatchObject({ kind: 'not-sent' })
+ ).toEqual({ kind: 'not-sent', reason: 'refused' })
+ })
+
+ it('says the host is not connected instead of sending, and asks it nothing', async () => {
+ const client = new FakeSession('reconnecting')
+ expect(
+ await sendLocalTaskReply({ client, session: terminalSession({}), text: 'x', reasons })
+ ).toEqual({ kind: 'not-sent', reason: 'offline' })
+ expect(client.sendRequest).not.toHaveBeenCalled()
})
})
diff --git a/mobile/src/local-tasks/local-task-reply-send.ts b/mobile/src/local-tasks/local-task-reply-send.ts
index 45ffb2671f4e..7f22a58792c3 100644
--- a/mobile/src/local-tasks/local-task-reply-send.ts
+++ b/mobile/src/local-tasks/local-task-reply-send.ts
@@ -27,6 +27,19 @@ export type LocalTaskReplyOutcome =
/** Nothing reached the agent; the text goes back to the reply box. */
| { kind: 'not-sent'; reason: string | null }
+/** Why a reply did not go out, in the caller's words; each finishes "Not sent: …". */
+export type LocalTaskReplyReasons = {
+ /** The session's terminal is no longer listed, or a chat session has no id. */
+ missing: string
+ notConnected: string
+ /** Text left in the terminal's input could not be cleared, so nothing was typed after it. */
+ inputNotCleared: string
+ /** The host refused the typed text, as it does while the agent waits on a choice. */
+ inputRefused: string
+ /** The chat session gave no fence to send against. */
+ chatNotReady: string
+}
+
// Why its own reader: the session screen's inventory keeps only handles, a reply needs the pane.
const replyTerminalsRead = defineRpcOperation({
name: 'terminal.list-for-task-reply',
@@ -74,16 +87,16 @@ async function replyInTerminal(
client: RpcClient,
session: LocalTaskSessionLink,
text: string,
- missing: string
+ reasons: LocalTaskReplyReasons
): Promise {
let handle: string | null
try {
handle = await terminalHandleFor(client, session)
} catch (error) {
- return { kind: 'not-sent', reason: message(error) }
+ return { kind: 'not-sent', reason: message(error) ?? reasons.notConnected }
}
if (!handle) {
- return { kind: 'not-sent', reason: missing }
+ return { kind: 'not-sent', reason: reasons.missing }
}
const deadline = openMobileNativeChatSendBudget()
if (
@@ -94,7 +107,7 @@ async function replyInTerminal(
deadline
}))
) {
- return { kind: 'not-sent', reason: null }
+ return { kind: 'not-sent', reason: reasons.inputNotCleared }
}
const outcome = await sendMobileNativeChatMessageWithOutcome({
client,
@@ -107,13 +120,14 @@ async function replyInTerminal(
? { kind: 'delivered' }
: outcome === 'unknown'
? { kind: 'unconfirmed' }
- : { kind: 'not-sent', reason: null }
+ : { kind: 'not-sent', reason: reasons.inputRefused }
}
async function replyInChat(
client: RpcClient,
sessionId: string,
- text: string
+ text: string,
+ reasons: LocalTaskReplyReasons
): Promise {
let fence: number | undefined
try {
@@ -127,10 +141,10 @@ async function replyInChat(
)
fence = history.page.fence ?? (history.ok ? undefined : history.fence)
} catch (error) {
- return { kind: 'not-sent', reason: message(error) }
+ return { kind: 'not-sent', reason: message(error) ?? reasons.notConnected }
}
if (typeof fence !== 'number') {
- return { kind: 'not-sent', reason: null }
+ return { kind: 'not-sent', reason: reasons.chatNotReady }
}
let refusal: string | null = null
const outcome = await sendMobileStructuredAgentSessionMessage({
@@ -149,26 +163,26 @@ async function replyInChat(
? { kind: 'delivered' }
: outcome === 'unknown'
? { kind: 'unconfirmed' }
- : { kind: 'not-sent', reason: refusal }
+ : { kind: 'not-sent', reason: refusal ?? reasons.inputRefused }
}
/**
- * Sends one reply into a linked session on the library's host. `missing` is said when the session's
- * terminal is no longer listed. The caller checks contact first; a disconnected client sends nothing.
+ * Sends one reply into a linked session on the library's host; every refusal says why. The caller
+ * checks contact first; a disconnected client sends nothing.
*/
export async function sendLocalTaskReply(args: {
client: RpcClient | null
session: LocalTaskSessionLink
text: string
- missing: string
+ reasons: LocalTaskReplyReasons
}): Promise {
if (!args.client || args.client.getState() !== 'connected') {
- return { kind: 'not-sent', reason: null }
+ return { kind: 'not-sent', reason: args.reasons.notConnected }
}
if (args.session.surface === 'chat') {
return args.session.sessionId
- ? replyInChat(args.client, args.session.sessionId, args.text)
- : { kind: 'not-sent', reason: args.missing }
+ ? replyInChat(args.client, args.session.sessionId, args.text, args.reasons)
+ : { kind: 'not-sent', reason: args.reasons.missing }
}
- return replyInTerminal(args.client, args.session, args.text, args.missing)
+ return replyInTerminal(args.client, args.session, args.text, args.reasons)
}
diff --git a/mobile/src/local-tasks/local-task-repos-section.tsx b/mobile/src/local-tasks/local-task-repos-section.tsx
index 946edb6c52e9..964d02d12600 100644
--- a/mobile/src/local-tasks/local-task-repos-section.tsx
+++ b/mobile/src/local-tasks/local-task-repos-section.tsx
@@ -30,8 +30,10 @@ export function LocalTaskReposSection(props: {
const [menuFor, setMenuFor] = useState(null)
const [error, setError] = useState(null)
const choices = props.orcaProjects.state === 'ready' ? props.orcaProjects.projects : []
+ // Never the raw id: unread names and repositories removed from Orca each say so (REQ-017).
const nameOf = (repoId: string) =>
- choices.find((choice) => choice.id === repoId)?.displayName ?? repoId
+ choices.find((choice) => choice.id === repoId)?.displayName ??
+ (props.orcaProjects.state === 'ready' ? copy.repoNotInOrca : copy.repoNameUnread)
const addable = choices.filter((choice) => !project.repos.includes(choice.id))
const edit = async (kind: 'add' | 'remove' | 'default', repoId: string) => {
@@ -78,12 +80,21 @@ export function LocalTaskReposSection(props: {
) : (
{copy.noReposHint}
)}
- {props.readOnly || project.repos.length >= LOCAL_TASK_PROJECT_REPOS_MAX ? null : (
+ {props.readOnly ? null : project.repos.length >= LOCAL_TASK_PROJECT_REPOS_MAX ? (
+
+ {copy.reposAtLimit(LOCAL_TASK_PROJECT_REPOS_MAX)}
+
+ ) : (
setAdding(true)}
accessibilityRole="button"
+ accessibilityState={{ disabled: props.orcaProjects.state !== 'ready' }}
>
{copy.addRepo}
diff --git a/mobile/src/local-tasks/local-task-review-actions.tsx b/mobile/src/local-tasks/local-task-review-actions.tsx
new file mode 100644
index 000000000000..a26fbc3c8a16
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-review-actions.tsx
@@ -0,0 +1,173 @@
+import { useState } from 'react'
+import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { Check, CircleCheck, Undo2 } from 'lucide-react-native'
+import { parseLocalTaskProgressSections } from '../../../src/shared/local-tasks/local-task-progress-sections'
+import { localTaskNeedsReview } from '../../../src/shared/local-tasks/local-task-review-items'
+import { localTaskWritebackSection } from '../../../src/shared/local-tasks/local-task-writebacks'
+import type { LocalTask, LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+import type { LocalTasksApi } from './local-tasks-api'
+import type { LocalTasksCopy } from './local-tasks-copy'
+import type { LocalTaskSite } from './local-task-endpoint'
+import { localTaskWriteMessage } from './local-task-failure-message'
+import { createLocalTaskOperationIds, sendLocalTaskWrite } from './local-task-operation-ids'
+import { LocalTaskReplyDrawer } from './local-task-reply-drawer'
+import { sendLocalTaskReply } from './local-task-reply-send'
+import { localTasksPageCopy } from './local-tasks-page-copy'
+import { localTaskStyles } from './local-tasks-styles'
+import type { LocalTasksLibrary } from './use-local-tasks-library'
+
+export function LocalTaskReviewActions(props: {
+ task: LocalTask
+ project: LocalTaskProject | null
+ api: LocalTasksApi
+ site: LocalTaskSite
+ library: LocalTasksLibrary
+ copy: LocalTasksCopy
+ readOnly: boolean
+ /** The PR as its host has it, when read. */
+ detail?: string | null
+ /** The latest writeback, whose 自检 / 验收 lines the block repeats. */
+ latestBody?: string | null
+ onUpdateStatus: (statusId: string) => Promise
+ onError: (message: string | null) => void
+}) {
+ const [commentIds] = useState(() => createLocalTaskOperationIds())
+ const [rejecting, setRejecting] = useState(false)
+ const [reasonDraft, setReasonDraft] = useState('')
+ const [savedReason, setSavedReason] = useState(null)
+ const statusFor = (category: 'started' | 'completed') =>
+ props.project?.statuses.find((entry) => entry.category === category)?.id ?? null
+ const connected = props.site.connState === 'connected'
+ const reject = async () => {
+ const reason = reasonDraft.trim()
+ if (!reason || !connected) {
+ return
+ }
+ if (savedReason !== reason) {
+ const comment = await sendLocalTaskWrite(
+ commentIds,
+ { taskId: props.task.id, body: reason },
+ props.api.addComment
+ )
+ props.onError(localTaskWriteMessage(props.copy, comment))
+ if (comment.kind !== 'ok') {
+ return
+ }
+ setSavedReason(reason)
+ }
+ const started = statusFor('started')
+ if (!started || !(await props.onUpdateStatus(started))) {
+ return
+ }
+ setRejecting(false)
+ setReasonDraft('')
+ setSavedReason(null)
+ props.library.reload(['events', 'tasks'])
+ const session = props.library.work.links.sessions.find(
+ (entry) => entry.id === props.task.latestProgress?.sessionLinkId
+ )
+ if (!session) {
+ return
+ }
+ const work = props.copy.work
+ const outcome = await sendLocalTaskReply({
+ client: props.site.client,
+ session,
+ text: reason,
+ reasons: {
+ missing: work.replySessionMissing,
+ notConnected: work.replyNotConnected,
+ inputNotCleared: work.replyInputNotCleared,
+ inputRefused: work.replyInputRefused,
+ chatNotReady: work.replyChatNotReady
+ }
+ })
+ if (outcome.kind === 'not-sent') {
+ props.onError(work.replyNotSentBecause(outcome.reason ?? ''))
+ }
+ }
+ if (!localTaskNeedsReview(props.task, props.project ?? undefined)) {
+ return null
+ }
+ const acceptDisabled = props.readOnly || !connected || !statusFor('completed')
+ const rejectDisabled = props.readOnly || !connected
+ const page = localTasksPageCopy()
+ // Only facts the host or the agent's writeback actually gave (m7).
+ const sections = props.latestBody ? parseLocalTaskProgressSections(props.latestBody) : null
+ const facts = [
+ [page.mergedInto, props.detail ?? null],
+ [page.selfCheck, localTaskWritebackSection(sections, 'selfCheck')],
+ [page.whereToVerify, localTaskWritebackSection(sections, 'verify')]
+ ].flatMap(([label, text]) => (label && text ? [{ label, text }] : []))
+ return (
+ <>
+
+
+
+ {props.copy.work.card.awaitingAcceptance}
+
+ {facts.map((fact) => (
+
+ {fact.label}
+ {fact.text}
+
+ ))}
+
+ setRejecting(true)}
+ accessibilityRole="button"
+ >
+
+ {props.copy.rejectReview}
+
+ {
+ const completed = statusFor('completed')
+ if (completed) {
+ void props.onUpdateStatus(completed)
+ }
+ }}
+ accessibilityRole="button"
+ >
+
+ {props.copy.acceptReview}
+
+
+
+ void reject()}
+ onClose={() => setRejecting(false)}
+ />
+ >
+ )
+}
+
+const styles = StyleSheet.create({
+ block: { marginTop: spacing.md, padding: spacing.md, gap: spacing.md },
+ head: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ title: { color: colors.statusGreen, fontSize: typography.bodySize, fontWeight: '600' },
+ fact: { flexDirection: 'row', gap: spacing.md },
+ factLabel: { width: 72, paddingTop: 2 },
+ factText: { flex: 1 },
+ actions: { flexDirection: 'row', gap: spacing.sm },
+ action: { flex: 1 }
+})
diff --git a/mobile/src/local-tasks/local-task-row-session-states.test.ts b/mobile/src/local-tasks/local-task-row-session-states.test.ts
new file mode 100644
index 000000000000..94f259b8bc43
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-row-session-states.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from 'vitest'
+import { buildLocalTaskIndex } from '../../../src/shared/local-tasks/local-task-grouping'
+import type {
+ LocalTaskLinks,
+ LocalTaskProgressSnapshot,
+ LocalTaskSessionLink
+} from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { localTaskRowSessionStates } from './local-task-progress-projection'
+
+function task(id: string, parentId: string | null): LocalTask {
+ return {
+ id,
+ projectId: 'p',
+ number: Number(id.slice(1)),
+ ref: `SHOP-${id.slice(1)}`,
+ title: id,
+ description: '',
+ criteria: [],
+ statusId: 's',
+ priority: 0,
+ labels: [],
+ parentId,
+ createdAt: 0,
+ updatedAt: 0,
+ revision: 1
+ }
+}
+
+function session(id: string, taskId: string, openedAt: number): LocalTaskSessionLink {
+ return {
+ id,
+ taskId,
+ hostId: 'local',
+ worktreeId: 'shop::/w/shop-12',
+ instanceId: null,
+ surface: 'terminal',
+ tabId: id,
+ paneKey: null,
+ sessionId: null,
+ providerSession: null,
+ agent: 'claude',
+ linkedVia: 'start',
+ openedAt
+ }
+}
+
+// SHOP-12 has the workspace; SHOP-13 works in it, SHOP-14 has a workspace of its own.
+const tasks = [task('t12', null), task('t13', 't12'), task('t14', 't12')]
+const links: LocalTaskLinks = {
+ workspaces: [
+ {
+ taskId: 't12',
+ hostId: 'local',
+ kind: 'git-worktree',
+ instanceId: null,
+ worktreeId: 'shop::/w/shop-12',
+ folderWorkspaceId: null,
+ repoId: 'shop',
+ branchSnapshot: null,
+ nameSnapshot: 'shop-12',
+ hostLabelSnapshot: null,
+ linkedVia: 'start',
+ linkedAt: 1
+ },
+ {
+ taskId: 't14',
+ hostId: 'local',
+ kind: 'git-worktree',
+ instanceId: null,
+ worktreeId: 'shop::/w/shop-14',
+ folderWorkspaceId: null,
+ repoId: 'shop',
+ branchSnapshot: null,
+ nameSnapshot: 'shop-14',
+ hostLabelSnapshot: null,
+ linkedVia: 'start',
+ linkedAt: 2
+ }
+ ],
+ sessions: [session('a', 't12', 1), session('b', 't13', 2), session('c', 't14', 3)]
+}
+const snapshot: LocalTaskProgressSnapshot = {
+ observedAt: 10,
+ sessions: [
+ { taskSessionId: 'a', state: 'working', lastMessage: null, stateSince: 1, unread: false },
+ { taskSessionId: 'b', state: 'waiting', lastMessage: null, stateSince: 2, unread: false },
+ { taskSessionId: 'c', state: 'done', lastMessage: null, stateSince: 3, unread: true }
+ ],
+ items: []
+}
+const index = buildLocalTaskIndex([], tasks)
+function statesOf(taskId: string, inContact = true) {
+ const row = index.tasksById.get(taskId)
+ if (!row) {
+ throw new Error(`no task ${taskId}`)
+ }
+ return localTaskRowSessionStates({ task: row, index, links, snapshot, inContact }).map(
+ (entry) => entry.state
+ )
+}
+
+describe('the session dots of a list row', () => {
+ it('shows every session in its own workspace, not those of a subtask with its own', () => {
+ expect(statesOf('t12')).toEqual(['working', 'waiting'])
+ expect(statesOf('t14')).toEqual(['done'])
+ })
+
+ it('shows only the sessions a task opened when it works in its parent workspace', () => {
+ expect(statesOf('t13')).toEqual(['waiting'])
+ })
+
+ it('claims no state out of contact, keeping the dots', () => {
+ expect(statesOf('t12', false)).toEqual(['unverifiable', 'unverifiable'])
+ })
+})
diff --git a/mobile/src/local-tasks/local-task-rules-section.test.tsx b/mobile/src/local-tasks/local-task-rules-section.test.tsx
new file mode 100644
index 000000000000..5dd11d0ac546
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-rules-section.test.tsx
@@ -0,0 +1,59 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { expect, it, vi } from 'vitest'
+import type { LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
+import { createLocalTasksApi, type LocalTasksApi } from './local-tasks-api'
+import { LocalTaskRulesSection } from './local-task-rules-section'
+
+vi.mock('react-native', () => ({
+ Pressable: 'Pressable',
+ Text: 'Text',
+ View: 'View',
+ StyleSheet: { create: (styles: T) => styles }
+}))
+vi.mock('../components/MobileSwitch', () => ({ MobileSwitch: 'MobileSwitch' }))
+vi.mock('../components/PickerModal', () => ({ PickerModal: 'PickerModal' }))
+
+const base: LocalTaskProject = {
+ id: 'p',
+ name: 'Project',
+ prefix: 'P',
+ aliases: [],
+ repos: [],
+ defaultRepoId: null,
+ lastExecutionHostId: null,
+ rules: { startMovesToInProgress: true, prMergedCompletes: false },
+ statuses: [],
+ createdAt: 0,
+ updatedAt: 0,
+ revision: 0
+}
+
+it('keeps the legacy PR switch when the host did not send prMergedMode', async () => {
+ const updateProject = vi.fn().mockResolvedValue({ kind: 'ok', value: { project: base } })
+ const api: LocalTasksApi = {
+ ...createLocalTasksApi({ sendRequest: async () => ({ id: 'test', ok: true, result: {} }) }),
+ updateProject
+ }
+ let renderer!: ReactTestRenderer
+ await act(async () => {
+ renderer = create(
+ createElement(LocalTaskRulesSection, {
+ project: base,
+ api,
+ readOnly: false,
+ onChanged: vi.fn()
+ })
+ )
+ })
+ const switchControl = renderer.root
+ .findAll((node) => String(node.type) === 'MobileSwitch')
+ .find((node) => node.props.testID === 'local-task-rule-prMergedCompletes')
+ expect(switchControl).toBeDefined()
+ expect(renderer.root.find((node) => String(node.type) === 'PickerModal').props.visible).toBe(
+ false
+ )
+ await act(async () => switchControl?.props.onValueChange(true))
+ expect(updateProject.mock.calls[0]?.[0].rules).toEqual({ prMergedCompletes: true })
+ act(() => renderer.unmount())
+})
diff --git a/mobile/src/local-tasks/local-task-rules-section.tsx b/mobile/src/local-tasks/local-task-rules-section.tsx
index 07bab534a0a1..5eaf9ffc674e 100644
--- a/mobile/src/local-tasks/local-task-rules-section.tsx
+++ b/mobile/src/local-tasks/local-task-rules-section.tsx
@@ -1,10 +1,11 @@
import { useState } from 'react'
-import { StyleSheet, Text, View } from 'react-native'
+import { Pressable, StyleSheet, Text, View } from 'react-native'
import type {
LocalTaskProject,
LocalTaskProjectRules
} from '../../../src/shared/local-tasks/local-task-types'
import { MobileSwitch } from '../components/MobileSwitch'
+import { PickerModal } from '../components/PickerModal'
import { spacing } from '../theme/mobile-theme'
import type { LocalTasksApi } from './local-tasks-api'
import { localTasksCopy } from './local-tasks-copy'
@@ -12,9 +13,7 @@ import { localTaskWriteMessage } from './local-task-failure-message'
import { createLocalTaskOperationIds, sendLocalTaskWrite } from './local-task-operation-ids'
import { localTaskStyles } from './local-tasks-styles'
-type RuleKey = keyof LocalTaskProjectRules
-
-/** The project's two automatic rules, each its own switch and its own write. */
+/** Project automation rules, saved independently. */
export function LocalTaskRulesSection(props: {
project: LocalTaskProject
api: LocalTasksApi
@@ -25,45 +24,82 @@ export function LocalTaskRulesSection(props: {
const work = copy.work
const [ids] = useState(() => ({
startMovesToInProgress: createLocalTaskOperationIds(),
- prMergedCompletes: createLocalTaskOperationIds()
+ prMergedCompletes: createLocalTaskOperationIds(),
+ prMergedMode: createLocalTaskOperationIds()
}))
const [error, setError] = useState(null)
- const set = async (rule: RuleKey, value: boolean) => {
+ const [modePicker, setModePicker] = useState(false)
+ const set = async (rules: Partial, idsKey: keyof typeof ids) => {
const outcome = await sendLocalTaskWrite(
- ids[rule],
- { projectId: props.project.id, rules: { [rule]: value } },
+ ids[idsKey],
+ { projectId: props.project.id, rules },
props.api.updateProject
)
setError(localTaskWriteMessage(copy, outcome))
props.onChanged()
}
- const rows: { rule: RuleKey; label: string; hint: string }[] = [
- { rule: 'startMovesToInProgress', label: work.ruleStart, hint: work.ruleStartHint },
- { rule: 'prMergedCompletes', label: work.rulePrMerged, hint: work.rulePrMergedHint }
- ]
+ const mergedMode = props.project.rules.prMergedMode
return (
<>
{work.rulesSection}
- {rows.map((row, index) => (
-
+
+
+ {work.ruleStart}
+ {work.ruleStartHint}
+
+
+ void set({ startMovesToInProgress: value }, 'startMovesToInProgress')
+ }
+ />
+
+ {mergedMode === undefined ? (
+
- {row.label}
- {row.hint}
+ {work.rulePrMerged}
+ {work.rulePrMergedHint}
void set(row.rule, value)}
+ accessibilityLabel={work.rulePrMerged}
+ onValueChange={(value) => void set({ prMergedCompletes: value }, 'prMergedCompletes')}
/>
- ))}
+ ) : (
+ setModePicker(true)}
+ disabled={props.readOnly}
+ accessibilityRole="button"
+ >
+
+ {copy.prMergedRule}
+ {copy.prMergedModes[mergedMode]}
+
+
+ )}
+ ({
+ value: mode,
+ label: copy.prMergedModes[mode]
+ }))}
+ onSelect={(mode) => {
+ setModePicker(false)
+ void set({ prMergedMode: mode }, 'prMergedMode')
+ }}
+ onClose={() => setModePicker(false)}
+ />
{error ? {error} : null}
>
)
diff --git a/mobile/src/local-tasks/local-task-session-dots.tsx b/mobile/src/local-tasks/local-task-session-dots.tsx
new file mode 100644
index 000000000000..0573f8075fb5
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-session-dots.tsx
@@ -0,0 +1,50 @@
+import { StyleSheet, Text, View } from 'react-native'
+import type { LocalTaskSessionState } from '../../../src/shared/local-tasks/local-task-link-types'
+import { AgentStateDot } from '../components/AgentStateDot'
+import type { AgentDotState } from '../worktree/agent-row-display'
+import { localTasksCopy } from './local-tasks-copy'
+import type { LocalTaskRowSession } from './local-task-progress-projection'
+import { localTaskStyles } from './local-tasks-styles'
+
+/** A linked session's state as the app's agent dot draws it. */
+export const LOCAL_TASK_SESSION_DOT: Record =
+ {
+ working: 'working',
+ waiting: 'waiting',
+ blocked: 'blocked',
+ done: 'done',
+ idle: 'idle',
+ exited: 'interrupted',
+ missing: 'unconfirmed',
+ unknown: 'idle',
+ unverifiable: 'unverifiable'
+ }
+
+// As many as the desktop row shows, the newest; the rest are counted.
+const SHOWN = 3
+
+/** A row's session dots (REQ-012), read aloud as their states. None for a task with no session. */
+export function LocalTaskSessionDots(props: { sessions: readonly LocalTaskRowSession[] }) {
+ if (props.sessions.length === 0) {
+ return null
+ }
+ const states = localTasksCopy().work.sessionStates
+ return (
+ states[session.state]).join(', ')}
+ >
+ {props.sessions.slice(-SHOWN).map((session) => (
+
+ ))}
+ {props.sessions.length > SHOWN ? (
+ +{props.sessions.length - SHOWN}
+ ) : null}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ dots: { flexDirection: 'row', alignItems: 'center', gap: 2, flexShrink: 0 }
+})
diff --git a/mobile/src/local-tasks/local-task-session-drawer-style.test.ts b/mobile/src/local-tasks/local-task-session-drawer-style.test.ts
new file mode 100644
index 000000000000..d681e531a1a0
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-session-drawer-style.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it, vi } from 'vitest'
+
+const platform = vi.hoisted(() => ({ os: 'web' }))
+vi.mock('react-native', () => ({
+ Platform: {
+ get OS() {
+ return platform.os
+ },
+ select: (options: Record) => options[platform.os]
+ },
+ StyleSheet: { create: (styles: T) => styles, absoluteFillObject: {} }
+}))
+
+describe('full-height task chat drawer clipping', () => {
+ it.each([
+ ['web', 'clip'],
+ ['ios', 'hidden'],
+ ['android', 'hidden']
+ ])('uses %s overflow value %s', async (os, expected) => {
+ platform.os = os
+ vi.resetModules()
+ const { bottomDrawerStyles } = await import('../components/bottom-drawer/bottom-drawer-styles')
+ expect(bottomDrawerStyles.drawerFill.overflow).toBe(expected)
+ })
+})
diff --git a/mobile/src/local-tasks/local-task-session-drawer.tsx b/mobile/src/local-tasks/local-task-session-drawer.tsx
new file mode 100644
index 000000000000..97cffe557c05
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-session-drawer.tsx
@@ -0,0 +1,214 @@
+import { themedStyleSheet } from '../theme/themed-styles'
+import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
+import { Pressable, Text, View } from 'react-native'
+import { ArrowUpRight, ListTodo, MessageSquare } from 'lucide-react-native'
+import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label'
+import type { LocalTaskSessionLink } from '../../../src/shared/local-tasks/local-task-link-types'
+import { AgentStateDot } from '../components/AgentStateDot'
+import { BottomDrawer } from '../components/bottom-drawer/BottomDrawer'
+import { useMobileTranslation } from '../i18n/use-mobile-translation'
+import { MobileSessionActiveContent } from '../session/MobileSessionActiveContent'
+import { resolveMobileNativeChatSessionId } from '../session/native-chat/mobile-native-chat-eligibility'
+import { useMobileSessionController } from '../session/use-mobile-session-controller'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import type { LocalTaskSessionSummary } from './local-task-card-facts'
+import { localTasksCopy } from './local-tasks-copy'
+import { LOCAL_TASK_SESSION_DOT } from './local-task-session-dots'
+import { localTaskSessionPaneKey } from './local-task-work-navigation'
+import type { LocalTasksApi } from './local-tasks-api'
+import { useAcknowledgeOpenSession } from './use-local-task-session-acknowledge'
+import type { LocalTasksLibrary } from './use-local-tasks-library'
+
+/** Who the session is and how it stands, the task it works on, and where it runs. */
+function DrawerHead(props: { summary: LocalTaskSessionSummary; now: number }) {
+ const copy = localTasksCopy()
+ const work = copy.work
+ const { summary } = props
+ const stateLabel = summary.item
+ ? work.attentionKinds[summary.item.kind]
+ : work.sessionStates[summary.state]
+ const duration =
+ summary.state === 'working' && summary.stateSince !== null
+ ? work.card.duration(props.now - summary.stateSince)
+ : null
+ return (
+
+
+
+ {formatAgentTypeLabel(summary.link.agent)}
+
+ {[stateLabel, duration].filter(Boolean).join(' · ')}
+
+
+ {summary.task ? (
+
+
+
+ {summary.task.title}
+
+ {summary.task.ref}
+
+ ) : null}
+
+
+
+ {[work.surfaces[summary.link.surface], summary.workspaceName, summary.hostName]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+
+ )
+}
+
+function ChatContent(props: {
+ hostId: string
+ worktreeId: string
+ session: LocalTaskSessionLink
+ api: LocalTasksApi
+ library: Pick
+ summary: LocalTaskSessionSummary | null
+ now: number
+ onOpenFull?: () => void
+}) {
+ useMobileColorScheme()
+ const t = useMobileTranslation()
+ const controller = useMobileSessionController({
+ hostId: props.hostId,
+ worktreeId: props.worktreeId,
+ paneKey: localTaskSessionPaneKey(props.session)
+ })
+ const activeTab = controller.activeSessionTab
+ const matchesSession =
+ activeTab !== null &&
+ ((props.session.sessionId !== null &&
+ resolveMobileNativeChatSessionId(activeTab) === props.session.sessionId) ||
+ (props.session.sessionId === null &&
+ props.session.tabId !== null &&
+ activeTab.id === props.session.tabId))
+ const visibleSessionId =
+ props.library.live &&
+ controller.connState === 'connected' &&
+ controller.nativeChatController.sessionViewReady &&
+ controller.nativeChatController.showNativeChat &&
+ matchesSession &&
+ !controller.showLoadingState &&
+ !controller.showEmptyState &&
+ !controller.showOfflineState
+ ? props.session.id
+ : null
+ useAcknowledgeOpenSession(
+ controller.client,
+ visibleSessionId,
+ props.library.work.progress,
+ props.api
+ )
+ return (
+
+ {props.summary ? (
+
+ ) : (
+
+ {props.session.agent ?? t('session.sheets.chat', 'Chat')}
+
+ )}
+
+ {props.onOpenFull ? (
+ [styles.openFull, pressed && styles.pressed]}
+ onPress={props.onOpenFull}
+ accessibilityRole="button"
+ testID="local-task-session-open-full"
+ >
+
+ {localTasksCopy().work.card.openFullSession}
+
+ ) : null}
+
+ )
+}
+
+/** A chat session answered in place (Q2); terminal sessions open their own page instead. */
+export function LocalTaskSessionDrawer(props: {
+ hostId: string
+ worktreeId: string | null
+ session: LocalTaskSessionLink | null
+ api: LocalTasksApi
+ library: Pick
+ /** The drawer's head; without one it names only the agent. */
+ summary?: LocalTaskSessionSummary | null
+ now?: number
+ /** Opens the session's own page; the drawer closes behind it. */
+ onOpenFull?: () => void
+ onClose: () => void
+}) {
+ const session = props.session
+ return (
+
+ {session && props.worktreeId ? (
+
+ ) : null}
+
+ )
+}
+
+const styles = themedStyleSheet(() => ({
+ content: { flex: 1, minHeight: 0, backgroundColor: colors.bgBase },
+ heading: {
+ color: colors.textPrimary,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm
+ },
+ head: {
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ gap: 4,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderSubtle
+ },
+ headLine: { flexDirection: 'row' as const, alignItems: 'center' as const, gap: 6 },
+ agent: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' as const },
+ state: { flexShrink: 1, color: colors.textSecondary, fontSize: typography.metaSize },
+ attention: { color: colors.statusAmber },
+ meta: { flexShrink: 1, color: colors.textSecondary, fontSize: typography.metaSize },
+ ref: {
+ color: colors.textMuted,
+ fontFamily: typography.monoFamily,
+ fontSize: typography.metaSize
+ },
+ openFull: {
+ flexDirection: 'row' as const,
+ alignItems: 'center' as const,
+ justifyContent: 'center' as const,
+ gap: 6,
+ marginHorizontal: spacing.lg,
+ marginVertical: spacing.sm,
+ paddingVertical: spacing.md,
+ borderRadius: radii.surface,
+ backgroundColor: colors.bgRaised
+ },
+ openFullText: {
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ fontWeight: '600' as const
+ },
+ pressed: { opacity: 0.7 }
+}))
diff --git a/mobile/src/local-tasks/local-task-session-open.ts b/mobile/src/local-tasks/local-task-session-open.ts
new file mode 100644
index 000000000000..5fa2188336bd
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-session-open.ts
@@ -0,0 +1,59 @@
+import type { LocalTaskSessionLink } from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTasksApi } from './local-tasks-api'
+import type { LocalTasksCopy } from './local-tasks-copy'
+import type { LocalTaskSite } from './local-task-endpoint'
+import { localTaskWorkspaceLinkFor } from './local-task-progress-projection'
+import { localTaskSessionHref, localTaskWorkspaceRouteId } from './local-task-work-navigation'
+import type { LocalTasksLibrary } from './use-local-tasks-library'
+
+/**
+ * Where a chat session previews in the drawer, or null: a terminal session opens its session page
+ * instead (Q2), and so does a chat whose workspace the phone cannot route to.
+ */
+export function localTaskChatPreviewTarget(
+ library: Pick,
+ taskSessionId: string
+): { session: LocalTaskSessionLink; worktreeId: string } | null {
+ const session = library.work.links.sessions.find((entry) => entry.id === taskSessionId)
+ if (session?.surface !== 'chat') {
+ return null
+ }
+ const tasksById = new Map(library.tasks.map((task) => [task.id, task]))
+ const owner = tasksById.get(session.taskId)
+ const workspace = owner ? localTaskWorkspaceLinkFor(owner, tasksById, library.work.links) : null
+ const worktreeId = session.worktreeId ?? (workspace ? localTaskWorkspaceRouteId(workspace) : null)
+ return worktreeId ? { session, worktreeId } : null
+}
+
+/**
+ * Opens a linked session's screen in the workspace it ran in, or says why it can't (it is no
+ * longer recorded, or an older Orca recorded it where the phone has no route). The reason is shown
+ * where the user asked, so a tap is never silent.
+ */
+export function openLinkedLocalTaskSession(args: {
+ site: Pick
+ api: LocalTasksApi
+ library: Pick
+ copy: LocalTasksCopy
+ taskSessionId: string
+ onNavigate: (href: string) => void
+}): string | null {
+ const { library } = args
+ const session = library.work.links.sessions.find((entry) => entry.id === args.taskSessionId)
+ if (!session) {
+ return args.copy.work.sessionNoLongerRecorded
+ }
+ const tasksById = new Map(library.tasks.map((task) => [task.id, task]))
+ const owner = tasksById.get(session.taskId)
+ const href = localTaskSessionHref({
+ hostId: args.site.hostId,
+ endpoint: args.site.endpoint,
+ session,
+ workspace: owner ? localTaskWorkspaceLinkFor(owner, tasksById, library.work.links) : null
+ })
+ if (!href) {
+ return args.copy.work.cannotOpenHere
+ }
+ args.onNavigate(href)
+ return null
+}
diff --git a/mobile/src/local-tasks/local-task-source-option.test.tsx b/mobile/src/local-tasks/local-task-source-option.test.tsx
index fc3c11faeb16..a74276423acb 100644
--- a/mobile/src/local-tasks/local-task-source-option.test.tsx
+++ b/mobile/src/local-tasks/local-task-source-option.test.tsx
@@ -118,7 +118,9 @@ describe("the Task Source picker's Local row", () => {
env.client = away.client
env.state = 'reconnecting'
const options = await sourceOptions()
- expect(options.map((option) => option.disabled)).toEqual([true])
+ expect(options.map(({ disabled, subtitle }) => ({ disabled, subtitle }))).toEqual([
+ { disabled: true, subtitle: 'This host’s Orca is not connected' }
+ ])
expect(away.calls).toEqual([])
})
diff --git a/mobile/src/local-tasks/local-task-source-option.tsx b/mobile/src/local-tasks/local-task-source-option.tsx
index a0369ed47107..02cdb61d9a06 100644
--- a/mobile/src/local-tasks/local-task-source-option.tsx
+++ b/mobile/src/local-tasks/local-task-source-option.tsx
@@ -42,7 +42,8 @@ export function useLocalTaskSourceOptions(page: {
{
value: LOCAL_TASK_SOURCE_OPTION,
label: copy.sourceLabel,
- subtitle: copy.sourceSubtitle,
+ // A dimmed row says why, as the picker's other disabled rows do.
+ subtitle: connected ? copy.sourceSubtitle : copy.desktopDisconnected(''),
disabled: !connected,
renderIcon: (selected: boolean) => (
diff --git a/mobile/src/local-tasks/local-task-view-memory.test.ts b/mobile/src/local-tasks/local-task-view-memory.test.ts
new file mode 100644
index 000000000000..7066a9de2c43
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-view-memory.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@react-native-async-storage/async-storage', () => ({ default: {} }))
+vi.mock('../storage/mirrored-storage-keys', () => ({ persistMirrored: vi.fn() }))
+
+import { isPageStorageKey } from '../mobile-web-shell/page-storage-keys'
+import {
+ LOCAL_TASK_VIEW_STORAGE_KEY,
+ readLocalTaskViewChoices,
+ writeLocalTaskViewChoice,
+ type LocalTaskViewChoice
+} from './local-task-view-memory'
+
+const board: LocalTaskViewChoice = { projectId: 'p1', segment: 'started', view: 'board' }
+
+describe("the task page's remembered view", () => {
+ it('keeps each library apart, and is a key the hybrid page may read and write', () => {
+ const one = writeLocalTaskViewChoice(new Map(), 'host-a\u0000self', board)
+ const both = writeLocalTaskViewChoice(readLocalTaskViewChoices(one), 'host-a\u0000runtime:vm', {
+ projectId: null,
+ segment: 'all',
+ view: 'list'
+ })
+ const read = readLocalTaskViewChoices(both)
+ expect(read.get('host-a\u0000self')).toEqual(board)
+ expect(read.get('host-a\u0000runtime:vm')?.projectId).toBeNull()
+ expect(isPageStorageKey(LOCAL_TASK_VIEW_STORAGE_KEY)).toBe(true)
+ })
+
+ it('drops what it cannot read, entry by entry, rather than the whole store', () => {
+ const raw = JSON.stringify({ good: board, bad: { segment: 'someday' } })
+ expect([...readLocalTaskViewChoices(raw).keys()]).toEqual(['good'])
+ expect(readLocalTaskViewChoices('not json').size).toBe(0)
+ })
+
+ it('forgets the libraries left longest ago, so the value stays small', () => {
+ let raw: string | null = null
+ for (let at = 0; at < 30; at += 1) {
+ raw = writeLocalTaskViewChoice(readLocalTaskViewChoices(raw), `library-${at}`, board)
+ }
+ const kept = readLocalTaskViewChoices(raw)
+ expect(kept.size).toBe(24)
+ expect(kept.has('library-0')).toBe(false)
+ expect(kept.has('library-29')).toBe(true)
+ })
+})
diff --git a/mobile/src/local-tasks/local-task-view-memory.ts b/mobile/src/local-tasks/local-task-view-memory.ts
new file mode 100644
index 000000000000..b688ef163c8f
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-view-memory.ts
@@ -0,0 +1,119 @@
+import { useEffect, useRef, useState } from 'react'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import { z } from 'zod'
+import { persistMirrored } from '../storage/mirrored-storage-keys'
+
+/**
+ * One key for every library, so the hybrid page is handed it by name (page-storage-keys.ts): the
+ * phone keeps nothing per host that the page would have to be told about one by one.
+ */
+export const LOCAL_TASK_VIEW_STORAGE_KEY = 'orca:localTasksView'
+
+// Why capped: the page is handed the whole value, which has a size limit; the oldest go first.
+const REMEMBERED_LIBRARIES = 24
+
+export type LocalTaskViewChoice = {
+ /** Null is All projects. */
+ projectId: string | null
+ segment: 'attention' | 'started' | 'all'
+ view: 'list' | 'board'
+}
+
+const choiceSchema = z.object({
+ projectId: z.string().nullable(),
+ segment: z.enum(['attention', 'started', 'all']),
+ view: z.enum(['list', 'board'])
+})
+
+// Insertion order is recency: a write moves its library last.
+const storedSchema = z.record(z.string(), z.unknown())
+
+/** The libraries' choices as stored, dropping anything this build cannot read. */
+export function readLocalTaskViewChoices(raw: string | null): Map {
+ const choices = new Map()
+ if (!raw) {
+ return choices
+ }
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(raw)
+ } catch {
+ return choices
+ }
+ const stored = storedSchema.safeParse(parsed)
+ if (!stored.success) {
+ return choices
+ }
+ for (const [library, value] of Object.entries(stored.data)) {
+ const choice = choiceSchema.safeParse(value)
+ if (choice.success) {
+ choices.set(library, choice.data)
+ }
+ }
+ return choices
+}
+
+export function writeLocalTaskViewChoice(
+ choices: ReadonlyMap,
+ library: string,
+ choice: LocalTaskViewChoice
+): string {
+ const next = new Map(choices)
+ next.delete(library)
+ next.set(library, choice)
+ const kept = [...next].slice(-REMEMBERED_LIBRARIES)
+ return JSON.stringify(Object.fromEntries(kept))
+}
+
+let pending: Promise = Promise.resolve()
+
+/**
+ * The project, segment and layout one library's page was left on (keyed by paired host and
+ * library), read once when the page opens. `override` wins: a route into "Needs you" opens there.
+ * Until the store answers the page shows `initial`; a choice the user makes first is kept.
+ */
+export function useLocalTaskViewMemory(
+ library: string,
+ initial: LocalTaskViewChoice,
+ override: Partial | null
+): [LocalTaskViewChoice, (patch: Partial) => void] {
+ const [choice, setChoice] = useState(() => ({ ...initial, ...override }))
+ const touched = useRef(false)
+ const latest = useRef(choice)
+ useEffect(() => {
+ let current = true
+ void pending
+ .then(() => AsyncStorage.getItem(LOCAL_TASK_VIEW_STORAGE_KEY))
+ .catch(() => null)
+ .then((raw) => {
+ const stored = readLocalTaskViewChoices(raw).get(library)
+ if (current && stored && !touched.current) {
+ const next = { ...stored, ...override }
+ latest.current = next
+ setChoice(next)
+ }
+ })
+ return () => {
+ current = false
+ }
+ // Why once per library: the override is the route's, read when the page opened.
+ }, [library])
+ const update = (patch: Partial) => {
+ touched.current = true
+ const next = { ...latest.current, ...patch }
+ latest.current = next
+ setChoice(next)
+ // Through the mirrored write: the page is handed this key on every `init` (ruling 35).
+ pending = pending
+ .then(() => AsyncStorage.getItem(LOCAL_TASK_VIEW_STORAGE_KEY))
+ .catch(() => null)
+ .then((raw) =>
+ persistMirrored(
+ LOCAL_TASK_VIEW_STORAGE_KEY,
+ writeLocalTaskViewChoice(readLocalTaskViewChoices(raw), library, next)
+ )
+ )
+ .catch(() => {})
+ }
+ return [choice, update]
+}
diff --git a/mobile/src/local-tasks/local-task-work-section.tsx b/mobile/src/local-tasks/local-task-work-section.tsx
index 6dd3959a3e5a..08827b3e6173 100644
--- a/mobile/src/local-tasks/local-task-work-section.tsx
+++ b/mobile/src/local-tasks/local-task-work-section.tsx
@@ -1,13 +1,12 @@
-import { useState } from 'react'
-import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { useRef, useState, type ReactNode } from 'react'
+import { Text } from 'react-native'
+import { FolderOpen, Link2, Play, RotateCcw, Unlink } from 'lucide-react-native'
import { suggestLocalTaskWorkspaceName } from '../../../src/shared/local-tasks/local-task-model'
-import type { LocalTaskSessionState } from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTaskSessionLink } from '../../../src/shared/local-tasks/local-task-link-types'
+import { localTaskLinkWorkspaceId } from '../../../src/shared/local-tasks/local-task-workspace-match'
import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
-import { AgentStateDot } from '../components/AgentStateDot'
+import type { ActionSheetAction } from '../components/ActionSheetModal'
import { ConfirmModal } from '../components/ConfirmModal'
-import { MOBILE_TUI_AGENT_LABELS, isMobileTuiAgent } from '../tasks/mobile-tui-agents'
-import { spacing } from '../theme/mobile-theme'
-import type { AgentDotState } from '../worktree/agent-row-display'
import type { LocalTasksApi } from './local-tasks-api'
import { localTasksCopy } from './local-tasks-copy'
import { localTaskListedHostId, type LocalTaskSite } from './local-task-endpoint'
@@ -18,59 +17,50 @@ import {
localTaskHostLabel,
localTaskSetupLabel
} from './local-task-launch-sheet'
-import { createLocalTaskOperationIds, sendLocalTaskWrite } from './local-task-operation-ids'
-import { localTaskSessionViews, localTaskWorkspaceLinkFor } from './local-task-progress-projection'
+import {
+ createLocalTaskOperationIds,
+ sendLocalTaskWrite,
+ type LocalTaskOperationIds
+} from './local-task-operation-ids'
+import { localTaskWorkspaceLinkFor } from './local-task-progress-projection'
import { localTaskSetupsFor, pickLocalTaskStartSetup } from './local-task-project-setups'
import { localTaskWorkspaceHref } from './local-task-work-navigation'
import { LocalTaskWorkspaceLinkSheet } from './local-task-workspace-link-sheet'
+import { localTaskWorkspaceVerdict } from './local-task-workspace-row'
import { localTaskStyles } from './local-tasks-styles'
import { useLocalTaskHostLabels } from './use-local-task-host-labels'
import { useLocalTaskLaunch } from './use-local-task-launch'
import { useLocalTaskProjectCatalog } from './use-local-task-project-catalog'
+import { useLocalTaskWorkspaceListing } from './use-local-task-workspace-listing'
import type { LocalTasksLibrary } from './use-local-tasks-library'
-const DOT_STATE: Record = {
- working: 'working',
- waiting: 'waiting',
- blocked: 'blocked',
- done: 'done',
- idle: 'idle',
- exited: 'interrupted',
- missing: 'unconfirmed',
- unknown: 'idle',
- unverifiable: 'unverifiable'
-}
-
-function agentLabel(agent: string | null): string {
- return agent && isMobileTuiAgent(agent) ? MOBILE_TUI_AGENT_LABELS[agent] : (agent ?? '')
-}
-
-function SmallButton(props: { label: string; disabled?: boolean; onPress: () => void }) {
- return (
-
- {props.label}
-
- )
+export type LocalTaskWorkControls = {
+ /** The head's main button (m4): a new session in the task's workspace, else Start work. */
+ primary: { label: string; disabled: boolean; onPress: () => void } | null
+ /** Everything else the task's workspace offers, for the "…" menu. */
+ menuActions: ActionSheetAction[]
+ /** Why a button is off, what is starting, what failed: said under the head. */
+ notices: ReactNode
+ /** Sheets and confirmations the controls open. */
+ sheets: ReactNode
+ requestUnlinkSession: ((session: LocalTaskSessionLink) => void) | null
}
/**
- * The task's workspace and sessions, with Start, Open a session, link and unlink. Any level may
- * start its own workspace; one without works in the nearest ancestor's (REQ-009).
+ * The task's workspace and sessions as head controls: Start work, New session, link, open and
+ * unlink. Any level may start its own workspace; one without works in the nearest ancestor's
+ * (REQ-009). Every control that is off says why under the head: a host out of contact, a catalog
+ * not published, a library that is read-only (the page's notice). Contact is read when the user
+ * asks, never polled.
*/
-export function LocalTaskWorkSection(props: {
+export function useLocalTaskWorkControls(props: {
task: LocalTask
library: LocalTasksLibrary
api: LocalTasksApi
site: LocalTaskSite
canWrite: boolean
- onOpenSession: (taskSessionId: string) => void
onNavigate: (href: string) => void
-}) {
+}): LocalTaskWorkControls {
const copy = localTasksCopy()
const work = copy.work
const { library, task, site } = props
@@ -79,7 +69,8 @@ export function LocalTaskWorkSection(props: {
const workspace = ownWorkspace ?? localTaskWorkspaceLinkFor(task, tasksById, library.work.links)
const inheritedFrom = workspace && !ownWorkspace ? tasksById.get(workspace.taskId) : undefined
const project = library.projects.find((entry) => entry.id === task.projectId) ?? null
- const catalog = useLocalTaskProjectCatalog(ownWorkspace ? null : site.pairedClient)
+ // Read with a linked workspace too: Start work again needs the setups once it is deleted.
+ const catalog = useLocalTaskProjectCatalog(site.pairedClient)
const setups =
catalog.state === 'ready' && project
? localTaskSetupsFor(catalog.catalog, site.endpoint, project.repos)
@@ -88,6 +79,33 @@ export function LocalTaskWorkSection(props: {
const actionHostId = workspace?.hostId ?? startSetup?.ownerHostId ?? null
const hostContact = useLocalTaskHostContact(site.client, site.endpoint, actionHostId)
const hostOffline = hostContact.contact.kind === 'out-of-contact'
+ // The library's own host gone (an SSH host's Orca): its reads fail because it is not connected.
+ const libraryOffline = site.connState !== 'connected'
+ const listing = useLocalTaskWorkspaceListing({
+ client: site.client,
+ key: site.endpoint.executionHost ?? site.hostId,
+ subject: workspace
+ ? [
+ workspace.taskId,
+ workspace.hostId,
+ localTaskLinkWorkspaceId(workspace),
+ workspace.linkedAt
+ ]
+ .map(String)
+ .join('\u0000')
+ : null,
+ open: workspace !== null && site.connState === 'connected'
+ })
+ const verdict =
+ workspace && listing.status === 'ready'
+ ? localTaskWorkspaceVerdict(
+ workspace,
+ listing.listing,
+ hostContact.contact.kind === 'in-contact'
+ )
+ : null
+ // Only the task's own: an ancestor's deleted workspace is restarted from the ancestor.
+ const gone = ownWorkspace !== null && verdict?.kind === 'missing'
const workspaceHost = workspace
? (localTaskListedHostId(site.endpoint, workspace.hostId) ?? workspace.hostId)
: null
@@ -104,15 +122,11 @@ export function LocalTaskWorkSection(props: {
})
const [linking, setLinking] = useState(false)
const [confirmUnlink, setConfirmUnlink] = useState(false)
+ const [unlinkingSession, setUnlinkingSession] = useState(null)
const [unlinkIds] = useState(() => createLocalTaskOperationIds())
+ // Per session, so a resend after a lost answer replays that one unlink.
+ const sessionUnlinkIds = useRef(new Map())
const [error, setError] = useState(null)
- const sessions = localTaskSessionViews({
- task,
- tasks: library.tasks,
- links: library.work.links,
- snapshot: library.work.progress,
- inContact: site.connState === 'connected'
- })
const canWrite = props.canWrite && !launch.busy
const unlink = async () => {
@@ -122,10 +136,25 @@ export function LocalTaskWorkSection(props: {
{ taskId: task.id },
props.api.unlinkWorkspace
)
- setError(localTaskWriteMessage(copy, outcome))
+ setError(localTaskWriteMessage(copy, outcome, library.tasks))
+ library.reload(['links'])
+ }
+ const unlinkSession = async (session: LocalTaskSessionLink) => {
+ setUnlinkingSession(null)
+ let ids = sessionUnlinkIds.current.get(session.id)
+ if (!ids) {
+ ids = createLocalTaskOperationIds()
+ sessionUnlinkIds.current.set(session.id, ids)
+ }
+ const outcome = await sendLocalTaskWrite(
+ ids,
+ { sessionLinkId: session.id },
+ props.api.unlinkSession
+ )
+ setError(localTaskWriteMessage(copy, outcome, library.tasks))
library.reload(['links'])
}
- const startFromTask = () => {
+ const startFromTask = (replace: boolean) => {
if (startSetup) {
void launch.open(
{
@@ -134,140 +163,143 @@ export function LocalTaskWorkSection(props: {
sessionTask: task,
name: suggestLocalTaskWorkspaceName(task.ref, task.title),
setups,
- setup: startSetup
+ setup: startSetup,
+ ...(replace ? { replace: true } : {})
},
localTaskSetupLabel(startSetup, labels)
)
}
}
+ const openWorkspace = () => {
+ const href = workspace
+ ? localTaskWorkspaceHref({ hostId: site.hostId, endpoint: site.endpoint, link: workspace })
+ : null
+ if (href) {
+ setError(null)
+ props.onNavigate(href)
+ } else {
+ setError(work.cannotOpenHere)
+ }
+ }
const hostLabel =
workspace && workspaceHost
- ? localTaskHostLabel(workspaceHost, labels.get(workspaceHost) ?? workspace.hostLabelSnapshot)
+ ? workspace.hostId === 'local'
+ ? site.label
+ : localTaskHostLabel(
+ workspaceHost,
+ labels.get(workspaceHost) ?? workspace.hostLabelSnapshot
+ )
+ : ''
+ // The host the offline line names: the workspace's, else the one Start work would create on.
+ const offlineLabel = workspace
+ ? hostLabel
+ : startSetup
+ ? localTaskHostLabel(startSetup.hostId, labels.get(startSetup.hostId))
: ''
+ const needsCatalog = !ownWorkspace || gone
const noSetupHint =
catalog.state === 'ready' && setups.length === 0
? project && project.repos.length > 0
? work.noSetupHint
: work.noRepoOnHostHint
- : null
+ : catalog.state === 'unavailable'
+ ? work.catalogUnavailable
+ : null
+ const openSession = () =>
+ workspace && void launch.open({ mode: 'session', sessionTask: task, workspace }, hostLabel)
- return (
+ const primary: LocalTaskWorkControls['primary'] = gone
+ ? startSetup
+ ? { label: work.startAgain, disabled: !canWrite, onPress: () => startFromTask(true) }
+ : null
+ : workspace
+ ? {
+ label: work.openSessionAction,
+ disabled: !canWrite || hostOffline || verdict?.kind === 'missing',
+ onPress: openSession
+ }
+ : startSetup
+ ? {
+ label: work.start,
+ disabled: !canWrite || hostOffline,
+ onPress: () => startFromTask(false)
+ }
+ : null
+ const menuActions: ActionSheetAction[] = [
+ ...(workspace && !gone
+ ? [{ label: work.openWorkspace, icon: FolderOpen, onPress: openWorkspace }]
+ : []),
+ ...(workspace && !ownWorkspace && startSetup
+ ? [
+ {
+ label: work.startOwn,
+ icon: Play,
+ disabled: !canWrite || hostOffline,
+ closeBeforePress: true,
+ onPress: () => startFromTask(false)
+ }
+ ]
+ : []),
+ ...(ownWorkspace
+ ? []
+ : [
+ {
+ label: work.linkExisting,
+ icon: Link2,
+ disabled: !canWrite || catalog.state !== 'ready',
+ closeBeforePress: true,
+ onPress: () => setLinking(true)
+ }
+ ]),
+ ...(ownWorkspace
+ ? [
+ {
+ label: work.unlinkWorkspace,
+ icon: Unlink,
+ destructive: true,
+ disabled: !canWrite,
+ closeBeforePress: true,
+ onPress: () => setConfirmUnlink(true)
+ }
+ ]
+ : []),
+ ...(hostOffline
+ ? [{ label: work.checkConnection, icon: RotateCcw, onPress: hostContact.refresh }]
+ : [])
+ ]
+ const hint = (text: string, testID?: string) => (
+
+ {text}
+
+ )
+ const notices = (
<>
- {work.workspaceSection}
-
- {workspace ? (
- <>
- {
- const href = localTaskWorkspaceHref({
- hostId: site.hostId,
- endpoint: site.endpoint,
- link: workspace
- })
- if (href) {
- props.onNavigate(href)
- }
- }}
- accessibilityRole="button"
- accessibilityLabel={work.openWorkspace}
- >
-
- {workspace.nameSnapshot ?? workspace.worktreeId ?? workspace.folderWorkspaceId}
-
-
- {[hostLabel, workspace.branchSnapshot].filter(Boolean).join(' · ')}
-
- {inheritedFrom ? (
-
- {work.inheritedWorkspace(inheritedFrom.title)}
-
- ) : null}
-
-
-
- void launch.open({ mode: 'session', sessionTask: task, workspace }, hostLabel)
- }
- />
- {ownWorkspace ? (
- setConfirmUnlink(true)}
- />
- ) : null}
-
- >
- ) : (
- {work.noWorkspace}
- )}
- {ownWorkspace ? null : (
- <>
- {noSetupHint || (setups.length > 0 && !startSetup) ? (
- {noSetupHint ?? work.folderProjectHint}
- ) : null}
-
- {startSetup ? (
-
- ) : null}
- setLinking(true)}
- />
-
- >
- )}
- {hostOffline ? (
-
- ) : null}
- {launch.busy ? {work.starting} : null}
- {launch.notice ? {launch.notice} : null}
- {error ? {error} : null}
- {library.work.error ? (
- {work.workLoadFailed}
- ) : null}
-
- {sessions.length > 0 ? (
- <>
- {work.sessionsSection}
-
- {sessions.map((view, index) => (
- props.onOpenSession(view.link.id)}
- >
-
-
-
- {[
- agentLabel(view.link.agent),
- view.task && view.task.id !== task.id ? work.ofSubtask(view.task.title) : '',
- work.sessionStates[view.state]
- ]
- .filter(Boolean)
- .join(' · ')}
-
- {view.lastMessage ? (
-
- {view.lastMessage}
-
- ) : null}
-
-
- ))}
-
- >
+ {inheritedFrom ? hint(work.inheritedWorkspace(inheritedFrom.title)) : null}
+ {verdict?.kind === 'missing' ? (
+
+ {work.workspaceGone}
+
+ ) : null}
+ {!workspace ? hint(work.noWorkspace) : null}
+ {needsCatalog && (noSetupHint || (setups.length > 0 && !startSetup))
+ ? hint(noSetupHint ?? work.folderProjectHint)
+ : null}
+ {/* Why no automatic re-check: contact coming back is read when the user asks. */}
+ {hostOffline
+ ? hint(work.hostOffline(offlineLabel), 'local-task-host-offline')
+ : libraryOffline
+ ? hint(work.hostOffline(site.label), 'local-task-library-offline')
+ : null}
+ {launch.busy ? {work.starting} : null}
+ {launch.notice ? {launch.notice} : null}
+ {error ? {error} : null}
+ {library.work.error && !libraryOffline ? (
+ {work.workLoadFailed}
) : null}
+ >
+ )
+ const sheets = (
+ <>
void unlink()}
onCancel={() => setConfirmUnlink(false)}
/>
+ unlinkingSession && void unlinkSession(unlinkingSession)}
+ onCancel={() => setUnlinkingSession(null)}
+ />
>
)
+ return {
+ primary,
+ menuActions,
+ notices,
+ sheets,
+ requestUnlinkSession: canWrite ? setUnlinkingSession : null
+ }
}
-
-const styles = StyleSheet.create({
- card: { padding: spacing.md, gap: spacing.sm },
- buttons: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
- sessionMain: { flex: 1, gap: 2 }
-})
diff --git a/mobile/src/local-tasks/local-task-workspace-candidates.test.ts b/mobile/src/local-tasks/local-task-workspace-candidates.test.ts
new file mode 100644
index 000000000000..d232eda0e49a
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-workspace-candidates.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import {
+ localTaskLinkParamsFor,
+ readLocalTaskWorkspaceCandidates
+} from './local-task-workspace-candidates'
+
+describe('workspaces offered for linking to a task', () => {
+ it('names the branch as the desktop does, so the link records `spare` and not `refs/heads/spare`', () => {
+ const [candidate] = readLocalTaskWorkspaceCandidates([
+ {
+ worktreeId: 'repo1::/w/spare',
+ repoId: 'repo1',
+ displayName: '',
+ branch: 'refs/heads/spare'
+ }
+ ])
+ expect(candidate).toMatchObject({ displayName: 'spare', branch: 'spare' })
+ expect(localTaskLinkParamsFor(candidate, 'Studio Mac')).toMatchObject({
+ branchSnapshot: 'spare'
+ })
+ })
+})
diff --git a/mobile/src/local-tasks/local-task-workspace-candidates.ts b/mobile/src/local-tasks/local-task-workspace-candidates.ts
index 27cdbe17e9fb..b618eac48593 100644
--- a/mobile/src/local-tasks/local-task-workspace-candidates.ts
+++ b/mobile/src/local-tasks/local-task-workspace-candidates.ts
@@ -5,6 +5,7 @@ import type {
LocalTaskLinks,
LocalTaskWorkspaceLink
} from '../../../src/shared/local-tasks/local-task-link-types'
+import { getWorktreeGitIdentityDisplay } from '../../../src/shared/worktree/git-identity-display'
import { worktreeIdsEqual } from '../../../src/shared/worktree/id'
import { parseWorkspaceKey } from '../../../src/shared/workspace-scope'
import { salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage'
@@ -46,12 +47,15 @@ export function readLocalTaskWorkspaceCandidates(rows: unknown): LocalTaskWorksp
.map((row) => {
const scope =
row.workspaceKind === 'folder-workspace' ? parseWorkspaceKey(row.worktreeId) : null
+ // Shown and stored as the desktop does: `main`, not `refs/heads/main`.
+ const identity = getWorktreeGitIdentityDisplay({ branch: row.branch })
+ const branch = identity?.kind === 'branch' ? identity.branchName : ''
return {
worktreeId: row.worktreeId,
repoId: row.repoId,
hostId: row.hostId ?? LOCAL_EXECUTION_HOST_ID,
- displayName: row.displayName || row.branch || row.worktreeId,
- branch: row.branch ?? '',
+ displayName: row.displayName || branch || row.worktreeId,
+ branch,
instanceId: row.worktreeInstanceId ?? null,
folderWorkspaceId: scope?.type === 'folder' ? scope.folderWorkspaceId : null
}
diff --git a/mobile/src/local-tasks/local-task-workspace-detail-rows.tsx b/mobile/src/local-tasks/local-task-workspace-detail-rows.tsx
index c710bfd4738c..aa383f228472 100644
--- a/mobile/src/local-tasks/local-task-workspace-detail-rows.tsx
+++ b/mobile/src/local-tasks/local-task-workspace-detail-rows.tsx
@@ -11,7 +11,7 @@ const ICON_SIZE = 16
/**
* The long-press sheet's row for the local task its workspace belongs to, drawn as the sheet's
- * other link rows: the title, then "SHOP-12 · Local task", or "Can't confirm" for a library out of
+ * other link rows: the title, then "SHOP-12 · Local task", or "Can’t confirm" for a library out of
* contact. Tapping it opens the task. None off the host list or for a workspace with no task.
*/
export function useLocalTaskDetailRows(row: Worktree): ReactNode[] {
diff --git a/mobile/src/local-tasks/local-task-workspace-link-sheet.tsx b/mobile/src/local-tasks/local-task-workspace-link-sheet.tsx
index 8a0130151d7c..a5020627d8f2 100644
--- a/mobile/src/local-tasks/local-task-workspace-link-sheet.tsx
+++ b/mobile/src/local-tasks/local-task-workspace-link-sheet.tsx
@@ -47,8 +47,14 @@ export function LocalTaskWorkspaceLinkSheet(props: {
const labelOf = (ownerHostId: string) =>
localTaskHostLabel(listedOf(ownerHostId), props.hostLabels.get(listedOf(ownerHostId)))
const titles = new Map(props.tasks.map((task) => [task.id, task.title]))
- const candidates =
+ const [query, setQuery] = useState('')
+ const all =
state.status === 'ready' ? localTaskCandidatesForProject(state.candidates, props.setups) : []
+ const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
+ const candidates = all.filter((candidate) => {
+ const haystack = `${candidate.displayName} ${candidate.branch}`.toLowerCase()
+ return words.every((word) => haystack.includes(word))
+ })
const link = async (index: number) => {
const candidate = candidates[index]
if (!candidate) {
@@ -66,8 +72,9 @@ export function LocalTaskWorkspaceLinkSheet(props: {
},
props.api.linkWorkspace
)
- setError(localTaskWriteMessage(copy, outcome))
+ setError(localTaskWriteMessage(copy, outcome, props.tasks))
if (outcome.kind === 'ok') {
+ setQuery('')
props.onLinked()
}
}
@@ -79,11 +86,16 @@ export function LocalTaskWorkspaceLinkSheet(props: {
message={
state.status === 'failed'
? copy.loadFailed
- : state.status === 'ready' && candidates.length === 0
+ : state.status === 'ready' && all.length === 0
? copy.work.noWorkspacesToLink
: null
}
error={error}
+ search={
+ all.length > 0
+ ? { query, placeholder: copy.work.searchWorkspaces, onChange: setQuery }
+ : null
+ }
choices={candidates.map((candidate, index) => {
const holder = localTaskLinkHolding(props.links, candidate)
const host = labelOf(candidate.hostId)
@@ -99,6 +111,7 @@ export function LocalTaskWorkspaceLinkSheet(props: {
})}
onClose={() => {
setError(null)
+ setQuery('')
props.onClose()
}}
/>
diff --git a/mobile/src/local-tasks/local-task-workspace-review-line.tsx b/mobile/src/local-tasks/local-task-workspace-review-line.tsx
new file mode 100644
index 000000000000..03f1f4c86495
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-workspace-review-line.tsx
@@ -0,0 +1,51 @@
+import { StyleSheet, Text, View } from 'react-native'
+import { GitPullRequest } from 'lucide-react-native'
+import { prStateColor } from '../components/WorktreeMetaGlyphs'
+import { colors } from '../theme/mobile-theme'
+import { localTasksCopy } from './local-tasks-copy'
+import type { LocalTaskWorkspaceReview } from './local-task-workspace-row'
+import { localTaskStyles } from './local-tasks-styles'
+
+/** "PR #231 merged", as the host has it; out of contact the state is not claimed as current. */
+export function localTaskReviewLabel(review: LocalTaskWorkspaceReview, inContact: boolean): string {
+ const work = localTasksCopy().work
+ const label = review.kind === 'pr' ? work.review.pr(review.number) : work.review.mr(review.number)
+ const state = review.state.toLowerCase()
+ return !inContact
+ ? work.review.unverifiable(label)
+ : state === 'open' || state === 'draft' || state === 'merged' || state === 'closed'
+ ? work.review[state](label)
+ : label
+}
+
+/**
+ * The workspace's PR or MR as its host has it, with its checks (REQ-012), colored as the host
+ * list's PR chip. Out of contact the cached state is not claimed as current.
+ */
+export function LocalTaskWorkspaceReviewLine(props: {
+ review: LocalTaskWorkspaceReview
+ inContact: boolean
+}) {
+ const work = localTasksCopy().work
+ const { review } = props
+ const said = localTaskReviewLabel(review, props.inContact)
+ const checks =
+ props.inContact && review.checks && review.checks !== 'neutral'
+ ? work.checks[review.checks]
+ : null
+ return (
+
+
+
+ {[said, checks].filter(Boolean).join(' · ')}
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ review: { flexDirection: 'row', alignItems: 'center', gap: 4 }
+})
diff --git a/mobile/src/local-tasks/local-task-workspace-row.test.ts b/mobile/src/local-tasks/local-task-workspace-row.test.ts
new file mode 100644
index 000000000000..5c6aa472dcfa
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-workspace-row.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+import type { LocalTaskWorkspaceLink } from '../../../src/shared/local-tasks/local-task-link-types'
+import {
+ localTaskWorkspaceVerdict,
+ readLocalTaskWorkspaceListing,
+ type LocalTaskWorkspaceListing
+} from './local-task-workspace-row'
+
+const link: LocalTaskWorkspaceLink = {
+ taskId: 't12',
+ hostId: 'ssh:box',
+ kind: 'git-worktree',
+ instanceId: null,
+ worktreeId: 'shop::/w/shop-12',
+ folderWorkspaceId: null,
+ repoId: 'shop',
+ branchSnapshot: 'zc/shop-12',
+ nameSnapshot: 'shop-12',
+ hostLabelSnapshot: 'box',
+ linkedVia: 'start',
+ linkedAt: 1
+}
+
+const MAIN = { worktreeId: 'shop::/w/shop', repoId: 'shop', hostId: 'ssh:box' }
+
+function listing(rows: unknown[], extra: Record = {}): LocalTaskWorkspaceListing {
+ const read = readLocalTaskWorkspaceListing({
+ worktrees: rows,
+ truncated: false,
+ hostScope: { hostIds: ['ssh:box'], omittedHostIds: [] },
+ ...extra
+ })
+ if (!read) {
+ throw new Error('the listing should read')
+ }
+ return read
+}
+
+describe('where a linked workspace is now', () => {
+ it("reads the host's PR and checks off the workspace's own row", () => {
+ const row = {
+ ...MAIN,
+ worktreeId: 'shop::/w/shop-12',
+ linkedPR: { number: 231, state: 'open', checksStatus: 'failure' }
+ }
+ expect(localTaskWorkspaceVerdict(link, listing([MAIN, row]), true)).toEqual({
+ kind: 'found',
+ review: { kind: 'pr', number: 231, state: 'open', checks: 'failure' }
+ })
+ })
+
+ it('calls it deleted only when its host listed the repo there and not the workspace', () => {
+ expect(localTaskWorkspaceVerdict(link, listing([MAIN]), true)).toEqual({ kind: 'missing' })
+ // The repo was never listed on that host (its scan may have failed): nothing is proved.
+ expect(localTaskWorkspaceVerdict(link, listing([]), true)).toEqual({ kind: 'unverifiable' })
+ // A cut page, or a host too old to say whether it cut one, proves nothing either.
+ expect(localTaskWorkspaceVerdict(link, listing([MAIN], { truncated: true }), true)).toEqual({
+ kind: 'unverifiable'
+ })
+ expect(
+ localTaskWorkspaceVerdict(link, listing([MAIN], { truncated: undefined }), true)
+ ).toEqual({ kind: 'unverifiable' })
+ })
+
+ it('never calls a workspace on a host out of contact deleted', () => {
+ expect(localTaskWorkspaceVerdict(link, listing([MAIN]), false)).toEqual({
+ kind: 'unverifiable'
+ })
+ })
+
+ it('follows a workspace that moved to another host by its durable instance', () => {
+ const moved = {
+ ...MAIN,
+ worktreeId: 'shop::/w/shop-12',
+ hostId: 'local',
+ worktreeInstanceId: 'i1'
+ }
+ expect(
+ localTaskWorkspaceVerdict({ ...link, instanceId: 'i1' }, listing([MAIN, moved]), true)
+ ).toEqual({ kind: 'found', review: null })
+ })
+})
diff --git a/mobile/src/local-tasks/local-task-workspace-row.ts b/mobile/src/local-tasks/local-task-workspace-row.ts
new file mode 100644
index 000000000000..bdbdb2dca85b
--- /dev/null
+++ b/mobile/src/local-tasks/local-task-workspace-row.ts
@@ -0,0 +1,144 @@
+import { z } from 'zod'
+import { LOCAL_EXECUTION_HOST_ID } from '../../../src/shared/execution-host'
+import type { CheckStatus } from '../../../src/shared/github/pull-request-types'
+import type { LocalTaskWorkspaceLink } from '../../../src/shared/local-tasks/local-task-link-types'
+import { localTaskLinkWorkspaceId } from '../../../src/shared/local-tasks/local-task-workspace-match'
+import { getRepoIdFromWorktreeId, worktreeIdsEqual } from '../../../src/shared/worktree/id'
+import { salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage'
+
+/** What the task's workspace card shows of the host's own row: its review, as the host has it. */
+export type LocalTaskWorkspaceReview = {
+ kind: 'pr' | 'mr'
+ number: number
+ /** `open`, `merged`, `closed`, `draft`, or `unknown` when the host has only the number. */
+ state: string
+ checks: CheckStatus | null
+}
+
+/**
+ * Where a linked workspace is now, judged from one `worktree.ps` of the library's host: its row, or
+ * `missing` only when that host listed the repo there and not the workspace (a host's own word, as
+ * the desktop requires), else `unverifiable`.
+ */
+export type LocalTaskWorkspaceVerdict =
+ | { kind: 'found'; review: LocalTaskWorkspaceReview | null }
+ | { kind: 'missing' }
+ | { kind: 'unverifiable' }
+
+const rowSchema = z.looseObject({
+ worktreeId: z.string().min(1),
+ repoId: z.string(),
+ hostId: salvagedOptional('hostId', z.string()),
+ worktreeInstanceId: salvagedOptional('worktreeInstanceId', z.string()),
+ linkedPR: salvagedOptional(
+ 'linkedPR',
+ z
+ .looseObject({
+ number: z.number(),
+ state: z.string(),
+ checksStatus: salvagedOptional(
+ 'checksStatus',
+ z.enum(['pending', 'success', 'failure', 'neutral'])
+ )
+ })
+ .nullable()
+ ),
+ linkedGitLabMR: salvagedOptional('linkedGitLabMR', z.number().nullable())
+})
+
+type Row = z.output
+
+const listingSchema = z.looseObject({
+ worktrees: salvagedOptional('worktrees', salvagingArray(rowSchema)),
+ truncated: salvagedOptional('truncated', z.boolean()),
+ hostScope: salvagedOptional('hostScope', z.looseObject({ hostIds: salvagingArray(z.string()) }))
+})
+
+/** One full `worktree.ps` as the verdict reads it; null for an unchanged or unreadable reply. */
+export type LocalTaskWorkspaceListing = {
+ rows: Row[]
+ /** False when the host cut the page, so an absent row proves nothing. */
+ complete: boolean
+ /** Hosts the page has rows for; empty from a host too old to say. */
+ coveredHostIds: ReadonlySet
+}
+
+export function readLocalTaskWorkspaceListing(reply: unknown): LocalTaskWorkspaceListing | null {
+ const parsed = listingSchema.safeParse(reply)
+ if (!parsed.success || !parsed.data.worktrees) {
+ return null
+ }
+ return {
+ rows: parsed.data.worktrees,
+ complete: parsed.data.truncated === false,
+ coveredHostIds: new Set(parsed.data.hostScope?.hostIds ?? [])
+ }
+}
+
+function rowHost(row: Row): string {
+ return row.hostId ?? LOCAL_EXECUTION_HOST_ID
+}
+
+function reviewOf(row: Row): LocalTaskWorkspaceReview | null {
+ if (row.linkedPR) {
+ return {
+ kind: 'pr',
+ number: row.linkedPR.number,
+ state: row.linkedPR.state,
+ checks: row.linkedPR.checksStatus ?? null
+ }
+ }
+ return typeof row.linkedGitLabMR === 'number'
+ ? { kind: 'mr', number: row.linkedGitLabMR, state: 'unknown', checks: null }
+ : null
+}
+
+/**
+ * The verdict for one link, matched as the desktop matches it: by its durable instance first, then
+ * by host and id. `hostInContact` is the library host's own answer for the workspace's host; a host
+ * it cannot reach never proves a workspace gone.
+ */
+export function localTaskWorkspaceVerdict(
+ link: Pick<
+ LocalTaskWorkspaceLink,
+ 'hostId' | 'kind' | 'instanceId' | 'worktreeId' | 'folderWorkspaceId' | 'repoId'
+ >,
+ listing: LocalTaskWorkspaceListing,
+ hostInContact: boolean
+): LocalTaskWorkspaceVerdict {
+ const workspaceId = localTaskLinkWorkspaceId(link)
+
+ // Archived rows count: an archived workspace still exists.
+ const rows = listing.rows
+ const row =
+ (link.kind === 'git-worktree' && link.instanceId
+ ? rows.find((candidate) => candidate.worktreeInstanceId === link.instanceId)
+ : undefined) ??
+ (workspaceId
+ ? rows.find(
+ (candidate) =>
+ rowHost(candidate) === link.hostId &&
+ worktreeIdsEqual(candidate.worktreeId, workspaceId)
+ )
+ : undefined)
+ if (row) {
+ return { kind: 'found', review: reviewOf(row) }
+ }
+ if (!hostInContact || !workspaceId || !listing.complete) {
+ return { kind: 'unverifiable' }
+ }
+ if (link.kind === 'folder') {
+ // Why local only: a remote host's folder catalog can still be loading (as on the desktop).
+ return link.hostId === LOCAL_EXECUTION_HOST_ID && listing.coveredHostIds.has(link.hostId)
+ ? { kind: 'missing' }
+ : { kind: 'unverifiable' }
+ }
+ // The repo's other rows on that host are its proof that the repo was scanned there.
+ const repoId = link.repoId ?? getRepoIdFromWorktreeId(workspaceId)
+ const repoListed = rows.some(
+ (candidate) => candidate.repoId === repoId && rowHost(candidate) === link.hostId
+ )
+ return listing.coveredHostIds.has(link.hostId) && repoListed
+ ? { kind: 'missing' }
+ : { kind: 'unverifiable' }
+}
diff --git a/mobile/src/local-tasks/local-tasks-access.test.ts b/mobile/src/local-tasks/local-tasks-access.test.ts
index f69d63be247c..922a85701326 100644
--- a/mobile/src/local-tasks/local-tasks-access.test.ts
+++ b/mobile/src/local-tasks/local-tasks-access.test.ts
@@ -97,17 +97,26 @@ describe('which hosts the phone treats as task hosts', () => {
expect(methods).toEqual([
'localTasks.attention.get',
'localTasks.projects.list',
+ 'localTasks.projects.list',
'localTasks.status',
+ 'localTasks.tasks.list',
'localTasks.tasks.list'
])
})
- it('uses the optional host count without downloading the library', async () => {
+ it('uses the optional host count while still reading tasks for acceptance', async () => {
const { library, methods } = await readHome({
- 'localTasks.status': { ...STATUS, inProgressCount: 4 }
+ 'localTasks.status': { ...STATUS, inProgressCount: 4 },
+ 'localTasks.tasks.list': { tasks: [] },
+ 'localTasks.projects.list': { projects: [] }
})
expect(library?.inProgress).toBe(4)
- expect(methods).toEqual(['localTasks.attention.get', 'localTasks.status'])
+ expect(methods).toEqual([
+ 'localTasks.attention.get',
+ 'localTasks.projects.list',
+ 'localTasks.status',
+ 'localTasks.tasks.list'
+ ])
})
it('leaves out, after its status alone, a library with no project or one not open', async () => {
diff --git a/mobile/src/local-tasks/local-tasks-attention-copy.ts b/mobile/src/local-tasks/local-tasks-attention-copy.ts
new file mode 100644
index 000000000000..4d0d34029f92
--- /dev/null
+++ b/mobile/src/local-tasks/local-tasks-attention-copy.ts
@@ -0,0 +1,127 @@
+import type { LocalTaskAttentionKind } from '../../../src/shared/local-tasks/local-task-link-types'
+import type { LocalTaskSnoozePreset } from '../../../src/shared/local-tasks/local-task-snooze-presets'
+import { translate } from '../i18n/mobile-i18n'
+import type { LocalTaskReplyStatus } from './use-local-task-attention-actions'
+
+/** "Needs you": what waits on the user, replying to a session, and snoozing it. */
+export function localTasksAttentionCopy() {
+ const snooze = translate('localTasks.work.snooze.label', 'Snooze')
+ return {
+ segmentAttention: translate('localTasks.work.attention.segment', 'Needs you'),
+ homeNeedsYou: (count: number): string =>
+ translate('localTasks.work.attention.homeCount', 'Needs you {{count}}', { count }),
+ attentionUnverifiable: (host: string): string =>
+ host
+ ? translate(
+ 'localTasks.work.attention.unverifiable',
+ '{{host}} is not connected, so what needs you can’t be confirmed.',
+ { host }
+ )
+ : translate(
+ 'localTasks.work.attention.unverifiableUnnamed',
+ 'This host’s Orca is not connected, so what needs you can’t be confirmed.'
+ ),
+ // The library is in contact, but some of its sessions run on a host it cannot reach.
+ attentionUnverifiableHosts: translate(
+ 'localTasks.work.attention.unverifiableHosts',
+ 'Nothing is waiting on you on the hosts in contact. Sessions on hosts that are not connected can’t be confirmed until they are back.'
+ ),
+ attentionEmpty: translate(
+ 'localTasks.work.attention.empty',
+ 'No session on a task is waiting on you.'
+ ),
+ attentionLoading: translate('localTasks.work.attention.loading', 'Reading session status…'),
+ workLoadFailed: translate(
+ 'localTasks.work.loadFailed',
+ 'Workspaces and sessions could not be read.'
+ ),
+ attentionKinds: {
+ approval: translate('localTasks.work.attentionKind.approval', 'Waiting for your approval'),
+ question: translate('localTasks.work.attentionKind.question', 'Waiting for your answer'),
+ blocked: translate('localTasks.work.attentionKind.blocked', 'Blocked'),
+ 'unread-done': translate(
+ 'localTasks.work.attentionKind.unreadDone',
+ 'Finished a turn you have not seen'
+ )
+ } satisfies Record,
+ agentSaid: (agent: string, message: string): string =>
+ translate('localTasks.work.agentSaid', '{{agent}}: “{{message}}”', { agent, message }),
+ agentStopped: (agent: string): string =>
+ translate('localTasks.work.agentStopped', '{{agent}} stopped', { agent }),
+ someAgent: translate('localTasks.work.someAgent', 'The agent'),
+ reply: translate('localTasks.work.reply.label', 'Reply'),
+ replyTitle: (title: string): string =>
+ translate('localTasks.work.reply.title', 'Reply in “{{title}}”', { title }),
+ replyPlaceholder: translate(
+ 'localTasks.work.reply.placeholder',
+ 'Next instruction for the agent…'
+ ),
+ replyReconnecting: translate('localTasks.work.reply.reconnecting', 'Reconnecting…'),
+ replyDisconnected: (host: string): string =>
+ host
+ ? translate(
+ 'localTasks.work.reply.disconnected',
+ '{{host}} is not connected, so the reply can’t be sent now. Your text stays here.',
+ { host }
+ )
+ : translate(
+ 'localTasks.work.reply.disconnectedUnnamed',
+ 'This host’s Orca is not connected, so the reply can’t be sent now. Your text stays here.'
+ ),
+ // Each reason below finishes "Not sent: …", so it is a clause, not a sentence.
+ replySessionMissing: translate(
+ 'localTasks.work.reply.sessionMissing',
+ 'this session’s terminal is no longer listed; open the session instead'
+ ),
+ replyNoSession: translate(
+ 'localTasks.work.reply.noSession',
+ 'this session is no longer linked to the task, so there is nowhere to send it'
+ ),
+ replyNotConnected: translate(
+ 'localTasks.work.reply.notConnected',
+ 'this host’s Orca is not connected'
+ ),
+ replyInputNotCleared: translate(
+ 'localTasks.work.reply.inputNotCleared',
+ 'text left in the session’s input could not be cleared first; open the session to check it'
+ ),
+ replyInputRefused: translate(
+ 'localTasks.work.reply.inputRefused',
+ 'the session did not take the text, which happens while the agent waits on a choice; open the session'
+ ),
+ replyChatNotReady: translate(
+ 'localTasks.work.reply.chatNotReady',
+ 'the chat did not say where the conversation stands; open the session'
+ ),
+ openSession: translate('localTasks.work.openSession', 'Open session'),
+ markSeen: translate('localTasks.work.markSeen', 'Mark as seen'),
+ snooze,
+ snoozeTitle: snooze,
+ snoozeChoices: {
+ hour: translate('localTasks.work.snooze.hour', 'For 1 hour'),
+ tonight: translate('localTasks.work.snooze.tonight', 'Until tonight'),
+ tomorrow: translate('localTasks.work.snooze.tomorrow', 'Until tomorrow'),
+ 'next-week': translate('localTasks.work.snooze.nextWeek', 'Until next week')
+ } satisfies Record,
+ itemActions: translate('localTasks.work.itemActions', 'Item actions'),
+ replyStates: {
+ sending: translate('localTasks.work.reply.sending', 'Sending…'),
+ delivered: translate('localTasks.work.reply.delivered', 'Delivered'),
+ unconfirmed: translate(
+ 'localTasks.work.reply.unconfirmed',
+ 'Sent, but not confirmed. Check the session before sending again.'
+ )
+ } satisfies Record, string>,
+ replyNotSent: translate(
+ 'localTasks.work.reply.notSent',
+ 'Not sent. Your reply is back in the box.'
+ ),
+ replyNotSentBecause: (reason: string): string =>
+ translate(
+ 'localTasks.work.reply.notSentBecause',
+ 'Not sent: {{reason}}. Your reply is back in the box.',
+ { reason }
+ ),
+ editReply: translate('localTasks.work.reply.edit', 'Edit and send again')
+ }
+}
diff --git a/mobile/src/local-tasks/local-tasks-attention-view.test.tsx b/mobile/src/local-tasks/local-tasks-attention-view.test.tsx
index 5505655e07ce..6012d7365148 100644
--- a/mobile/src/local-tasks/local-tasks-attention-view.test.tsx
+++ b/mobile/src/local-tasks/local-tasks-attention-view.test.tsx
@@ -24,6 +24,14 @@ vi.mock('react-native', () => ({
View: 'View',
StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }
}))
+vi.mock('lucide-react-native', () => ({
+ Check: 'Check',
+ Clock: 'Clock',
+ CornerDownRight: 'CornerDownRight',
+ Eye: 'Eye',
+ SquareTerminal: 'SquareTerminal'
+}))
+vi.mock('../components/AgentStateDot', () => ({ AgentStateDot: () => null }))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('../components/bottom-drawer/BottomDrawer', () => ({
BottomDrawer: (props: { visible: boolean; children: ReactNode }) =>
@@ -77,7 +85,7 @@ const session: LocalTaskSessionLink = {
const snapshot: LocalTaskProgressSnapshot = { observedAt: 2_000, sessions: [], items: [done] }
-function library(): LocalTasksLibrary {
+function library(progress: LocalTaskProgressSnapshot = snapshot): LocalTasksLibrary {
return {
api: null,
status: null,
@@ -92,7 +100,7 @@ function library(): LocalTasksLibrary {
retryFailed: vi.fn(),
resubscribe: vi.fn(),
acceptTasks: vi.fn(),
- work: { links: { workspaces: [], sessions: [session] }, progress: snapshot, error: null }
+ work: { links: { workspaces: [], sessions: [session] }, progress, error: null }
}
}
@@ -105,18 +113,18 @@ afterEach(() => {
renderer = null
})
-function render(connState: ConnectionState) {
+function render(connState: ConnectionState, progress: LocalTaskProgressSnapshot = snapshot) {
act(() => {
renderer = create(
createElement(LocalTasksAttentionView, {
- library: library(),
+ library: library(progress),
api: createLocalTasksApi({
sendRequest: async () => {
throw new Error('unexpected RPC')
}
}),
site: { client: null, connState, label: 'minizc' },
- rows: localTaskAttentionRows({ snapshot, tasks: [task], projectId: null }),
+ rows: localTaskAttentionRows({ snapshot: progress, tasks: [task], projectId: null }),
now: 60_000,
canWrite: connState === 'connected',
bottomInset: 0,
@@ -150,6 +158,9 @@ describe('replying from "Needs you"', () => {
expect(input(tree).props.editable).toBe(false)
expect(input(tree).props.placeholder).toBe(localTasksCopy().work.replyReconnecting)
expect(tree.root.findByProps({ testID: 'local-task-reply-send' }).props.disabled).toBe(true)
+ expect(tree.root.findByProps({ testID: 'local-task-reply-offline' }).props.children).toBe(
+ localTasksCopy().work.replyDisconnected('minizc')
+ )
})
it('puts the text back when nothing reached the agent, and sends only once', async () => {
@@ -182,3 +193,32 @@ describe('replying from "Needs you"', () => {
)
})
})
+
+describe('an empty "Needs you"', () => {
+ const message = (tree: ReactTestRenderer) =>
+ tree.root.findByProps({ testID: 'local-tasks-attention-message' }).props.children
+
+ it('does not call it empty while a session runs on a host the library cannot reach', () => {
+ const unreachable: LocalTaskProgressSnapshot = {
+ observedAt: 2_000,
+ sessions: [
+ {
+ taskSessionId: session.id,
+ state: 'unverifiable',
+ lastMessage: null,
+ stateSince: null,
+ unread: false
+ }
+ ],
+ items: []
+ }
+ expect(message(render('connected', unreachable))).toBe(
+ localTasksCopy().work.attentionUnverifiableHosts
+ )
+ })
+
+ it('is empty only when every session it reads can be confirmed', () => {
+ const quiet: LocalTaskProgressSnapshot = { observedAt: 2_000, sessions: [], items: [] }
+ expect(message(render('connected', quiet))).toBe(localTasksCopy().work.attentionEmpty)
+ })
+})
diff --git a/mobile/src/local-tasks/local-tasks-attention-view.tsx b/mobile/src/local-tasks/local-tasks-attention-view.tsx
index 3c9f83689df3..2c88cb0e923a 100644
--- a/mobile/src/local-tasks/local-tasks-attention-view.tsx
+++ b/mobile/src/local-tasks/local-tasks-attention-view.tsx
@@ -1,23 +1,14 @@
-import { useState } from 'react'
import { Pressable, ScrollView, Text, View } from 'react-native'
-import type { LocalTaskAttentionItem } from '../../../src/shared/local-tasks/local-task-link-types'
-import {
- LOCAL_TASK_SNOOZE_PRESETS,
- localTaskSnoozeUntil
-} from '../../../src/shared/local-tasks/local-task-snooze-presets'
-import { ActionSheetModal } from '../components/ActionSheetModal'
import type { LocalTasksApi } from './local-tasks-api'
import { localTasksCopy } from './local-tasks-copy'
-import { LocalTaskAttentionCard, localTaskAttentionLine } from './local-task-attention-row'
import type { LocalTaskSite } from './local-task-endpoint'
+import {
+ LocalTaskNeedsYouList,
+ localTaskBasicCardContext,
+ type LocalTaskCardContext
+} from './local-task-needs-you-list'
import type { LocalTaskAttentionRow } from './local-task-progress-projection'
-import { LocalTaskReplyDrawer } from './local-task-reply-drawer'
-import { LocalTaskSwipeRow } from './local-task-swipe-row'
import { localTaskStyles } from './local-tasks-styles'
-import {
- useLocalTaskAttentionActions,
- type LocalTaskAttentionActions
-} from './use-local-task-attention-actions'
import type { LocalTasksLibrary } from './use-local-tasks-library'
type Props = {
@@ -30,26 +21,16 @@ type Props = {
canWrite: boolean
bottomInset: number
onOpenTask: (taskId: string) => void
- onOpenSession: (item: LocalTaskAttentionItem) => void
+ onOpenSession: (taskSessionId: string) => void
+ /** A chat session previews in a drawer; without one, Preview opens the session. */
+ onPreview?: (taskSessionId: string) => void
+ context?: LocalTaskCardContext
}
-/** "Needs you": the library host's judgement, one row per task; seen and snooze per item or by swipe. */
+/** The "Needs you" segment: only the band the All list leads with, or why it is empty. */
export function LocalTasksAttentionView(props: Props) {
const copy = localTasksCopy()
const work = copy.work
- const actions = useLocalTaskAttentionActions({
- api: props.api,
- library: props.library,
- client: props.site.client,
- copy
- })
- const [snoozing, setSnoozing] = useState(null)
- const [menuFor, setMenuFor] = useState(null)
- const [replying, setReplying] = useState<{ item: LocalTaskAttentionItem; title: string } | null>(
- null
- )
- // Why kept per item in memory: a reply the agent never got must not lose what the user wrote.
- const [drafts, setDrafts] = useState>({})
const snapshot = props.library.work.progress
const connected = props.site.connState === 'connected'
@@ -70,9 +51,11 @@ export function LocalTasksAttentionView(props: Props) {
const message = !snapshot
? work.attentionLoading
: props.rows.length === 0
- ? connected
- ? work.attentionEmpty
- : work.attentionUnverifiable(props.site.label)
+ ? !connected
+ ? work.attentionUnverifiable(props.site.label)
+ : snapshot.sessions.some((session) => session.state === 'unverifiable')
+ ? work.attentionUnverifiableHosts
+ : work.attentionEmpty
: null
if (message) {
return (
@@ -83,113 +66,28 @@ export function LocalTasksAttentionView(props: Props) {
)
}
- const handlers = {
- errors: actions.errors,
- replyFor: (itemKey: string) => actions.replies[itemKey] ?? null,
- onOpenSession: props.onOpenSession,
- onReply: (item: LocalTaskAttentionItem) => {
- const row = props.rows.find((entry) => entry.items.includes(item))
- setReplying({ item, title: row?.task.title ?? '' })
- },
- canWrite: props.canWrite,
- onMarkSeen: (item: LocalTaskAttentionItem) => void actions.markSeen([item]),
- onSnooze: (item: LocalTaskAttentionItem) => setSnoozing([item])
- }
- const now = new Date(props.now)
- const swipeActions = (row: LocalTaskAttentionRow) =>
- props.canWrite
- ? [
- {
- label: work.markSeen,
- tone: 'neutral' as const,
- onPress: () => void actions.markSeen(row.items)
- },
- { label: work.snooze, tone: 'accent' as const, onPress: () => setSnoozing(row.items) }
- ]
- : []
return (
- <>
-
- {connected ? null : (
-
- {work.attentionUnverifiable(props.site.label)}
-
- )}
- {props.rows.map((row) => (
-
- (props.canWrite ? setMenuFor(row) : undefined)}
- />
-
- ))}
-
- menuFor && void actions.markSeen(menuFor.items) },
- { label: work.snooze, onPress: () => menuFor && setSnoozing(menuFor.items) }
- ]}
- onClose={() => setMenuFor(null)}
- />
- localTaskSnoozeUntil(choice, now) !== null
- ).map((choice) => ({
- label: work.snoozeChoices[choice],
- onPress: () => snoozing && void actions.snooze(snoozing, choice)
- }))}
- onClose={() => setSnoozing(null)}
- />
- {
- const key = replying?.item.key
- if (key) {
- setDrafts((previous) => ({ ...previous, [key]: draft }))
- }
- }}
- onSend={(text) => {
- const target = replying
- setReplying(null)
- if (target) {
- sendReply(actions, target.item, text, setDrafts)
- }
- }}
- onClose={() => setReplying(null)}
+
+ {connected ? null : (
+
+ {work.attentionUnverifiable(props.site.label)}
+
+ )}
+
- >
+
)
}
-
-/** Empties the draft as the send goes out; only a send nothing received puts the text back. */
-function sendReply(
- actions: LocalTaskAttentionActions,
- item: LocalTaskAttentionItem,
- text: string,
- setDrafts: (update: (previous: Record) => Record) => void
-) {
- const key = item.key
- setDrafts(({ [key]: _sent, ...rest }) => rest)
- void actions.reply(item, text).then((outcome) => {
- if (outcome.kind === 'not-sent') {
- setDrafts((previous) => ({
- ...previous,
- [key]: previous[key] ? `${text}\n${previous[key]}` : text
- }))
- }
- })
-}
diff --git a/mobile/src/local-tasks/local-tasks-board-view.tsx b/mobile/src/local-tasks/local-tasks-board-view.tsx
index e44b878aa03a..5aed5d9c9414 100644
--- a/mobile/src/local-tasks/local-tasks-board-view.tsx
+++ b/mobile/src/local-tasks/local-tasks-board-view.tsx
@@ -12,6 +12,8 @@ import {
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { formatLocalTaskAgo, localTasksCopy } from './local-tasks-copy'
import type { LocalTaskListGroup, LocalTaskListRow } from './local-task-list-projection'
+import type { LocalTaskRowSession } from './local-task-progress-projection'
+import { LocalTaskSessionDots } from './local-task-session-dots'
import { localTaskStyles } from './local-tasks-styles'
import {
LocalTaskPriorityIcon,
@@ -30,6 +32,9 @@ type Props = {
bottomInset: number
/** Present in one project, where a column is a status a new task can start in. */
onCreateIn?: (statusId: string) => void
+ /** The session dots a card shows, from what the page already loaded. */
+ sessionsFor: (task: LocalTask) => LocalTaskRowSession[]
+ latestLineFor?: (task: LocalTask) => string | null
onOpen: (taskId: string) => void
}
@@ -91,6 +96,10 @@ export function LocalTasksBoardView(props: Props) {
ancestors={localTaskPathFromRoot(row.task, props.tasksById)}
status={group.status}
now={props.now}
+ sessions={props.sessionsFor(row.task)}
+ latestLine={
+ props.latestLineFor?.(row.task) ?? row.task.latestProgress?.body ?? null
+ }
onOpen={props.onOpen}
/>
))}
@@ -118,6 +127,8 @@ function LocalTaskBoardCard(props: {
ancestors: LocalTask[]
status: LocalTaskStatusDef | null
now: number
+ sessions: readonly LocalTaskRowSession[]
+ latestLine: string | null
onOpen: (taskId: string) => void
}) {
const copy = localTasksCopy()
@@ -127,7 +138,7 @@ function LocalTaskBoardCard(props: {
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
onPress={() => props.onOpen(task.id)}
accessibilityRole="button"
- accessibilityLabel={`${task.ref} ${task.title}`}
+ accessibilityLabel={`${task.title} ${task.ref}`}
>
{task.ref}
@@ -151,6 +162,11 @@ function LocalTaskBoardCard(props: {
{task.title}
+ {props.latestLine ? (
+
+ {props.latestLine}
+
+ ) : null}
{task.labels.length > 0 ? (
{task.labels.slice(0, 3).map((label) => (
@@ -173,6 +189,8 @@ function LocalTaskBoardCard(props: {
{copy.updatedAgo(formatLocalTaskAgo(copy, task.updatedAt, props.now))}
)}
+
+
)
diff --git a/mobile/src/local-tasks/local-tasks-copy-en.ts b/mobile/src/local-tasks/local-tasks-copy-en.ts
deleted file mode 100644
index 424b5577494c..000000000000
--- a/mobile/src/local-tasks/local-tasks-copy-en.ts
+++ /dev/null
@@ -1,263 +0,0 @@
-import type {
- LocalTaskEventSource,
- LocalTaskFailureCode
-} from '../../../src/shared/local-tasks/local-task-types'
-import type {
- LocalTaskStatusCategory,
- LocalTaskStatusColor
-} from '../../../src/shared/local-tasks/local-task-model'
-import { LOCAL_TASKS_WORK_COPY_EN } from './local-tasks-work-copy-en'
-
-/** The phone's copy not yet in the mobile catalog, inline English. Mirrors desktop `localTasks.*`. */
-export const LOCAL_TASKS_COPY_EN = {
- back: 'Back',
- retry: 'Retry',
- reconnect: 'Reconnect',
- cancel: 'Cancel',
- save: 'Save',
- create: 'Create',
- edit: 'Edit',
- moreActions: 'More actions',
- delete: 'Delete',
- none: 'None',
- send: 'Send',
- homeCardTitle: 'Local tasks',
- homeCardInProgress: (count: number): string => `${count} in progress`,
- homeCardError: 'Could not read task count',
- pickHostTitle: 'Open local tasks on',
- switchHost: 'Switch host',
- showHostTasks: (machine: string): string => `Show ${machine}'s tasks`,
- sourceLabel: 'Local tasks',
- sourceSubtitle: 'Tasks you write down in Orca',
- workspaceTask: 'Local task',
- workspaceTaskGlyph: (title: string): string => `Local task: ${title}`,
- openWorkspaceTask: (title: string): string => `Open local task ${title}`,
- desktopDisconnected: (machine: string): string =>
- machine ? `Cannot connect to Orca on ${machine}` : "Cannot connect to this host's Orca",
- pairingInvalid: 'Pairing invalid — re-pair with this host',
- unsupported: "This host's Orca does not support local tasks yet. Update Orca on it.",
- statusUnreadable: "Could not read this host's status. Try again once the connection settles.",
- checking: 'Checking the host…',
- libraryStarting: 'The task library is starting…',
- libraryUnavailable: "The task library on this host's Orca could not be opened.",
- libraryUnavailableHint:
- "Its data file could not be read (disk, permissions or a damaged file). The rest of Orca is unaffected; restart this host's Orca and try again.",
- libraryReadOnly:
- 'Read-only: this task library was written by a newer version of Orca, so it can be viewed here but not changed.',
- loadFailed: 'Could not load tasks.',
- loadFailedHint: 'The connection may be unstable. Try again.',
- eventsLoadFailed: 'Could not refresh activity',
- streamEnded: 'Live updates stopped. Reconnect to refresh.',
- showDetails: 'Show details',
- hideDetails: 'Hide details',
- conflictTitle: 'Changed on another device',
- conflictMessage:
- 'Your edits are kept. Overwrite writes the fields you changed onto the latest version; Discard drops your edits and shows the latest.',
- overwrite: 'Overwrite',
- discard: 'Discard my edits',
- allProjects: 'All projects',
- segmentStarted: 'In progress',
- segmentAll: 'All',
- viewList: 'List',
- viewBoard: 'Board',
- newTask: 'New task',
- newSubtask: 'New subtask',
- editTask: 'Edit task',
- noProjects: 'No projects yet',
- noProjectsHint:
- 'Create a project to start tracking tasks. A project can link Orca repositories on this host.',
- createProject: 'New project',
- projectSettings: 'Project settings',
- switchProject: 'Switch project',
- emptyList: 'No tasks here yet',
- filteredEmpty: 'No tasks match the search or filter',
- searchPlaceholder: 'Search ref or title',
- filterTitle: 'Filter',
- filterCategories: 'Status category',
- noLabels: 'No labels in this scope',
- clearFilters: 'Clear',
- showCanceled: (count: number): string => `Show Canceled (${count})`,
- hideCanceled: 'Hide Canceled',
- unknownStatus: 'Unknown status',
- categories: {
- backlog: 'Backlog',
- unstarted: 'Unstarted',
- started: 'Started',
- review: 'Review',
- completed: 'Completed',
- canceled: 'Canceled'
- } satisfies Record,
- defaultStatusNames: {
- backlog: 'Backlog',
- unstarted: 'Todo',
- started: 'In Progress',
- review: 'In Review',
- completed: 'Done',
- canceled: 'Canceled'
- } satisfies Record,
- colors: {
- gray: 'Gray',
- blue: 'Blue',
- yellow: 'Yellow',
- orange: 'Orange',
- green: 'Green',
- red: 'Red',
- purple: 'Purple'
- } satisfies Record,
- priorities: ['No priority', 'Urgent', 'High', 'Medium', 'Low'],
- updatedAgo: (ago: string): string => `Updated ${ago}`,
- justNow: 'just now',
- minutesAgo: (count: number): string => `${count}m ago`,
- hoursAgo: (count: number): string => `${count}h ago`,
- daysAgo: (count: number): string => `${count}d ago`,
- status: 'Status',
- priority: 'Priority',
- labels: 'Labels',
- parent: 'Parent',
- project: 'Project',
- noDescription: 'No description',
- criteria: 'Done when',
- criteriaOptional: 'optional',
- subtasks: 'Subtasks',
- addSubtask: 'Add',
- activity: 'Comments and activity',
- commentPlaceholder: 'Write a comment…',
- editComment: 'Edit comment',
- deleteComment: 'Delete comment',
- deleteCommentConfirm: 'Delete this comment?',
- edited: 'edited',
- taskMissing: 'This task no longer exists.',
- deleteTaskTitle: (title: string): string => `Delete “${title}”?`,
- deleteTaskMessage: (below: number): string =>
- below > 0
- ? `The ${below} tasks under it, at every level, are deleted too. Linked workspaces and sessions are left alone.`
- : 'Linked workspaces and sessions are left alone.',
- eventSources: {
- desktop: 'Desktop',
- mobile: 'Phone',
- cli: 'CLI',
- rule: 'Rule'
- } satisfies Record,
- eventCreated: 'created the task',
- eventTitle: 'renamed the task',
- eventDescription: 'edited the description',
- eventCriteria: 'edited the done criteria',
- eventStatus: (from: string, to: string): string => `changed status ${from} → ${to}`,
- eventPriority: (from: string, to: string): string => `changed priority ${from} → ${to}`,
- eventLabels: 'changed labels',
- eventParent: 'changed the parent',
- eventMoved: (from: string, to: string): string => `moved ${from} → ${to}`,
- eventWorkspaceLinked: (name: string): string => `linked workspace ${name}`,
- eventWorkspaceUnlinked: (name: string): string => `unlinked workspace ${name}`,
- eventWorkspaceHost: (host: string): string => `workspace moved to ${host}`,
- eventSessionLinked: (agent: string): string => `linked a ${agent} session`,
- eventSessionUnlinked: (agent: string): string => `unlinked a ${agent} session`,
- eventComment: 'commented',
- titlePlaceholder: 'Task title',
- descriptionPlaceholder: 'Add a description…',
- criterionPlaceholder: 'A done criterion',
- addCriterion: 'Add one',
- properties: 'Properties',
- labelsPlaceholder: 'Comma separated, e.g. feature, backend',
- subtaskPlaceholder: 'Subtask title; paste several lines to add several',
- continueCreating: 'Create more',
- formHint:
- 'Creating a task only records it; it does not open a workspace. Paste several lines into the subtask field to add several at once.',
- pastedLines: (count: number): string => `Pasted ${count} lines`,
- pasteAsSubtasks: (count: number): string => `Create ${count} subtasks`,
- pasteAsSubtasksHint: (title: string): string => `One per line, under “${title}”`,
- pasteAsTitle: 'Use as one title',
- pasteAsTitleHint: 'Line breaks become spaces',
- savedPartially: 'The task was saved, but its subtasks were not. Add them from the task.',
- subtasksPartiallySaved: 'The subtasks were created, but some properties were not saved.',
- subtasksAlreadyCreated:
- 'These subtasks already exist. Finish saving them or view them from their parent.',
- retrySavingSubtasks: 'Retry saving properties',
- viewCreatedSubtasks: 'View created subtasks',
- settingsTitle: 'Settings',
- projectSection: 'Project',
- name: 'Name',
- reposSection: 'Repos',
- defaultRepo: 'Default',
- noReposHint:
- 'No repo: this project only tracks tasks. Start then picks from every repo on this host.',
- addRepo: 'Add repo',
- noRepoCandidates: 'No other repo on this host to add.',
- makeDefaultRepo: 'Make default',
- removeRepo: 'Remove from project',
- prefix: 'Prefix',
- prefixHint: (aliases: string): string =>
- aliases
- ? `Earlier prefixes ${aliases} stay as aliases: their refs still find these tasks, and no other project can take them.`
- : 'Changing the prefix keeps the old one as an alias: old refs still find these tasks, and no other project can take it.',
- prefixInvalid: 'Use 1–10 capital letters or digits, starting with a letter.',
- renameProject: 'Rename project',
- changePrefix: 'Change prefix',
- changePrefixConfirm: (from: string, to: string): string =>
- `Change ${from} to ${to}? ${from} stays as an alias of this project.`,
- deleteProject: 'Delete project…',
- deleteProjectTitle: (name: string): string => `Delete ${name}?`,
- deleteProjectMessage: (count: number): string =>
- `Its ${count} task records will be deleted. The Orca project, repositories, workspaces and sessions are not touched.`,
- confirmDeleteProject: 'Delete project',
- statusesSection: 'Statuses',
- statusesHint:
- 'The six categories are fixed. Inside one you can add, rename, recolor and reorder statuses; a status never changes category, and each category keeps at least one.',
- addStatus: 'Add status',
- statusTaskCount: (count: number): string => `${count} tasks`,
- newStatusName: 'Status name',
- renameStatus: 'Rename',
- statusColor: 'Color',
- moveUp: 'Move up',
- moveDown: 'Move down',
- deleteStatus: 'Delete status',
- moveTasksTitle: (count: number): string => `Move its ${count} tasks to`,
- newProjectTitle: 'New project',
- newProjectHint:
- 'Start from one of this host’s repos, or from nothing; repos can be added later. Nothing is created until you confirm.',
- emptyProject: 'Empty project',
- emptyProjectHint: 'Only tasks for now; link repos later',
- orcaProjectsUnavailable:
- "This host hasn't published its project list yet, so repos can't be listed. Try again later.",
- orcaProjectLinked: (name: string): string => `Also in ${name}`,
- noOrcaProjects: 'No Orca projects to choose from.',
- confirmCreateProject: (name: string, prefix: string): string =>
- `Create the project ${name} with prefix ${prefix}?`,
- confirmCreateProjectMessage: 'It gets the six default statuses; you can change them later.',
- failures: {
- not_found: 'It no longer exists. The list has been refreshed.',
- stale: 'Someone changed this in the meantime. The latest version is shown; try again.',
- read_only: "The task library is read-only on this host's Orca.",
- invalid_argument: "This host's Orca rejected these values. Check the form and try again.",
- prefix_taken: (owner: string): string =>
- owner ? `That prefix belongs to the project ${owner}.` : 'That prefix is already used.',
- prefix_invalid: 'Use 1–10 capital letters or digits, starting with a letter.',
- prefix_history_in_project: 'This project already used that prefix for task numbers.',
- parent_invalid:
- 'That parent cannot hold this task: it must be in the same project, and not the task itself or one under it.',
- status_invalid: 'That status does not belong to this project.',
- status_in_use: (count: string): string =>
- `${count} tasks still use this status. Pick where to move them first.`,
- last_status_in_category: 'Each category keeps at least one status.',
- comment_not_editable: 'Only comments can be edited.',
- workspace_already_linked: (ref: string): string =>
- ref
- ? `That workspace already belongs to ${ref}.`
- : 'That workspace already belongs to a task.',
- task_has_workspace: (name: string): string =>
- name
- ? `This task already works in “${name}”. Unlink it first.`
- : 'This task already has a workspace. Unlink it first.',
- workspace_required: 'Link or start a workspace for the task first.',
- session_already_linked: (ref: string): string =>
- ref ? `That session is already linked to ${ref}.` : 'That session is already linked.',
- host_not_owned: "That workspace or session is on another Orca; link it in that host's tasks.",
- desktop_only: 'Only a desktop or web client can do this, not the phone.',
- attention_item_mismatch: 'That item belongs to another session, so nothing was marked.'
- } satisfies Record string)>,
- failureGeneric: (code: string): string => `This host's Orca refused this (${code}).`,
- deliveryUnknown:
- "The connection dropped before this host's Orca answered. Try again; it will not be applied twice.",
- writeError: (message: string): string => `Could not reach this host's Orca: ${message}`,
- work: LOCAL_TASKS_WORK_COPY_EN
-}
diff --git a/mobile/src/local-tasks/local-tasks-copy.test.ts b/mobile/src/local-tasks/local-tasks-copy.test.ts
new file mode 100644
index 000000000000..cfa5759d7072
--- /dev/null
+++ b/mobile/src/local-tasks/local-tasks-copy.test.ts
@@ -0,0 +1,21 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { activateMobileUiLocale } from '../i18n/mobile-i18n'
+import { localTasksCopy } from './local-tasks-copy'
+
+describe('localTasksCopy', () => {
+ afterEach(async () => {
+ await activateMobileUiLocale('en')
+ })
+
+ it('follows a language switch made after the copy was first built', async () => {
+ expect(localTasksCopy().work.card.preview).toBe('Preview')
+
+ await activateMobileUiLocale('zh')
+ const copy = localTasksCopy()
+
+ expect(copy.back).toBe('返回')
+ expect(copy.work.card.preview).toBe('速览')
+ expect(copy.work.startWhere('fix-login', 'minizc')).toBe('在 minizc 上新建工作区 fix-login')
+ expect(copy.work.card.duration(3 * 60 * 60_000)).toBe('3 小时')
+ })
+})
diff --git a/mobile/src/local-tasks/local-tasks-copy.ts b/mobile/src/local-tasks/local-tasks-copy.ts
index da4a18c9bda1..2ea99fa45fed 100644
--- a/mobile/src/local-tasks/local-tasks-copy.ts
+++ b/mobile/src/local-tasks/local-tasks-copy.ts
@@ -1,16 +1,58 @@
-import { translate } from '../i18n/mobile-i18n'
-import { LOCAL_TASKS_COPY_EN } from './local-tasks-copy-en'
+import { getMobileUiRevision, translate } from '../i18n/mobile-i18n'
+import { localTasksFailureCopy } from './local-tasks-failure-copy'
+import { localTasksLibraryCopy } from './local-tasks-library-copy'
+import { localTasksProjectCopy } from './local-tasks-project-copy'
+import { localTasksTaskCopy } from './local-tasks-task-copy'
+import { localTasksWorkCopy } from './local-tasks-work-copy'
-export type LocalTasksCopy = typeof LOCAL_TASKS_COPY_EN & { title: string; description: string }
+function buildLocalTasksCopy() {
+ return {
+ ...localTasksLibraryCopy(),
+ ...localTasksTaskCopy(),
+ ...localTasksProjectCopy(),
+ ...localTasksFailureCopy(),
+ work: localTasksWorkCopy(),
+ title: translate('localTasks.title', 'Local tasks'),
+ description: translate('localTasks.description', 'Description'),
+ now: translate('localTasks.now', 'Now'),
+ noProgress: translate('localTasks.noProgress', 'No progress yet'),
+ systemRecords: translate('localTasks.systemRecords', 'System records'),
+ prMergedRule: translate('localTasks.prMergedRule', 'When a PR merges'),
+ prMergedModes: {
+ none: translate('localTasks.prMergedModes.none', 'Keep the current status'),
+ review: translate('localTasks.prMergedModes.review', 'Move to review'),
+ completed: translate('localTasks.prMergedModes.completed', 'Complete the task')
+ },
+ acceptReview: translate('localTasks.acceptReview', 'Accept'),
+ rejectReview: translate('localTasks.rejectReview', 'Send back'),
+ rejectReason: translate('localTasks.rejectReason', 'What needs fixing?'),
+ dispatchPlaceholder: translate('localTasks.dispatchPlaceholder', 'What should the agent do?'),
+ dispatchButton: translate('localTasks.dispatchButton', 'Start task'),
+ dispatchRunOn: translate('localTasks.dispatchRunOn', 'Run on'),
+ dispatchNoSetup: translate(
+ 'localTasks.dispatchNoSetup',
+ 'Choose a project with a repository to start work'
+ ),
+ dispatchHost: translate('localTasks.dispatchHost', 'Host'),
+ dispatchRepo: translate('localTasks.dispatchRepo', 'Repository'),
+ dispatchWorkspace: translate('localTasks.dispatchWorkspace', 'Workspace'),
+ dispatchTask: translate('localTasks.dispatchTask', 'Task'),
+ dispatchTarget: translate('localTasks.dispatchTarget', 'Target'),
+ dispatchChooseTarget: translate('localTasks.dispatchChooseTarget', 'Choose a target')
+ }
+}
-// Why mostly English: this copy predates the mobile catalog; entries move to translate() as
-// they are localized.
+export type LocalTasksCopy = ReturnType
+
+let built: { revision: number; copy: LocalTasksCopy } | undefined
+
+// Rebuilt when the language changes, not per call: task cards ask for it several times per row.
export function localTasksCopy(): LocalTasksCopy {
- return {
- ...LOCAL_TASKS_COPY_EN,
- title: translate('localTasks.title', 'Tasks'),
- description: translate('localTasks.description', 'Description')
+ const revision = getMobileUiRevision()
+ if (built?.revision !== revision) {
+ built = { revision, copy: buildLocalTasksCopy() }
}
+ return built.copy
}
export function formatLocalTaskAgo(copy: LocalTasksCopy, at: number, now: number): string {
diff --git a/mobile/src/local-tasks/local-tasks-failure-copy.ts b/mobile/src/local-tasks/local-tasks-failure-copy.ts
new file mode 100644
index 000000000000..b902763ee97f
--- /dev/null
+++ b/mobile/src/local-tasks/local-tasks-failure-copy.ts
@@ -0,0 +1,127 @@
+import type { LocalTaskFailureCode } from '../../../src/shared/local-tasks/local-task-types'
+import { translate } from '../i18n/mobile-i18n'
+
+/** What the phone says when the host's Orca refuses or loses a write. */
+export function localTasksFailureCopy() {
+ return {
+ failures: {
+ not_found: translate(
+ 'localTasks.failure.notFound',
+ 'It no longer exists. The list has been refreshed.'
+ ),
+ stale: translate(
+ 'localTasks.failure.stale',
+ 'Someone changed this in the meantime. The latest version is shown; try again.'
+ ),
+ read_only: translate(
+ 'localTasks.failure.readOnly',
+ 'Tasks are read-only here because a newer version of Orca wrote them.'
+ ),
+ invalid_argument: translate(
+ 'localTasks.failure.invalidArgument',
+ "This host's Orca rejected these values. Check the form and try again."
+ ),
+ prefix_taken: (owner: string): string =>
+ owner
+ ? translate(
+ 'localTasks.failure.prefixTakenBy',
+ 'That prefix belongs to the project {{owner}}.',
+ { owner }
+ )
+ : translate('localTasks.failure.prefixTaken', 'That prefix is already used.'),
+ prefix_invalid: translate(
+ 'localTasks.prefix.invalid',
+ 'Use 1–10 capital letters or digits, starting with a letter.'
+ ),
+ prefix_history_in_project: translate(
+ 'localTasks.failure.prefixHistoryInProject',
+ 'This project already used that prefix for task numbers.'
+ ),
+ parent_invalid: translate(
+ 'localTasks.failure.parentInvalid',
+ 'That parent cannot hold this task: it must be in the same project, and not the task itself or one under it.'
+ ),
+ status_invalid: translate(
+ 'localTasks.failure.statusInvalid',
+ 'That status does not belong to this project.'
+ ),
+ // The detail arrives as text, so it can't be i18next's numeric `count`.
+ status_in_use: (taskCount: string): string =>
+ translate(
+ 'localTasks.failure.statusInUse',
+ '{{taskCount}} tasks still use this status. Pick where to move them first.',
+ { taskCount }
+ ),
+ last_status_in_category: translate(
+ 'localTasks.failure.lastStatusInCategory',
+ 'Each category keeps at least one status.'
+ ),
+ comment_not_editable: translate(
+ 'localTasks.failure.commentNotEditable',
+ 'Only comments can be edited.'
+ ),
+ // The detail is the holder's ref; the caller swaps in its title when it has the task.
+ workspace_already_linked: (task: string): string =>
+ task
+ ? translate(
+ 'localTasks.failure.workspaceAlreadyLinkedTo',
+ 'This workspace already belongs to “{{task}}”. Unlink it there first.',
+ { task }
+ )
+ : translate(
+ 'localTasks.failure.workspaceAlreadyLinked',
+ 'This workspace already belongs to another task.'
+ ),
+ task_has_workspace: (name: string): string =>
+ name
+ ? translate(
+ 'localTasks.failure.taskHasWorkspaceNamed',
+ 'This task already works in “{{name}}”. Unlink it first.',
+ { name }
+ )
+ : translate(
+ 'localTasks.failure.taskHasWorkspace',
+ 'This task already has a workspace. Unlink it first.'
+ ),
+ workspace_required: translate(
+ 'localTasks.failure.workspaceRequired',
+ 'Link or start a workspace for the task first.'
+ ),
+ session_already_linked: (task: string): string =>
+ task
+ ? translate(
+ 'localTasks.failure.sessionAlreadyLinkedTo',
+ 'This session is already linked to “{{task}}”.',
+ { task }
+ )
+ : translate(
+ 'localTasks.failure.sessionAlreadyLinked',
+ 'This session is already linked to a task.'
+ ),
+ host_not_owned: translate(
+ 'localTasks.failure.hostNotOwned',
+ "That workspace or session is on another Orca; link it in that host's tasks."
+ ),
+ desktop_only: translate(
+ 'localTasks.failure.desktopOnly',
+ 'Only a desktop or web client can do this, not the phone.'
+ ),
+ attention_item_mismatch: translate(
+ 'localTasks.failure.attentionItemMismatch',
+ 'That item belongs to another session, so nothing was marked.'
+ )
+ } satisfies Record string)>,
+ failureGeneric: (code: string): string =>
+ translate('localTasks.failure.generic', "This host's Orca refused this ({{code}}).", {
+ code
+ }),
+ deliveryUnknown: translate(
+ 'localTasks.failure.deliveryUnknown',
+ "The connection dropped before this host's Orca answered. Try again; it will not be applied twice."
+ ),
+ writeError: (message: string): string =>
+ translate('localTasks.failure.writeError', 'Could not save the change: {{message}}', {
+ message
+ })
+ }
+}
diff --git a/mobile/src/local-tasks/local-tasks-failure-recovery.test.tsx b/mobile/src/local-tasks/local-tasks-failure-recovery.test.tsx
index d2ba7892b0c2..43f2d445d1d5 100644
--- a/mobile/src/local-tasks/local-tasks-failure-recovery.test.tsx
+++ b/mobile/src/local-tasks/local-tasks-failure-recovery.test.tsx
@@ -275,7 +275,6 @@ describe('mobile task failure recovery', () => {
const library = testLibrary({ acceptTasks: vi.fn(), reload: vi.fn(), tasks: [parent] })
const draft = {
...initialLocalTaskDraft({ kind: 'subtask' as const, parent }),
- criteria: [{ id: 'c', text: 'passes review' }],
labelsText: 'frontend',
statusId: 'chosen',
priority: 2 as const
@@ -307,77 +306,10 @@ describe('mobile task failure recovery', () => {
taskIds: ['child'],
patch: { statusId: 'chosen', priority: 2, addLabels: ['frontend'] }
})
- expect(updateTask).toHaveBeenCalledWith(
- expect.objectContaining({
- taskId: 'child',
- patch: { criteria: ['passes review'] }
- })
- )
+ expect(updateTask).not.toHaveBeenCalled()
expect(model?.partialTaskId).toBeNull()
})
- it('applies edited fields to the same children after a later criterion fails', async () => {
- const parent = task('parent')
- const children = [task('first'), task('second')]
- const createSubtasks = vi.fn(async () => ({
- kind: 'ok',
- value: { ok: true, tasks: children }
- }))
- const batchUpdate = vi.fn(async () => ({
- kind: 'ok',
- value: { ok: true }
- }))
- const updateTask = vi
- .fn()
- .mockResolvedValueOnce({ kind: 'ok', value: { ok: true, task: children[0] } })
- .mockResolvedValueOnce({ kind: 'refused', code: 'stale', detail: null })
- .mockResolvedValue({ kind: 'ok', value: { ok: true, task: children[0] } })
- const library = testLibrary({ acceptTasks: vi.fn(), reload: vi.fn(), tasks: [parent] })
- let model: ReturnType | undefined
- function Probe() {
- model = useLocalTaskFormSubmit(testApi({ createSubtasks, batchUpdate, updateTask }), library)
- return null
- }
- const base = initialLocalTaskDraft({ kind: 'subtask', parent })
- const firstDraft = {
- ...base,
- priority: 2 as const,
- labelsText: 'old',
- criteria: [{ id: 'c', text: 'old criterion' }]
- }
- const revised = {
- ...base,
- priority: 0 as const,
- labelsText: 'new',
- criteria: [{ id: 'c', text: 'new criterion' }]
- }
- await act(async () => {
- renderer = create(createElement(Probe))
- })
- await act(async () => {
- expect(await model?.createSubtasks(parent, ['one', 'two'], firstDraft)).toBe(false)
- })
- await act(async () => {
- expect(await model?.retrySubtasks(parent, revised)).toBe(true)
- })
- expect(createSubtasks).toHaveBeenCalledOnce()
- expect(batchUpdate).toHaveBeenCalledTimes(2)
- expect(batchUpdate).toHaveBeenNthCalledWith(
- 2,
- expect.objectContaining({
- patch: expect.objectContaining({
- priority: 0,
- addLabels: ['new'],
- removeLabels: ['old']
- })
- })
- )
- expect(updateTask.mock.calls.slice(2).map((call) => call[0])).toEqual([
- expect.objectContaining({ taskId: 'first', patch: { criteria: ['new criterion'] } }),
- expect.objectContaining({ taskId: 'second', patch: { criteria: ['new criterion'] } })
- ])
- })
-
it('retains the multi-line intent and operation id when the create reply is unknown', async () => {
const parent = task('parent')
const child = task('child')
@@ -454,52 +386,4 @@ describe('mobile task failure recovery', () => {
batchUpdate.mock.calls[0]?.[0].clientOperationId
)
})
-
- it('settles an unknown criterion write before applying a changed criterion', async () => {
- const parent = task('parent')
- const child = task('child')
- const createSubtasks = vi.fn(async () => ({
- kind: 'ok',
- value: { ok: true, tasks: [child] }
- }))
- const updateTask = vi
- .fn()
- .mockResolvedValueOnce({ kind: 'unknown' })
- .mockResolvedValue({ kind: 'ok', value: { ok: true, task: child } })
- const library = testLibrary({ acceptTasks: vi.fn(), reload: vi.fn(), tasks: [parent] })
- let model: ReturnType | undefined
- function Probe() {
- model = useLocalTaskFormSubmit(testApi({ createSubtasks, updateTask }), library)
- return null
- }
- const base = initialLocalTaskDraft({ kind: 'subtask', parent })
- await act(async () => {
- renderer = create(createElement(Probe))
- })
- await act(async () => {
- expect(
- await model?.createSubtasks(parent, ['one'], {
- ...base,
- criteria: [{ id: 'c', text: 'old' }]
- })
- ).toBe(false)
- })
- await act(async () => {
- expect(
- await model?.retrySubtasks(parent, {
- ...base,
- criteria: [{ id: 'c', text: 'new' }]
- })
- ).toBe(true)
- })
- expect(createSubtasks).toHaveBeenCalledOnce()
- expect(updateTask).toHaveBeenCalledTimes(3)
- expect(updateTask.mock.calls[1]?.[0].clientOperationId).toBe(
- updateTask.mock.calls[0]?.[0].clientOperationId
- )
- expect(updateTask).toHaveBeenNthCalledWith(
- 3,
- expect.objectContaining({ patch: { criteria: ['new'] } })
- )
- })
})
diff --git a/mobile/src/local-tasks/local-tasks-filter-bar.tsx b/mobile/src/local-tasks/local-tasks-filter-bar.tsx
index ce02ade26c78..ca8646b96e73 100644
--- a/mobile/src/local-tasks/local-tasks-filter-bar.tsx
+++ b/mobile/src/local-tasks/local-tasks-filter-bar.tsx
@@ -21,7 +21,7 @@ import {
// The order the priority picker uses: urgent first, "no priority" last.
const PRIORITY_ORDER = [1, 2, 3, 4, 0] as const
-/** Search by ref or title, and a sheet for category, priority and labels. */
+/** Search by title or ref, and a sheet for "waiting on me", category, priority and labels. */
export function LocalTasksFilterBar(props: {
filter: LocalTaskFilter
labels: string[]
@@ -78,6 +78,13 @@ export function LocalTasksFilterBar(props: {
) : null}
+
+ set({ needsYou: !filter.needsYou })}
+ />
+
{LOCAL_TASK_STATUS_CATEGORIES.map((category) => (
+ translate('localTasks.home.cardInProgress', '{{count}} in progress', { count }),
+ homeCardError: translate('localTasks.home.cardError', 'Could not read task count'),
+ pickHostTitle: translate('localTasks.host.pickTitle', 'Open local tasks on'),
+ switchHost: translate('localTasks.host.switch', 'Switch host'),
+ showHostTasks: (machine: string): string =>
+ translate('localTasks.host.showTasks', "Show {{machine}}'s tasks", { machine }),
+ sourceLabel: translate('localTasks.source.label', 'Local tasks'),
+ sourceSubtitle: translate('localTasks.source.subtitle', 'Tasks you write down in Orca'),
+ workspaceTask: translate('localTasks.workspaceTask.label', 'Local task'),
+ workspaceTaskGlyph: (title: string): string =>
+ translate('localTasks.workspaceTask.glyph', 'Local task: {{title}}', { title }),
+ openWorkspaceTask: (title: string): string =>
+ translate('localTasks.workspaceTask.open', 'Open local task {{title}}', { title }),
+ desktopDisconnected: (machine: string): string =>
+ machine
+ ? translate('localTasks.host.disconnected', 'Orca on {{machine}} is not connected', {
+ machine
+ })
+ : translate('localTasks.host.disconnectedUnnamed', 'This host’s Orca is not connected'),
+ pairingInvalid: translate(
+ 'localTasks.host.pairingInvalid',
+ 'Pairing invalid — re-pair with this host'
+ ),
+ unsupported: translate(
+ 'localTasks.host.unsupported',
+ "This host's Orca does not support local tasks yet. Update Orca on it."
+ ),
+ statusUnreadable: translate(
+ 'localTasks.host.statusUnreadable',
+ "Could not read this host's status. Try again once the connection settles."
+ ),
+ checking: translate('localTasks.host.checking', 'Checking the host…'),
+ libraryStarting: translate('localTasks.library.starting', 'The task library is starting…'),
+ libraryUnavailable: translate(
+ 'localTasks.library.unavailable',
+ "The task library on this host's Orca could not be opened."
+ ),
+ libraryUnavailableHint: translate(
+ 'localTasks.library.unavailableHint',
+ "Its data file could not be read (disk, permissions or a damaged file). The rest of Orca is unaffected; restart this host's Orca and try again."
+ ),
+ libraryReadOnly: translate(
+ 'localTasks.library.readOnly',
+ 'Read-only: this task library was written by a newer version of Orca, so it can be viewed here but not changed.'
+ ),
+ loadFailed: translate('localTasks.library.loadFailed', 'Could not load tasks.'),
+ loadFailedHint: translate(
+ 'localTasks.library.loadFailedHint',
+ 'The connection may be unstable. Try again.'
+ ),
+ eventsLoadFailed: translate(
+ 'localTasks.library.eventsLoadFailed',
+ 'Could not refresh activity'
+ ),
+ streamEnded: translate(
+ 'localTasks.library.streamEnded',
+ 'Live updates stopped. Reconnect to refresh.'
+ ),
+ showDetails: translate('localTasks.library.showDetails', 'Show details'),
+ hideDetails: translate('localTasks.library.hideDetails', 'Hide details'),
+ conflictTitle: translate('localTasks.conflict.title', 'Changed on another device'),
+ conflictMessage: translate(
+ 'localTasks.conflict.message',
+ 'Your edits are kept. Overwrite writes the fields you changed onto the latest version; Discard drops your edits and shows the latest.'
+ ),
+ overwrite: translate('localTasks.conflict.overwrite', 'Overwrite'),
+ discard: translate('localTasks.conflict.discard', 'Discard my edits'),
+ allProjects: translate('localTasks.list.allProjects', 'All projects'),
+ segmentStarted: translate('localTasks.list.segmentStarted', 'In Progress'),
+ segmentAll: translate('localTasks.list.segmentAll', 'All'),
+ viewList: translate('localTasks.list.viewList', 'List'),
+ viewBoard: translate('localTasks.list.viewBoard', 'Board'),
+ newTask: translate('localTasks.list.newTask', 'New task'),
+ newSubtask: translate('localTasks.list.newSubtask', 'New subtask'),
+ editTask: translate('localTasks.list.editTask', 'Edit task'),
+ noProjects: translate('localTasks.list.noProjects', 'No projects yet'),
+ noProjectsHint: translate(
+ 'localTasks.list.noProjectsHint',
+ 'Create a project to start writing down tasks. A project can link repositories on this host.'
+ ),
+ createProject: translate('localTasks.list.createProject', 'New project'),
+ projectSettings: translate('localTasks.list.projectSettings', 'Project settings'),
+ switchProject: translate('localTasks.list.switchProject', 'Switch project'),
+ emptyList: translate('localTasks.list.empty', 'No tasks here yet'),
+ filteredEmpty: translate(
+ 'localTasks.list.filteredEmpty',
+ 'No tasks match the search or filter'
+ ),
+ searchPlaceholder: translate('localTasks.list.searchPlaceholder', 'Search title or ref'),
+ filterTitle: translate('localTasks.filter.title', 'Filter'),
+ filterNeedsYou: translate('localTasks.filter.needsYou', 'A session is waiting on you'),
+ filterCategories: translate('localTasks.filter.categories', 'Status category'),
+ noLabels: translate('localTasks.filter.noLabels', 'No labels in this scope'),
+ clearFilters: translate('localTasks.filter.clear', 'Clear'),
+ showCanceled: (count: number): string =>
+ translate('localTasks.filter.showCanceled', 'Show Canceled ({{count}})', { count }),
+ hideCanceled: translate('localTasks.filter.hideCanceled', 'Hide Canceled'),
+ unknownStatus: translate('localTasks.list.unknownStatus', 'Unknown status'),
+ categories: {
+ backlog: translate('localTasks.category.backlog', 'Backlog'),
+ unstarted: translate('localTasks.category.unstarted', 'Unstarted'),
+ started: translate('localTasks.category.started', 'In Progress'),
+ review: translate('localTasks.category.review', 'Review'),
+ completed: translate('localTasks.category.completed', 'Completed'),
+ canceled: translate('localTasks.category.canceled', 'Canceled')
+ } satisfies Record,
+ // The names a new project's statuses get, the same as the desktop writes.
+ defaultStatusNames: {
+ backlog: translate('localTasks.defaultStatus.backlog', 'Backlog'),
+ unstarted: translate('localTasks.defaultStatus.unstarted', 'Todo'),
+ started: translate('localTasks.defaultStatus.started', 'In Progress'),
+ review: translate('localTasks.defaultStatus.review', 'Ready for acceptance'),
+ completed: translate('localTasks.defaultStatus.completed', 'Done'),
+ canceled: translate('localTasks.defaultStatus.canceled', 'Canceled')
+ } satisfies Record,
+ colors: {
+ gray: translate('localTasks.color.gray', 'Gray'),
+ blue: translate('localTasks.color.blue', 'Blue'),
+ yellow: translate('localTasks.color.yellow', 'Yellow'),
+ orange: translate('localTasks.color.orange', 'Orange'),
+ green: translate('localTasks.color.green', 'Green'),
+ red: translate('localTasks.color.red', 'Red'),
+ purple: translate('localTasks.color.purple', 'Purple')
+ } satisfies Record,
+ priorities: [
+ translate('localTasks.priority.none', 'No priority'),
+ translate('localTasks.priority.urgent', 'Urgent'),
+ translate('localTasks.priority.high', 'High'),
+ translate('localTasks.priority.medium', 'Medium'),
+ translate('localTasks.priority.low', 'Low')
+ ],
+ updatedAgo: (ago: string): string =>
+ translate('localTasks.time.updatedAgo', 'Updated {{ago}}', { ago }),
+ justNow: translate('localTasks.time.justNow', 'just now'),
+ minutesAgo: (count: number): string =>
+ translate('localTasks.time.minutesAgo', '{{count}}m ago', { count }),
+ hoursAgo: (count: number): string =>
+ translate('localTasks.time.hoursAgo', '{{count}}h ago', { count }),
+ daysAgo: (count: number): string =>
+ translate('localTasks.time.daysAgo', '{{count}}d ago', { count })
+ }
+}
diff --git a/mobile/src/local-tasks/local-tasks-library-header.test.tsx b/mobile/src/local-tasks/local-tasks-library-header.test.tsx
new file mode 100644
index 000000000000..2791800567c4
--- /dev/null
+++ b/mobile/src/local-tasks/local-tasks-library-header.test.tsx
@@ -0,0 +1,60 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, expect, it, vi } from 'vitest'
+import { localTasksCopy } from './local-tasks-copy'
+import { LocalTasksLibraryHeader } from './local-tasks-library-header'
+
+vi.mock('react-native', () => ({
+ Pressable: 'Pressable',
+ Text: 'Text',
+ View: 'View',
+ StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }
+}))
+vi.mock('lucide-react-native', () => ({
+ ChevronDown: 'ChevronDown',
+ ChevronLeft: 'ChevronLeft',
+ Columns3: 'Columns3',
+ Layers: 'Layers',
+ List: 'List',
+ Plus: 'Plus',
+ Search: 'Search',
+ Server: 'Server',
+ Settings: 'Settings'
+}))
+vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
+
+let renderer: ReactTestRenderer | null = null
+afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+})
+
+it('reaches another host from an empty library, where no project can be picked', () => {
+ const onPickHost = vi.fn()
+ act(() => {
+ renderer = create(
+ createElement(LocalTasksLibraryHeader, {
+ projects: [],
+ projectId: null,
+ onSelectProject: vi.fn(),
+ hostLabel: 'minizc',
+ onPickHost,
+ view: 'list',
+ showViewToggle: false,
+ searchActive: false,
+ onExit: vi.fn(),
+ onSettings: vi.fn(),
+ onView: vi.fn()
+ })
+ )
+ })
+ const scope = renderer!.root.findByProps({ testID: 'local-tasks-scope' })
+ expect(scope.props.disabled).toBe(false)
+ act(() => scope.props.onPress())
+ const sheet = renderer!.root.find((node) => Array.isArray(node.props.actions))
+ const actions: { label: string }[] = sheet.props.actions
+ const labels = actions.map((action) => action.label)
+ const copy = localTasksCopy()
+ expect(labels).toContain(copy.switchHost)
+ expect(labels).not.toContain(copy.allProjects)
+})
diff --git a/mobile/src/local-tasks/local-tasks-library-header.tsx b/mobile/src/local-tasks/local-tasks-library-header.tsx
index 99d112fc4772..b4f064da8eb7 100644
--- a/mobile/src/local-tasks/local-tasks-library-header.tsx
+++ b/mobile/src/local-tasks/local-tasks-library-header.tsx
@@ -1,3 +1,4 @@
+import { useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import {
ChevronDown,
@@ -5,119 +6,183 @@ import {
Columns3,
Layers,
List,
+ Plus,
+ Search,
Server,
Settings
} from 'lucide-react-native'
import type { LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
-import { colors } from '../theme/mobile-theme'
+import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal'
+import { colors, typography } from '../theme/mobile-theme'
import { localTasksCopy } from './local-tasks-copy'
import { localTaskStyles } from './local-tasks-styles'
export type LocalTasksViewMode = 'list' | 'board'
-/** Back, settings and the list/board toggle, then the title with the host and project switches. */
+/**
+ * Back, the title, New task and Search, as the prototype's header. The title also names the scope
+ * (all projects or one, and the host when several are reachable) and opens the page's switches:
+ * project, host, project settings, list or board.
+ */
export function LocalTasksLibraryHeader(props: {
- project: LocalTaskProject | null
- hasProjects: boolean
+ projects: readonly LocalTaskProject[]
+ /** Null for all projects. */
+ projectId: string | null
+ onSelectProject: (projectId: string | null) => void
+ onCreateProject?: () => void
/** Shown only while the page reaches more than one host's library (D-008). */
hostLabel: string | null
onPickHost: () => void
view: LocalTasksViewMode
/** Off where list and board mean nothing: with no project, and in "Needs you". */
showViewToggle: boolean
+ /** Absent where nothing can be created: a read-only library, or one with no project. */
+ onNewTask?: () => void
+ /** Absent where there is nothing to search ("Needs you"). */
+ onSearch?: () => void
+ searchActive: boolean
onExit: () => void
onSettings: () => void
onView: (view: LocalTasksViewMode) => void
- onPickProject: () => void
}) {
const copy = localTasksCopy()
+ const [menuOpen, setMenuOpen] = useState(false)
+ const project = props.projects.find((entry) => entry.id === props.projectId) ?? null
+ const hasProjects = props.projects.length > 0
+ // Why not only with projects: an empty library still needs the way to another host's library.
+ const canOpen = hasProjects || props.hostLabel !== null || props.onCreateProject !== undefined
+ const scope = [
+ project ? `${project.name} · ${project.prefix}` : copy.allProjects,
+ props.hostLabel
+ ]
+ .filter(Boolean)
+ .join(' · ')
+ const actions: ActionSheetAction[] = [
+ ...(hasProjects
+ ? [
+ {
+ label: copy.allProjects,
+ icon: Layers,
+ checked: props.projectId === null,
+ onPress: () => props.onSelectProject(null)
+ }
+ ]
+ : []),
+ ...props.projects.map((entry) => ({
+ label: entry.name,
+ hint: entry.prefix,
+ checked: entry.id === props.projectId,
+ onPress: () => props.onSelectProject(entry.id)
+ })),
+ ...(props.onCreateProject
+ ? [
+ {
+ label: copy.createProject,
+ icon: Plus,
+ closeBeforePress: true,
+ onPress: props.onCreateProject
+ }
+ ]
+ : []),
+ ...(props.hostLabel !== null
+ ? [
+ {
+ label: copy.switchHost,
+ hint: props.hostLabel,
+ icon: Server,
+ closeBeforePress: true,
+ onPress: props.onPickHost
+ }
+ ]
+ : []),
+ ...(props.showViewToggle
+ ? (['list', 'board'] as const).map((mode) => ({
+ label: mode === 'list' ? copy.viewList : copy.viewBoard,
+ icon: mode === 'list' ? List : Columns3,
+ checked: props.view === mode,
+ onPress: () => props.onView(mode)
+ }))
+ : []),
+ {
+ label: copy.projectSettings,
+ icon: Settings,
+ closeBeforePress: true,
+ onPress: props.onSettings
+ }
+ ]
return (
- <>
-
-
-
- {copy.back}
-
-
- {props.hasProjects ? (
-
-
-
- ) : null}
- {props.showViewToggle ? (
-
- {(['list', 'board'] as const).map((mode) => {
- const Icon = mode === 'list' ? List : Columns3
- const active = props.view === mode
- return (
- props.onView(mode)}
- accessibilityRole="button"
- accessibilityState={{ selected: active }}
- accessibilityLabel={mode === 'list' ? copy.viewList : copy.viewBoard}
- >
-
-
- )
- })}
+
+
+
+ {copy.back}
+
+ setMenuOpen(true)}
+ accessibilityRole="button"
+ accessibilityLabel={copy.switchProject}
+ testID="local-tasks-scope"
+ >
+
+ {copy.title}
+
+ {canOpen ? (
+
+
+ {hasProjects ? scope : (props.hostLabel ?? copy.createProject)}
+
+
) : null}
-
-
- {copy.title}
- {props.hostLabel !== null ? (
+
+
+ {props.onNewTask ? (
-
-
- {props.hostLabel}
-
-
+
) : null}
- {props.hasProjects ? (
+ {props.onSearch ? (
-
-
- {props.project?.name ?? copy.allProjects}
-
- {props.project ? (
- {props.project.prefix}
- ) : null}
-
+
) : null}
- >
+ setMenuOpen(false)}
+ />
+
)
}
const styles = StyleSheet.create({
- viewToggle: {
- flexDirection: 'row',
- backgroundColor: colors.bgPanel,
- borderRadius: 8,
- padding: 2
- }
+ // Equal sides keep the title centered whichever buttons show.
+ side: { width: 92 },
+ trailing: { flexDirection: 'row', justifyContent: 'flex-end', gap: 2 },
+ title: { flex: 1, minWidth: 0, alignItems: 'center' },
+ titleText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
+ scope: { flexDirection: 'row', alignItems: 'center', gap: 2, maxWidth: '100%' },
+ scopeText: { flexShrink: 1, color: colors.textMuted, fontSize: typography.metaSize }
})
diff --git a/mobile/src/local-tasks/local-tasks-library-screen.tsx b/mobile/src/local-tasks/local-tasks-library-screen.tsx
index 71451289f52e..40b01b4bab2a 100644
--- a/mobile/src/local-tasks/local-tasks-library-screen.tsx
+++ b/mobile/src/local-tasks/local-tasks-library-screen.tsx
@@ -1,45 +1,39 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useState } from 'react'
import { Pressable, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Plus } from 'lucide-react-native'
-import { localTaskAttentionClearsOnView } from '../../../src/shared/local-tasks/local-task-attention-rules'
import { MobileSegmentedControl } from '../components/MobileSegmentedControl'
import { PickerListDrawer } from '../components/PickerListDrawer'
import { useBackClaim } from '../navigation/use-back-claim'
-import { colors, spacing } from '../theme/mobile-theme'
+import { colors } from '../theme/mobile-theme'
import type { LocalTasksApi } from './local-tasks-api'
import type { LocalTaskEndpoint, LocalTaskSite } from './local-task-endpoint'
import { localTasksCopy } from './local-tasks-copy'
import { LocalTaskDetailView } from './local-task-detail-view'
+import { LocalTaskDispatchInput } from './local-task-dispatch-input'
import type { LocalTaskFormMode } from './local-task-form-state'
import { LocalTaskFormView } from './local-task-form-view'
-import {
- buildLocalTaskGroups,
- localTaskSegmentCounts,
- localTasksInScope,
- localTaskStatusLookup,
- type LocalTaskSegment
-} from './local-task-list-projection'
+import { localTasksInScope, type LocalTaskSegment } from './local-task-list-projection'
import {
EMPTY_LOCAL_TASK_FILTER,
isLocalTaskFilterActive,
localTaskFilterLabels,
type LocalTaskFilter
} from './local-task-filter'
-import { LocalTaskProjectPicker } from './local-task-pickers'
import { LocalTasksFilterBar } from './local-tasks-filter-bar'
import { LocalTasksBoardView } from './local-tasks-board-view'
import { LocalTasksLibraryHeader, type LocalTasksViewMode } from './local-tasks-library-header'
import { LocalTasksListView } from './local-tasks-list-view'
import { LocalTasksAttentionView } from './local-tasks-attention-view'
+import { LocalTaskNeedsYouList } from './local-task-needs-you-list'
import {
- localTaskAttentionItemCount,
- localTaskAttentionRows,
- localTaskItemsForSession,
- localTaskWorkspaceLinkFor
+ localTaskAttentionTaskCount,
+ type LocalTaskAttentionRow
} from './local-task-progress-projection'
-import { createLocalTaskOperationIds, sendLocalTaskWrite } from './local-task-operation-ids'
-import { localTaskSessionHref } from './local-task-work-navigation'
+import { LocalTaskPreviewDrawer } from './local-task-preview-drawer'
+import { localTaskChatPreviewTarget, openLinkedLocalTaskSession } from './local-task-session-open'
+import { useLocalTaskViewMemory } from './local-task-view-memory'
+import { useLocalTasksLibraryModel } from './use-local-tasks-library-model'
import { LocalTasksProjectCreateView } from './local-tasks-project-create-view'
import { LocalTasksSettingsView } from './local-tasks-settings-view'
import { localTaskStyles } from './local-tasks-styles'
@@ -74,19 +68,27 @@ type Segment = 'attention' | LocalTaskSegment
export function LocalTasksLibraryScreen(props: Props) {
const copy = localTasksCopy()
const insets = useSafeAreaInsets()
- const { library } = props
- // Undefined until the user picks: then the first project, so a one-project library opens on it.
- const [chosenProjectId, setChosenProjectId] = useState(undefined)
- const [segment, setSegment] = useState(props.initialView ?? 'all')
- const [view, setView] = useState('list')
+ const { library, site } = props
+ // Remembered per paired host and library. Home's "Needs you" opens on it with All projects,
+ // since home counts every project (REQ-006).
+ const [remembered, remember] = useLocalTaskViewMemory(
+ `${site.hostId}\u0000${site.endpoint.key}`,
+ { projectId: null, segment: 'all', view: 'list' },
+ props.initialView === 'attention' ? { projectId: null, segment: 'attention' } : null
+ )
+ const { segment, view } = remembered
+ const setSegment = (next: Segment) => remember({ segment: next })
+ const setView = (next: LocalTasksViewMode) => remember({ view: next })
+ const setChosenProjectId = (next: string | null) => remember({ projectId: next })
const [stack, setStack] = useState(() =>
props.initialTaskId ? [{ kind: 'detail', taskId: props.initialTaskId }] : []
)
- const [pickerOpen, setPickerOpen] = useState(false)
const [hostPickerOpen, setHostPickerOpen] = useState(false)
const [filter, setFilter] = useState(EMPTY_LOCAL_TASK_FILTER)
+ const [searchOpen, setSearchOpen] = useState(false)
+ const [notice, setNotice] = useState(null)
+ const [previewSessionId, setPreviewSessionId] = useState(null)
const top = stack.at(-1) ?? null
- const { site } = props
const orcaProjects = useOrcaProjectChoices(
top?.kind === 'settings' || top?.kind === 'newProject' ? site.pairedClient : null,
site.endpoint
@@ -103,51 +105,36 @@ export function LocalTasksLibraryScreen(props: Props) {
: null
)
- const projectId =
- chosenProjectId === undefined
- ? (library.projects[0]?.id ?? null)
- : library.projects.some((project) => project.id === chosenProjectId)
- ? chosenProjectId
- : null
+ const chosenProjectId = remembered.projectId
+ // Unchosen opens on all projects, as the desktop does; a deleted project falls back to them too.
+ const projectId = library.projects.some((project) => project.id === chosenProjectId)
+ ? chosenProjectId
+ : null
const project = library.projects.find((entry) => entry.id === projectId) ?? null
// Why tied to the data: relative times move when the library does, and no clock ticks for them.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
setNow(Date.now())
}, [library.tasks, library.eventsVersion])
- const lookup = useMemo(() => localTaskStatusLookup(library.projects), [library.projects])
- const counts = localTaskSegmentCounts(library.tasks, library.projects, projectId)
- const groups = buildLocalTaskGroups({
- tasks: library.tasks,
- projects: library.projects,
- projectId,
- segment: segment === 'attention' ? 'all' : segment,
- filter,
- nestSubtasks: view === 'list',
- keepEmpty: view === 'board'
- })
+ const model = useLocalTasksLibraryModel({ site, library, projectId, segment, view, filter })
+ const { lookup, counts, inContact, attentionRows, groups, listBand, cards, tasksById } = model
const openTask = (taskId: string) => push({ kind: 'detail', taskId })
- const tasksById = new Map(library.tasks.map((task) => [task.id, task]))
- const attentionRows = localTaskAttentionRows({
- snapshot: library.work.progress,
- tasks: library.tasks,
- projectId
- })
- const inContact = site.connState === 'connected'
- const openSession = (taskSessionId: string) => {
- const session = library.work.links.sessions.find((entry) => entry.id === taskSessionId)
- const owner = session ? tasksById.get(session.taskId) : undefined
- const href = session
- ? localTaskSessionHref({
- hostId: site.hostId,
- endpoint: site.endpoint,
- session,
- workspace: owner ? localTaskWorkspaceLinkFor(owner, tasksById, library.work.links) : null
- })
- : null
- if (href) {
- acknowledgeOpened(props.api, library, taskSessionId)
- props.onNavigate(href)
+ const openSession = (taskSessionId: string) =>
+ openLinkedLocalTaskSession({
+ site,
+ api: props.api,
+ library,
+ copy,
+ taskSessionId,
+ onNavigate: props.onNavigate
+ })
+ // Q2: a chat session is answered in the drawer; a terminal one opens its own page.
+ const previewSession = (taskSessionId: string) => {
+ if (localTaskChatPreviewTarget(library, taskSessionId)) {
+ setNotice(null)
+ setPreviewSessionId(taskSessionId)
+ } else {
+ setNotice(openSession(taskSessionId))
}
}
const createIn = (statusId?: string) => {
@@ -183,6 +170,8 @@ export function LocalTasksLibraryScreen(props: Props) {
api={props.api}
mode={top.mode}
readOnly={props.readOnly}
+ site={site}
+ onNavigate={props.onNavigate}
onClose={pop}
// A new task opens on its detail; an edit or a subtask returns to the detail it came from.
onDone={(taskId) =>
@@ -228,19 +217,43 @@ export function LocalTasksLibraryScreen(props: Props) {
)
}
+ const canWrite = props.canRead && !props.readOnly
+ const searchShown = segment !== 'attention' && (searchOpen || isLocalTaskFilterActive(filter))
+ const needsYouList = (rows: LocalTaskAttentionRow[]) => (
+ setNotice(openSession(id))}
+ />
+ )
return (
0}
+ projects={library.projects}
+ projectId={projectId}
+ onSelectProject={setChosenProjectId}
+ onCreateProject={props.readOnly ? undefined : () => push({ kind: 'newProject' })}
hostLabel={showHosts ? site.label : null}
onPickHost={() => setHostPickerOpen(true)}
view={view}
showViewToggle={library.projects.length > 0 && segment !== 'attention'}
+ onNewTask={props.readOnly || library.projects.length === 0 ? undefined : () => createIn()}
+ onSearch={
+ library.projects.length > 0 && segment !== 'attention'
+ ? () => setSearchOpen((open) => !open)
+ : undefined
+ }
+ searchActive={searchShown}
onExit={props.onExit}
onSettings={() => push({ kind: 'settings', projectId })}
onView={setView}
- onPickProject={() => setPickerOpen(true)}
/>
{library.projects.length === 0 ? (
@@ -259,6 +272,17 @@ export function LocalTasksLibraryScreen(props: Props) {
) : (
<>
+ {!props.readOnly && (project ?? library.projects[0]) ? (
+
+ ) : null}
{
+ setNotice(null)
+ setSegment(next)
+ }}
/>
- {segment === 'attention' ? null : (
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+ {searchShown ? (
- )}
+ ) : null}
{segment === 'attention' ? (
openSession(item.taskSessionId)}
+ onPreview={previewSession}
+ onOpenSession={(id) => setNotice(openSession(id))}
/>
) : view === 'list' ? (
) : (
@@ -315,29 +364,22 @@ export function LocalTasksLibraryScreen(props: Props) {
now={now}
bottomInset={insets.bottom}
onCreateIn={props.readOnly || !project ? undefined : createIn}
+ sessionsFor={model.sessionsFor}
+ latestLineFor={model.latestLineFor}
onOpen={openTask}
/>
)}
- {props.readOnly ? null : (
- createIn()}
- accessibilityRole="button"
- accessibilityLabel={copy.newTask}
- >
-
-
- )}
>
)}
- push({ kind: 'newProject' })}
- onSelect={setChosenProjectId}
- onClose={() => setPickerOpen(false)}
+ setNotice(openSession(id))}
+ onClose={() => setPreviewSessionId(null)}
/>
)
}
-
-/**
- * Opening a session is seeing it: the items a look settles (a finished turn, REQ-014) are marked
- * seen on the library's host (技术方案 §9.2); an approval or a question waits until handled.
- */
-function acknowledgeOpened(
- api: LocalTasksApi,
- library: Pick,
- taskSessionId: string
-) {
- const items = localTaskItemsForSession(library.work.progress, taskSessionId).filter(
- localTaskAttentionClearsOnView
- )
- if (items.length === 0) {
- return
- }
- // Why fresh ids: each open is its own act; a lost answer leaves the item for the next open.
- void Promise.all(
- items.map((item) =>
- sendLocalTaskWrite(
- createLocalTaskOperationIds(),
- { itemKey: item.key, taskSessionId: item.taskSessionId },
- api.acknowledge
- )
- )
- ).then(() => library.reload(['attention']))
-}
diff --git a/mobile/src/local-tasks/local-tasks-list-view.tsx b/mobile/src/local-tasks/local-tasks-list-view.tsx
index 080d83734f4f..bed6f3ac6717 100644
--- a/mobile/src/local-tasks/local-tasks-list-view.tsx
+++ b/mobile/src/local-tasks/local-tasks-list-view.tsx
@@ -1,6 +1,6 @@
-import { useState } from 'react'
+import { useState, type ReactNode } from 'react'
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
-import { ChevronDown, ChevronRight, ListTodo } from 'lucide-react-native'
+import { Bell, ChevronDown, ChevronRight } from 'lucide-react-native'
import type { LocalTaskStatusCategory } from '../../../src/shared/local-tasks/local-task-model'
import type {
LocalTask,
@@ -8,14 +8,14 @@ import type {
} from '../../../src/shared/local-tasks/local-task-types'
import { unfoldedLocalTaskLines } from '../../../src/shared/local-tasks/local-task-list-groups'
import { colors, spacing, typography } from '../theme/mobile-theme'
-import { formatLocalTaskAgo, localTasksCopy } from './local-tasks-copy'
+import { localTasksCopy } from './local-tasks-copy'
+import { LocalTaskCard, localTaskCardStyles } from './local-task-card'
+import type { LocalTaskCardFacts } from './local-task-card-facts'
import type { LocalTaskListGroup, LocalTaskListRow } from './local-task-list-projection'
+import type { LocalTaskRowSession } from './local-task-progress-projection'
+import { LocalTaskSessionDots } from './local-task-session-dots'
import { localTaskStyles } from './local-tasks-styles'
-import {
- LocalTaskPriorityIcon,
- LocalTaskStatusIcon,
- localTaskCategoryColor
-} from './local-tasks-visuals'
+import { LocalTaskStatusIcon, localTaskCategoryColor } from './local-tasks-visuals'
// Why these start folded: finished and parked work is the long tail of a list you scan for today.
const FOLDED_CATEGORIES: ReadonlySet = new Set([
@@ -31,11 +31,31 @@ type Props = {
bottomInset: number
/** Said when there is nothing to show; defaults to "no tasks here yet". */
emptyText?: string
+ /** The session dots a subtask line shows, from what the page already loaded. */
+ sessionsFor: (task: LocalTask) => LocalTaskRowSession[]
+ /** What a card says, from what the page already loaded. */
+ factsFor: (task: LocalTask) => LocalTaskCardFacts
+ /** "Needs you", drawn first; the tasks it holds leave the status groups below. */
+ band?: { ids: ReadonlySet; count: number; note: string | null; section: ReactNode }
onOpen: (taskId: string) => void
}
+/** Tasks a group counts that "Needs you" shows instead. */
+function tasksInBand(group: LocalTaskListGroup, band: ReadonlySet): number {
+ let count = 0
+ for (const row of group.rows) {
+ for (const task of [row.task, ...row.subtasks.map((line) => line.task)]) {
+ if (band.has(task.id)) {
+ count += 1
+ }
+ }
+ }
+ return count
+}
+
export function LocalTasksListView(props: Props) {
const copy = localTasksCopy()
+ const card = copy.work.card
const [toggledGroups, setToggledGroups] = useState>(new Set())
const [foldedParents, setFoldedParents] = useState>(new Set())
const toggle = (set: ReadonlySet, key: string) => {
@@ -45,7 +65,16 @@ export function LocalTasksListView(props: Props) {
}
return next
}
- if (props.groups.length === 0) {
+ const banded = props.band?.ids ?? new Set()
+ const groups = props.groups
+ .map((group) => ({
+ group,
+ rows: group.rows.filter((row) => !banded.has(row.task.id)),
+ inBand: tasksInBand(group, banded)
+ }))
+ .filter((entry) => entry.rows.length > 0)
+ const bandCount = props.band?.count ?? 0
+ if (groups.length === 0 && bandCount === 0) {
return (
{props.emptyText ?? copy.emptyList}
@@ -54,7 +83,20 @@ export function LocalTasksListView(props: Props) {
}
return (
- {props.groups.map((group) => {
+ {props.band && bandCount > 0 ? (
+
+
+
+ {copy.work.segmentAttention}
+ {bandCount}
+
+ {props.band.note ? (
+ {props.band.note}
+ ) : null}
+ {props.band.section}
+
+ ) : null}
+ {groups.map(({ group, rows, inBand }) => {
const foldedByDefault = group.category !== null && FOLDED_CATEGORIES.has(group.category)
const folded = foldedByDefault !== toggledGroups.has(group.key)
return (
@@ -82,21 +124,32 @@ export function LocalTasksListView(props: Props) {
{group.status?.name ??
(group.category ? copy.categories[group.category] : copy.unknownStatus)}
- {group.count}
+ {group.count - inBand}
+ {inBand > 0 ? (
+
+ {card.moreInNeedsYou(inBand)}
+
+ ) : null}
- {folded
- ? null
- : group.rows.map((row) => (
+ {folded ? null : (
+
+ {rows.map((row, at) => (
setFoldedParents((previous) => toggle(previous, taskId))}
onOpen={props.onOpen}
/>
))}
+
+ )}
)
})}
@@ -106,83 +159,46 @@ export function LocalTasksListView(props: Props) {
function LocalTaskListRowView(props: {
row: LocalTaskListRow
+ first: boolean
+ banded: ReadonlySet
lookup: Map
now: number
folded: ReadonlySet
+ sessionsFor: (task: LocalTask) => LocalTaskRowSession[]
+ factsFor: (task: LocalTask) => LocalTaskCardFacts
onToggle: (taskId: string) => void
onOpen: (taskId: string) => void
}) {
- const copy = localTasksCopy()
const { task } = props.row
- const status = props.lookup.get(task.statusId) ?? null
const expanded = !props.folded.has(task.id)
return (
-
- [styles.row, pressed && styles.rowPressed]}
- onPress={() => props.onOpen(task.id)}
- accessibilityRole="button"
- accessibilityLabel={`${task.title} ${task.ref}`}
- >
-
-
-
- {task.title}
-
-
-
-
- {task.ref}
-
- {props.row.subtaskTotal > 0 || props.row.subtasks.length > 0 ? (
- props.onToggle(task.id)}
- accessibilityRole="button"
- accessibilityState={{ expanded }}
- >
-
-
-
- {props.row.subtaskDone}/{props.row.subtaskTotal}
-
- {expanded ? (
-
- ) : (
-
- )}
-
-
- ) : null}
- {/* The first label, cut at the edge, and how many more; the detail lists them all. */}
- {task.labels.length > 0 ? (
-
- {task.labels[0]}
-
- ) : null}
- {task.labels.length > 1 ? (
- +{task.labels.length - 1}
- ) : null}
-
-
-
- {formatLocalTaskAgo(copy, task.updatedAt, props.now)}
-
-
+ 0 ? () => props.onToggle(task.id) : undefined}
+ onOpen={props.onOpen}
+ >
{expanded
- ? unfoldedLocalTaskLines(props.row.subtasks, props.folded).map((line) => (
- 0 ? props.folded.has(line.task.id) : undefined}
- onToggle={() => props.onToggle(line.task.id)}
- onOpen={props.onOpen}
- />
- ))
+ ? unfoldedLocalTaskLines(props.row.subtasks, props.folded)
+ .filter((line) => !props.banded.has(line.task.id))
+ .map((line) => (
+ 0 ? props.folded.has(line.task.id) : undefined}
+ sessions={props.sessionsFor(line.task)}
+ onToggle={() => props.onToggle(line.task.id)}
+ onOpen={props.onOpen}
+ />
+ ))
: null}
-
+
)
}
@@ -199,6 +215,8 @@ export function LocalTaskSubtaskRow(props: {
onToggle?: () => void
/** Inside a card, which pads its own rows: no list indent under a parent's status icon. */
inCard?: boolean
+ /** The sessions this subtask opened, drawn as dots after its title (REQ-012). */
+ sessions?: readonly LocalTaskRowSession[]
onOpen: (taskId: string) => void
}) {
const indent = (Math.min(props.depth ?? 1, MAX_INDENT_DEPTH) - 1) * spacing.lg
@@ -213,7 +231,7 @@ export function LocalTaskSubtaskRow(props: {
]}
onPress={() => props.onOpen(props.task.id)}
accessibilityRole="button"
- accessibilityLabel={`${props.task.ref} ${props.task.title}`}
+ accessibilityLabel={`${props.task.title} ${props.task.ref}`}
>
{props.task.title}
+ {props.sessions ? : null}
{props.folded !== undefined ? (
+ translate('localTasks.page.homeLatest', 'Latest progress · {{source}}', { source }),
+ homeViewAll: (count: number): string =>
+ translate('localTasks.page.homeViewAll', 'View all {{count}}', { count }),
+ latestProgress: translate('localTasks.page.latestProgress', 'Latest progress'),
+ writebackNumber: (number: number): string =>
+ translate('localTasks.page.writebackNumber', 'Writeback #{{number}}', { number }),
+ noWriteback: translate('localTasks.page.noWriteback', 'No writeback yet'),
+ allProgress: (count: number): string =>
+ translate('localTasks.page.allProgress', 'All progress {{count}}', { count }),
+ collapse: translate('localTasks.page.collapse', 'Collapse'),
+ expand: translate('localTasks.page.expand', 'Expand'),
+ selfCheck: translate('localTasks.page.selfCheck', 'Self-check'),
+ whereToVerify: translate('localTasks.page.whereToVerify', 'Where to verify'),
+ mergedInto: translate('localTasks.page.mergedInto', 'Merged into')
+ }
+}
diff --git a/mobile/src/local-tasks/local-tasks-project-copy.ts b/mobile/src/local-tasks/local-tasks-project-copy.ts
new file mode 100644
index 000000000000..7da018b61f99
--- /dev/null
+++ b/mobile/src/local-tasks/local-tasks-project-copy.ts
@@ -0,0 +1,129 @@
+import { translate } from '../i18n/mobile-i18n'
+
+/** Project settings: name, repositories, ref prefix, statuses, and creating projects. */
+export function localTasksProjectCopy() {
+ return {
+ settingsTitle: translate('localTasks.project.settingsTitle', 'Settings'),
+ projectSection: translate('localTasks.project.section', 'Project'),
+ name: translate('localTasks.project.name', 'Name'),
+ reposSection: translate('localTasks.repos.section', 'Repositories'),
+ repoNameUnread: translate('localTasks.repos.nameUnread', 'Repository (name not read yet)'),
+ repoNotInOrca: translate('localTasks.repos.notInOrca', 'A repository no longer in Orca'),
+ defaultRepo: translate('localTasks.repos.default', 'Default'),
+ noReposHint: translate(
+ 'localTasks.repos.noneHint',
+ 'None: this project only tracks tasks. Start work then picks from every repository on this host.'
+ ),
+ addRepo: translate('localTasks.repos.add', 'Add repository'),
+ reposAtLimit: (limit: number): string =>
+ translate(
+ 'localTasks.repos.atLimit',
+ 'A project links up to {{limit}} repositories. Remove one to add another.',
+ { limit }
+ ),
+ noRepoCandidates: translate(
+ 'localTasks.repos.noCandidates',
+ 'No other repository in Orca on this host.'
+ ),
+ makeDefaultRepo: translate('localTasks.repos.makeDefault', 'Make default'),
+ removeRepo: translate('localTasks.repos.remove', 'Remove from project'),
+ prefix: translate('localTasks.prefix.label', 'Ref prefix'),
+ prefixHint: (aliases: string): string =>
+ aliases
+ ? translate(
+ 'localTasks.prefix.hintAliases',
+ 'Earlier prefixes {{aliases}} stay as aliases: their refs still find these tasks, and no other project can take them.',
+ { aliases }
+ )
+ : translate(
+ 'localTasks.prefix.hint',
+ 'Changing the prefix keeps the old one as an alias: old refs still find these tasks, and no other project can take it.'
+ ),
+ prefixInvalid: translate(
+ 'localTasks.prefix.invalid',
+ 'Use 1–10 capital letters or digits, starting with a letter.'
+ ),
+ nameRequired: translate('localTasks.project.nameRequired', 'Give the project a name.'),
+ renameProject: translate('localTasks.project.rename', 'Rename project'),
+ changePrefix: translate('localTasks.prefix.change', 'Change prefix'),
+ changePrefixConfirm: (from: string, to: string): string =>
+ translate(
+ 'localTasks.prefix.changeConfirm',
+ 'Change {{from}} to {{to}}? {{from}} stays as an alias of this project.',
+ { from, to }
+ ),
+ deleteProject: translate('localTasks.project.delete', 'Delete project…'),
+ deleteProjectTitle: (name: string): string =>
+ translate('localTasks.project.deleteTitle', 'Delete {{name}}?', { name }),
+ deleteProjectMessage: (count: number): string =>
+ translate(
+ 'localTasks.project.deleteMessage',
+ 'Its tasks ({{count}}) are deleted with it. Its repositories, their workspaces and sessions are not touched.',
+ { count }
+ ),
+ deletingProject: translate('localTasks.project.deleting', 'Deleting the project…'),
+ confirmDeleteProject: translate('localTasks.project.confirmDelete', 'Delete project'),
+ statusesSection: translate('localTasks.statuses.section', 'Statuses'),
+ statusesHint: translate(
+ 'localTasks.statuses.hint',
+ 'The six categories are fixed. Inside one you can add, rename, recolor and reorder statuses; a status never changes category, and each category keeps at least one.'
+ ),
+ addStatus: translate('localTasks.statuses.add', 'Add status'),
+ statusTaskCount: (count: number): string =>
+ translate('localTasks.statuses.taskCount', 'Tasks: {{count}}', { count }),
+ newStatusName: translate('localTasks.statuses.newName', 'Status name'),
+ renameStatus: translate('localTasks.statuses.rename', 'Rename'),
+ statusColor: translate('localTasks.statuses.color', 'Status color'),
+ moveUp: translate('localTasks.statuses.moveUp', 'Move up'),
+ moveDown: translate('localTasks.statuses.moveDown', 'Move down'),
+ firstInCategory: translate(
+ 'localTasks.statuses.firstInCategory',
+ 'Already first in its category'
+ ),
+ lastInCategory: translate('localTasks.statuses.lastInCategory', 'Already last in its category'),
+ deleteStatus: translate('localTasks.statuses.remove', 'Remove status'),
+ removeStatusTitle: (name: string): string =>
+ translate('localTasks.statuses.removeTitle', 'Remove “{{name}}”?', { name }),
+ removeStatusUnused: translate('localTasks.statuses.removeUnused', 'No task uses this status.'),
+ lastStatusInCategory: translate(
+ 'localTasks.statuses.lastInCategoryRemove',
+ 'The only status in its category can’t be removed.'
+ ),
+ moveTasksTitle: (count: number): string =>
+ translate(
+ 'localTasks.statuses.moveTasksTitle',
+ 'Tasks using this status: {{count}}. Choose where they go.',
+ { count }
+ ),
+ newProjectTitle: translate('localTasks.newProject.title', 'New project'),
+ newProjectHint: translate(
+ 'localTasks.newProject.hint',
+ 'Pick the repositories to track, one project each, check their prefixes and create them; or start an empty project and add repositories later.'
+ ),
+ emptyProjectTitle: translate('localTasks.newProject.emptyTitle', 'Or start an empty project'),
+ emptyProject: translate('localTasks.newProject.empty', 'Empty project'),
+ emptyProjectHint: translate(
+ 'localTasks.newProject.emptyHint',
+ 'Only tasks for now; link repositories later'
+ ),
+ createEmptyProject: translate('localTasks.newProject.createEmpty', 'Create empty project'),
+ createSelected: (count: number): string =>
+ translate('localTasks.newProject.createSelected', 'Create selected ({{count}})', { count }),
+ continueWithCreated: translate(
+ 'localTasks.newProject.continueWithCreated',
+ 'Continue with created projects'
+ ),
+ prefixFor: (name: string): string =>
+ translate('localTasks.newProject.prefixFor', 'Prefix for {{name}}', { name }),
+ orcaProjectsUnavailable: translate(
+ 'localTasks.newProject.reposUnavailable',
+ 'This host hasn’t published its repositories yet, so they can’t be listed here.'
+ ),
+ orcaProjectLinked: (name: string): string =>
+ translate('localTasks.newProject.alsoIn', 'Also in {{name}}', { name }),
+ noOrcaProjects: translate(
+ 'localTasks.newProject.noRepos',
+ 'No repositories in Orca on this host yet. Make an empty project, or add a repository to Orca first.'
+ )
+ }
+}
diff --git a/mobile/src/local-tasks/local-tasks-project-create-view.tsx b/mobile/src/local-tasks/local-tasks-project-create-view.tsx
index 94fb1e3d41c7..23bfbcb25a79 100644
--- a/mobile/src/local-tasks/local-tasks-project-create-view.tsx
+++ b/mobile/src/local-tasks/local-tasks-project-create-view.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
Pressable,
@@ -9,17 +9,24 @@ import {
View
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import { Check, ChevronLeft, FilePlus, Layers } from 'lucide-react-native'
+import { Check, ChevronLeft, FilePlus, Square, SquareCheck } from 'lucide-react-native'
import {
LOCAL_TASK_PROJECT_NAME_MAX,
normalizeLocalTaskPrefix
} from '../../../src/shared/local-tasks/local-task-model'
-import { ConfirmModal } from '../components/ConfirmModal'
+import {
+ suggestLocalTaskPrefix as suggestPrefix,
+ takenLocalTaskPrefixes
+} from '../../../src/shared/local-tasks/local-task-prefix-suggestion'
import { colors, spacing } from '../theme/mobile-theme'
import type { LocalTasksApi } from './local-tasks-api'
import { localTasksCopy } from './local-tasks-copy'
import { localTaskWriteMessage } from './local-task-failure-message'
-import { createLocalTaskOperationIds, sendLocalTaskWrite } from './local-task-operation-ids'
+import {
+ createLocalTaskOperationIds,
+ sendLocalTaskWrite,
+ type LocalTaskOperationIds
+} from './local-task-operation-ids'
import { localTaskStyles } from './local-tasks-styles'
import {
suggestLocalTaskPrefix,
@@ -37,64 +44,128 @@ type Props = {
onCreated: (projectId: string) => void
}
-/** What the new project starts from: one of the host's Orca projects, or nothing (REQ-001). */
-type ProjectStart = { kind: 'repo'; choice: OrcaProjectChoice } | { kind: 'empty' }
+type RowState = { selected: boolean; prefix: string; error: string | null }
+
+/** Each repository with a prefix of its own, none taken by the library or by a row above it. */
+export function suggestLocalTaskProjectRows(
+ choices: readonly OrcaProjectChoice[],
+ projects: LocalTasksLibrary['projects']
+): { choice: OrcaProjectChoice; prefix: string }[] {
+ const taken = takenLocalTaskPrefixes(projects)
+ return choices.map((choice) => {
+ const prefix = suggestPrefix(choice.displayName, taken)
+ taken.add(prefix)
+ return { choice, prefix }
+ })
+}
/**
- * Recommends this host's Orca projects, one task project each, or an empty project that only tracks
- * tasks; nothing is created until the user confirms (REQ-001). Repos are added later in settings.
+ * One task project per ticked repository, each with its own editable prefix, or an empty project
+ * that only tracks tasks (REQ-001, as the desktop's "Create selected"). Nothing is created until the
+ * user presses Create; each row's refusal is said on that row, and the rows that went through stay.
*/
export function LocalTasksProjectCreateView(props: Props) {
const copy = localTasksCopy()
const insets = useSafeAreaInsets()
- const [ids] = useState(() => createLocalTaskOperationIds())
- const [picked, setPicked] = useState(null)
+ const choices = props.orcaProjects.state === 'ready' ? props.orcaProjects.projects : []
+ // A row the user edited keeps its own prefix; the rest follow the projects as they are created.
+ const suggestions = useMemo(
+ () => suggestLocalTaskProjectRows(choices, props.library.projects),
+ [choices, props.library.projects]
+ )
+ const [rows, setRows] = useState>({})
+ const rowIds = useRef(new Map())
+ const [emptyIds] = useState(() => createLocalTaskOperationIds())
+ const [createdId, setCreatedId] = useState(null)
+ const [busy, setBusy] = useState(false)
const [name, setName] = useState('')
const [prefixText, setPrefixText] = useState('')
const [prefixEdited, setPrefixEdited] = useState(false)
- const [confirming, setConfirming] = useState(false)
- const [busy, setBusy] = useState(false)
- const [error, setError] = useState(null)
- const prefix = normalizeLocalTaskPrefix(prefixText)
- // Why only a hint: one repo may serve several task projects (D-008).
+ const [emptyError, setEmptyError] = useState(null)
+ const rowOf = (id: string, suggested: string): RowState =>
+ rows[id] ?? { selected: false, prefix: suggested, error: null }
+ const patchRow = (id: string, suggested: string, patch: Partial) =>
+ setRows((current) => ({ ...current, [id]: { ...rowOf(id, suggested), ...patch } }))
+ const chosen = suggestions.filter(({ choice, prefix }) => rowOf(choice.id, prefix).selected)
+ const emptyPrefix = normalizeLocalTaskPrefix(prefixText)
+ // Why only a hint: one repository may serve several task projects (D-008).
const linkedNames = (orcaProjectId: string) =>
props.library.projects
.filter((project) => project.repos.includes(orcaProjectId))
.map((project) => project.name)
- const pick = (next: ProjectStart) => {
- setPicked(next)
- const displayName = next.kind === 'repo' ? next.choice.displayName : ''
- setName(displayName.slice(0, LOCAL_TASK_PROJECT_NAME_MAX))
- setPrefixText(displayName ? suggestLocalTaskPrefix(displayName, props.library.projects) : '')
- setPrefixEdited(false)
- setError(null)
+ const createSelected = async () => {
+ if (props.readOnly || busy) {
+ return
+ }
+ setBusy(true)
+ let firstCreated: string | null = null
+ let failed = false
+ for (const { choice, prefix: suggested } of chosen) {
+ const prefix = normalizeLocalTaskPrefix(rowOf(choice.id, suggested).prefix)
+ if (!prefix) {
+ failed = true
+ patchRow(choice.id, suggested, { error: copy.prefixInvalid })
+ continue
+ }
+ let ids = rowIds.current.get(choice.id)
+ if (!ids) {
+ ids = createLocalTaskOperationIds()
+ rowIds.current.set(choice.id, ids)
+ }
+ const outcome = await sendLocalTaskWrite(
+ ids,
+ {
+ name: choice.displayName.slice(0, LOCAL_TASK_PROJECT_NAME_MAX),
+ prefix,
+ repos: [choice.id],
+ statusNames: copy.defaultStatusNames
+ },
+ props.api.createProject
+ )
+ if (outcome.kind === 'ok') {
+ firstCreated ??= outcome.value.project.id
+ patchRow(choice.id, suggested, { selected: false, error: null })
+ } else {
+ failed = true
+ patchRow(choice.id, suggested, { error: localTaskWriteMessage(copy, outcome) })
+ }
+ }
+ setBusy(false)
+ props.library.reload(['projects'])
+ if (firstCreated && !failed) {
+ props.onCreated(firstCreated)
+ } else if (firstCreated) {
+ setCreatedId(firstCreated)
+ }
}
- const create = async () => {
- setConfirming(false)
- if (props.readOnly || !picked || !prefix) {
+ const createEmpty = async () => {
+ if (props.readOnly || busy || !emptyPrefix || !name.trim()) {
return
}
setBusy(true)
const outcome = await sendLocalTaskWrite(
- ids,
- {
- name: name.trim(),
- prefix,
- ...(picked.kind === 'repo' ? { repos: [picked.choice.id] } : {}),
- statusNames: copy.defaultStatusNames
- },
+ emptyIds,
+ { name: name.trim(), prefix: emptyPrefix, statusNames: copy.defaultStatusNames },
props.api.createProject
)
setBusy(false)
- setError(localTaskWriteMessage(copy, outcome))
+ setEmptyError(localTaskWriteMessage(copy, outcome))
props.library.reload(['projects'])
if (outcome.kind === 'ok') {
props.onCreated(outcome.value.project.id)
}
}
+ const canCreateEmpty = !props.readOnly && !busy && emptyPrefix !== null && name.trim() !== ''
+ // Said only for what was typed: an empty field is what the disabled button is waiting for.
+ const emptyHint = !name.trim()
+ ? copy.nameRequired
+ : prefixText && !emptyPrefix
+ ? copy.prefixInvalid
+ : null
+
return (
@@ -114,120 +185,176 @@ export function LocalTasksProjectCreateView(props: Props) {
keyboardShouldPersistTaps="handled"
>
{copy.newProjectHint}
- pick({ kind: 'empty' })}
- accessibilityRole="radio"
- accessibilityState={{ selected: picked?.kind === 'empty' }}
- >
-
-
- {copy.emptyProject}
-
- {copy.emptyProjectHint}
-
-
- {picked?.kind === 'empty' ? : null}
-
{props.orcaProjects.state === 'loading' ? (
) : null}
{props.orcaProjects.state === 'unavailable' ? (
{copy.orcaProjectsUnavailable}
) : null}
- {props.orcaProjects.state === 'ready' && props.orcaProjects.projects.length === 0 ? (
+ {props.orcaProjects.state === 'ready' && suggestions.length === 0 ? (
{copy.noOrcaProjects}
) : null}
- {props.orcaProjects.state === 'ready' && props.orcaProjects.projects.length > 0 ? (
-
- {props.orcaProjects.projects.map((choice, index) => {
- const linked = linkedNames(choice.id)
- const selected = picked?.kind === 'repo' && picked.choice.id === choice.id
- return (
+ {suggestions.length > 0 ? (
+ <>
+
+ {suggestions.map(({ choice, prefix: suggested }, index) => {
+ const row = rowOf(choice.id, suggested)
+ const linked = linkedNames(choice.id)
+ const Box = row.selected ? SquareCheck : Square
+ return (
+
+
+
+ patchRow(choice.id, suggested, { selected: !row.selected, error: null })
+ }
+ accessibilityRole="checkbox"
+ accessibilityState={{
+ checked: row.selected,
+ disabled: props.readOnly || busy
+ }}
+ accessibilityLabel={choice.displayName}
+ >
+
+
+
+ {choice.displayName}
+
+
+ {linked.length > 0
+ ? copy.orcaProjectLinked(linked.join(', '))
+ : choice.setupNames.join(' · ')}
+
+
+
+
+ patchRow(choice.id, suggested, {
+ prefix: value.toUpperCase(),
+ error: null
+ })
+ }
+ />
+
+ {row.error ? (
+ {row.error}
+ ) : null}
+
+ )
+ })}
+
+
+ {createdId ? (
pick({ kind: 'repo', choice })}
- accessibilityRole="radio"
- accessibilityState={{ selected }}
+ style={localTaskStyles.secondaryButton}
+ disabled={busy}
+ onPress={() => props.onCreated(createdId)}
+ accessibilityRole="button"
>
-
-
- {choice.displayName}
-
- {linked.length > 0
- ? copy.orcaProjectLinked(linked.join('、'))
- : choice.setupNames.join(' · ')}
-
-
- {selected ? : null}
+
+ {copy.continueWithCreated}
+
- )
- })}
-
- ) : null}
- {picked ? (
- <>
- {copy.name}
- {
- setName(next)
- if (!prefixEdited && picked.kind === 'empty') {
- setPrefixText(
- next.trim() ? suggestLocalTaskPrefix(next, props.library.projects) : ''
- )
- }
- }}
- />
- {copy.prefix}
- {
- setPrefixEdited(true)
- setPrefixText(value.toUpperCase())
- }}
- />
- {prefix ? null : (
- {copy.prefixInvalid}
- )}
- {error ? {error} : null}
- setConfirming(true)}
- accessibilityRole="button"
- >
- {busy ? (
-
- ) : (
-
- )}
- {copy.create}
-
+ ) : null}
+ void createSelected()}
+ accessibilityRole="button"
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+
+ {copy.createSelected(chosen.length)}
+
+
+
>
) : null}
+ {copy.emptyProjectTitle}
+
+
+ {copy.emptyProjectHint}
+
+ {copy.name}
+ {
+ setName(next)
+ setEmptyError(null)
+ if (!prefixEdited) {
+ setPrefixText(next.trim() ? suggestLocalTaskPrefix(next, props.library.projects) : '')
+ }
+ }}
+ />
+ {copy.prefix}
+ {
+ setPrefixEdited(true)
+ setEmptyError(null)
+ setPrefixText(value.toUpperCase())
+ }}
+ />
+ {emptyHint ? (
+
+ {emptyHint}
+
+ ) : null}
+ {emptyError ? (
+ {emptyError}
+ ) : null}
+ void createEmpty()}
+ accessibilityRole="button"
+ accessibilityState={{ disabled: !canCreateEmpty }}
+ >
+ {copy.createEmptyProject}
+
- void create()}
- onCancel={() => setConfirming(false)}
- />
)
}
@@ -237,5 +364,25 @@ const styles = StyleSheet.create({
content: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm, gap: spacing.xs },
gap: { marginTop: spacing.md },
below: { marginTop: spacing.xs },
- choiceText: { flex: 1, gap: 2 }
+ repoRow: {
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.borderSubtle,
+ gap: 4
+ },
+ repoLine: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
+ repoPick: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
+ prefixInput: { width: 96 },
+ rowError: { paddingLeft: 26 },
+ choiceText: { flex: 1, gap: 2 },
+ // Why wrap: two long labels overflow a 320–390pt row and push the first button off-screen.
+ actions: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ justifyContent: 'flex-end',
+ gap: spacing.sm,
+ marginTop: spacing.md
+ },
+ emptyHead: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }
})
diff --git a/mobile/src/local-tasks/local-tasks-screen-state.test.ts b/mobile/src/local-tasks/local-tasks-screen-state.test.ts
index 222f14706f3e..6fed6809edc4 100644
--- a/mobile/src/local-tasks/local-tasks-screen-state.test.ts
+++ b/mobile/src/local-tasks/local-tasks-screen-state.test.ts
@@ -33,7 +33,7 @@ describe('what the task page says about the library state', () => {
ready: false,
status: null
})
- ).toMatchObject({ kind: 'gate', title: "Cannot connect to this host's Orca" })
+ ).toMatchObject({ kind: 'gate', title: 'This host’s Orca is not connected' })
expect(
localTasksScreenState(copy, {
...ready,
diff --git a/mobile/src/local-tasks/local-tasks-settings-view.test.tsx b/mobile/src/local-tasks/local-tasks-settings-view.test.tsx
index eee5d42590b3..f6c36c478c76 100644
--- a/mobile/src/local-tasks/local-tasks-settings-view.test.tsx
+++ b/mobile/src/local-tasks/local-tasks-settings-view.test.tsx
@@ -28,6 +28,7 @@ vi.mock('lucide-react-native', () => ({
vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: 'ConfirmModal' }))
vi.mock('../components/TextInputModal', () => ({ TextInputModal: 'TextInputModal' }))
vi.mock('../components/MobileSwitch', () => ({ MobileSwitch: 'MobileSwitch' }))
+vi.mock('../components/PickerModal', () => ({ PickerModal: 'PickerModal' }))
vi.mock('./local-tasks-status-section', () => ({ LocalTasksStatusSection: () => null }))
vi.mock('./local-task-repos-section', () => ({ LocalTaskReposSection: () => null }))
vi.mock('./local-task-form-fields', () => ({
@@ -139,7 +140,7 @@ describe('mobile project deletion', () => {
let confirm = renderer?.root.findAll(
(node) => String(node.type) === 'ConfirmModal' && node.props.visible
)[0]
- expect(confirm?.props.message).toContain('1 task records')
+ expect(confirm?.props.message).toContain('Its tasks (1)')
await act(async () => {
confirm?.props.onConfirm()
confirm?.props.onCancel()
diff --git a/mobile/src/local-tasks/local-tasks-settings-view.tsx b/mobile/src/local-tasks/local-tasks-settings-view.tsx
index 8a2dfc00d141..c8e01cce0b2e 100644
--- a/mobile/src/local-tasks/local-tasks-settings-view.tsx
+++ b/mobile/src/local-tasks/local-tasks-settings-view.tsx
@@ -187,7 +187,7 @@ function LocalTaskProjectSettings(props: Props & { project: LocalTaskProject })
- {copy.prefixHint(project.aliases.join('、'))}
+ {copy.prefixHint(project.aliases.join(', '))}
{error ? {error} : null}
- {copy.deleteProject}
+
+ {busyDelete ? copy.deletingProject : copy.deleteProject}
+
entry.category === menuStatus.category)
+ : []
+ const menuAt = menuStatus ? menuPeers.findIndex((entry) => entry.id === menuStatus.id) : -1
return (
{copy.statusesSection}
@@ -165,12 +170,27 @@ export function LocalTasksStatusSection(props: {
icon: Palette,
onPress: () => setDialog({ kind: 'color', status: menuStatus })
},
- { label: copy.moveUp, icon: ArrowUp, onPress: () => void move(menuStatus, -1) },
- { label: copy.moveDown, icon: ArrowDown, onPress: () => void move(menuStatus, 1) },
+ {
+ label: copy.moveUp,
+ icon: ArrowUp,
+ ...(menuAt <= 0 ? { disabled: true, hint: copy.firstInCategory } : {}),
+ onPress: () => void move(menuStatus, -1)
+ },
+ {
+ label: copy.moveDown,
+ icon: ArrowDown,
+ ...(menuAt >= menuPeers.length - 1
+ ? { disabled: true, hint: copy.lastInCategory }
+ : {}),
+ onPress: () => void move(menuStatus, 1)
+ },
{
label: copy.deleteStatus,
icon: Trash2,
destructive: true,
+ ...(menuPeers.length <= 1
+ ? { disabled: true, hint: copy.lastStatusInCategory }
+ : {}),
onPress: () =>
setDialog({
kind: 'remove',
@@ -252,9 +272,9 @@ export function LocalTasksStatusSection(props: {
/>
{
diff --git a/mobile/src/local-tasks/local-tasks-task-copy.ts b/mobile/src/local-tasks/local-tasks-task-copy.ts
new file mode 100644
index 000000000000..7445ca000a86
--- /dev/null
+++ b/mobile/src/local-tasks/local-tasks-task-copy.ts
@@ -0,0 +1,165 @@
+import type { LocalTaskEventSource } from '../../../src/shared/local-tasks/local-task-types'
+import { translate } from '../i18n/mobile-i18n'
+
+/** One task's words: its fields, comments, activity lines, and the create/edit form. */
+export function localTasksTaskCopy() {
+ return {
+ status: translate('localTasks.field.status', 'Status'),
+ priority: translate('localTasks.field.priority', 'Priority'),
+ labels: translate('localTasks.field.labels', 'Labels'),
+ parent: translate('localTasks.field.parent', 'Parent task'),
+ project: translate('localTasks.field.project', 'Project'),
+ noDescription: translate('localTasks.field.noDescription', 'No description'),
+ criteria: translate('localTasks.field.criteria', 'Done criteria'),
+ criteriaOptional: translate('localTasks.field.criteriaOptional', 'optional'),
+ subtasks: translate('localTasks.field.subtasks', 'Subtasks'),
+ addSubtask: translate('localTasks.field.addSubtask', 'Add'),
+ activity: translate('localTasks.comment.activity', 'Comments and activity'),
+ commentPlaceholder: translate('localTasks.comment.placeholder', 'Write a comment…'),
+ editComment: translate('localTasks.comment.edit', 'Edit comment'),
+ deleteComment: translate('localTasks.comment.delete', 'Delete comment'),
+ deleteCommentConfirm: translate('localTasks.comment.deleteConfirm', 'Delete this comment?'),
+ edited: translate('localTasks.comment.edited', 'edited'),
+ taskMissing: translate('localTasks.detail.missing', 'This task no longer exists.'),
+ deleteTaskTitle: (title: string): string =>
+ translate('localTasks.detail.deleteTitle', 'Delete “{{title}}”?', { title }),
+ deleteTaskMessage: (below: number): string =>
+ below > 0
+ ? translate(
+ 'localTasks.detail.deleteWithSubtasks',
+ 'Its subtasks at every level ({{count}}) are deleted with it. Only task records are removed; workspaces and sessions are not touched.',
+ { count: below }
+ )
+ : translate(
+ 'localTasks.detail.deleteSingle',
+ 'Only the task record is removed; workspaces and sessions are not touched.'
+ ),
+ eventSources: {
+ desktop: translate('localTasks.event.source.desktop', 'You'),
+ mobile: translate('localTasks.event.source.mobile', 'You (phone)'),
+ cli: translate('localTasks.event.source.cli', 'Orca CLI'),
+ rule: translate('localTasks.event.source.rule', 'Automatic rule')
+ } satisfies Record,
+ eventCreated: translate('localTasks.event.created', 'created the task'),
+ eventTitle: (title: string): string =>
+ translate('localTasks.event.renamed', 'renamed it to “{{title}}”', { title }),
+ eventDescription: translate('localTasks.event.description', 'edited the description'),
+ eventCriteria: translate('localTasks.event.criteria', 'edited the done criteria'),
+ eventStatus: (from: string, to: string): string =>
+ translate('localTasks.event.status', 'changed status: {{from}} → {{to}}', { from, to }),
+ eventPriority: (from: string, to: string): string =>
+ translate('localTasks.event.priority', 'changed priority: {{from}} → {{to}}', { from, to }),
+ eventLabelsChanged: (added: string, removed: string): string =>
+ translate(
+ 'localTasks.event.labelsChanged',
+ 'added labels {{added}} and removed {{removed}}',
+ {
+ added,
+ removed
+ }
+ ),
+ eventLabelsAdded: (added: string): string =>
+ translate('localTasks.event.labelsAdded', 'added labels {{added}}', { added }),
+ eventLabelsRemoved: (removed: string): string =>
+ translate('localTasks.event.labelsRemoved', 'removed labels {{removed}}', { removed }),
+ eventParentSet: (title: string): string =>
+ translate('localTasks.event.parentSet', 'made it a subtask of “{{title}}”', { title }),
+ eventParentRemoved: (title: string): string =>
+ translate('localTasks.event.parentRemoved', 'detached it from “{{title}}”', { title }),
+ eventDeletedTask: translate('localTasks.event.deletedTask', 'a deleted task'),
+ eventRemovedStatus: translate('localTasks.event.removedStatus', 'a removed status'),
+ eventMoved: (from: string, to: string): string =>
+ translate('localTasks.event.moved', 'moved it: {{from}} → {{to}}', { from, to }),
+ eventWorkspaceStarted: (name: string, host: string): string =>
+ host
+ ? translate('localTasks.event.workspaceStartedOn', 'started work in {{name}} on {{host}}', {
+ name,
+ host
+ })
+ : translate('localTasks.event.workspaceStarted', 'started work in {{name}}', { name }),
+ eventWorkspaceLinked: (name: string): string =>
+ translate('localTasks.event.workspaceLinked', 'linked workspace {{name}}', { name }),
+ eventWorkspaceUnlinked: (name: string): string =>
+ translate('localTasks.event.workspaceUnlinked', 'unlinked workspace {{name}}', { name }),
+ // Said without an actor, as the desktop says it: the host moved, nobody did it.
+ eventWorkspaceHost: (name: string, to: string, from: string): string =>
+ translate(
+ 'localTasks.event.workspaceHost',
+ 'Workspace {{name}} now runs on {{to}} (was {{from}})',
+ { name, to, from }
+ ),
+ eventUnnamedWorkspace: translate('localTasks.event.unnamedWorkspace', 'a workspace'),
+ eventSessionOpened: (agent: string): string =>
+ translate('localTasks.event.sessionOpened', 'opened a {{agent}} session', { agent }),
+ eventSessionLinked: (agent: string): string =>
+ translate('localTasks.event.sessionLinked', 'linked a {{agent}} session', { agent }),
+ eventSessionUnlinked: (agent: string): string =>
+ translate('localTasks.event.sessionUnlinked', 'unlinked a {{agent}} session', { agent }),
+ eventUnknownAgent: translate('localTasks.event.unknownAgent', 'an agent'),
+ // A rule's change says which rule did it, with no actor in front.
+ eventRuleStart: (from: string, to: string): string =>
+ translate(
+ 'localTasks.event.ruleStart',
+ 'Work started, so the status moved: {{from}} → {{to}}',
+ {
+ from,
+ to
+ }
+ ),
+ eventRulePrMerged: (pr: string, from: string, to: string): string =>
+ translate(
+ 'localTasks.event.rulePrMerged',
+ '#{{pr}} was merged, so the task completed: {{from}} → {{to}}',
+ { pr, from, to }
+ ),
+ eventComment: translate('localTasks.event.commented', 'commented'),
+ titlePlaceholder: translate('localTasks.form.titlePlaceholder', 'Task title'),
+ descriptionPlaceholder: translate(
+ 'localTasks.form.descriptionPlaceholder',
+ 'Add a description…'
+ ),
+ criterionPlaceholder: translate('localTasks.form.criterionPlaceholder', 'A done criterion'),
+ addCriterion: translate('localTasks.form.addCriterion', 'Add a criterion'),
+ properties: translate('localTasks.form.properties', 'Properties'),
+ labelsPlaceholder: translate(
+ 'localTasks.form.labelsPlaceholder',
+ 'Comma separated, e.g. feature, backend'
+ ),
+ subtaskPlaceholder: translate(
+ 'localTasks.form.subtaskPlaceholder',
+ 'Subtask title; paste several lines to add several'
+ ),
+ continueCreating: translate('localTasks.form.continueCreating', 'Create another'),
+ formHint: translate(
+ 'localTasks.form.hint',
+ 'Creating a task only records it; it does not open a workspace. Paste several lines into the subtask field to add several at once.'
+ ),
+ pastedLines: (count: number): string =>
+ translate('localTasks.form.pastedLines', 'Pasted {{count}} lines', { count }),
+ pasteAsSubtasks: (count: number): string =>
+ translate('localTasks.form.pasteAsSubtasks', 'Create {{count}} subtasks', { count }),
+ pasteAsSubtasksHint: (title: string): string =>
+ translate('localTasks.form.pasteAsSubtasksHint', 'One per line, under “{{title}}”', {
+ title
+ }),
+ pasteAsTitle: translate('localTasks.form.pasteAsTitle', 'Use as one title'),
+ pasteAsTitleHint: translate('localTasks.form.pasteAsTitleHint', 'Line breaks become spaces'),
+ savedPartially: translate(
+ 'localTasks.form.savedPartially',
+ 'The task was saved, but its subtasks were not. Add them from the task.'
+ ),
+ subtasksPartiallySaved: translate(
+ 'localTasks.form.subtasksPartiallySaved',
+ 'The subtasks were created, but some properties were not saved.'
+ ),
+ subtasksAlreadyCreated: translate(
+ 'localTasks.form.subtasksAlreadyCreated',
+ 'These subtasks already exist. Finish saving them or view them from their parent.'
+ ),
+ retrySavingSubtasks: translate(
+ 'localTasks.form.retrySavingSubtasks',
+ 'Retry saving properties'
+ ),
+ viewCreatedSubtasks: translate('localTasks.form.viewCreatedSubtasks', 'View created subtasks')
+ }
+}
diff --git a/mobile/src/local-tasks/local-tasks-work-card-copy.ts b/mobile/src/local-tasks/local-tasks-work-card-copy.ts
new file mode 100644
index 000000000000..e905c00e4706
--- /dev/null
+++ b/mobile/src/local-tasks/local-tasks-work-card-copy.ts
@@ -0,0 +1,140 @@
+import type { LocalTaskSessionSurface } from '../../../src/shared/local-tasks/local-task-link-types'
+import { translate } from '../i18n/mobile-i18n'
+
+export function localTaskHostOfflineUnnamed(): string {
+ return translate('localTasks.work.hostOfflineUnnamed', 'The workspace’s host is not connected')
+}
+
+function noWorkspaceYet(): string {
+ return translate('localTasks.work.card.noWorkspace', 'No workspace yet')
+}
+
+function cardDuration(ms: number): string {
+ const minutes = Math.floor(Math.max(0, ms) / 60_000)
+ if (minutes < 1) {
+ return translate('localTasks.work.card.durationJustStarted', 'just started')
+ }
+ if (minutes < 60) {
+ return translate('localTasks.work.card.durationMinutes', '{{count}}m', { count: minutes })
+ }
+ const hours = Math.floor(minutes / 60)
+ return hours < 24
+ ? translate('localTasks.work.card.durationHours', '{{count}}h', { count: hours })
+ : translate('localTasks.work.card.durationDays', '{{count}}d', {
+ count: Math.floor(hours / 24)
+ })
+}
+
+/** The task card, the dispatch box and its mention panel. */
+export function localTasksWorkCardCopy() {
+ return {
+ surfaces: {
+ terminal: translate('localTasks.work.surface.terminal', 'Terminal'),
+ chat: translate('localTasks.work.surface.chat', 'Chat')
+ } satisfies Record,
+ card: {
+ noWorkspace: noWorkspaceYet(),
+ hostOffline: (host: string): string =>
+ host
+ ? translate('localTasks.work.card.hostOffline', '{{host}} not connected', { host })
+ : localTaskHostOfflineUnnamed(),
+ lastProgress: (ago: string): string =>
+ translate('localTasks.work.card.lastProgress', 'last progress {{ago}}', { ago }),
+ writeback: translate('localTasks.work.card.writeback', 'Writeback'),
+ lastLine: translate('localTasks.work.card.lastLine', 'Last line'),
+ sessionStarted: translate(
+ 'localTasks.work.card.sessionStarted',
+ 'Session started · waiting for the first writeback'
+ ),
+ fromSubtask: (ref: string): string =>
+ translate('localTasks.work.card.fromSubtask', '{{ref}}: ', { ref }),
+ subtasks: (count: number): string =>
+ translate('localTasks.work.card.subtasks', 'Subtasks {{count}}', { count }),
+ subtasksNone: translate('localTasks.work.card.subtasksNone', 'no session yet'),
+ subtasksAllDone: translate('localTasks.work.card.subtasksAllDone', 'all finished a turn'),
+ subtasksWaiting: (count: number): string =>
+ translate('localTasks.work.card.subtasksWaiting', '{{count}} waiting for you', { count }),
+ subtasksWorking: (count: number): string =>
+ translate('localTasks.work.card.subtasksWorking', '{{count}} working', { count }),
+ subtasksLatest: (ref: string): string =>
+ translate('localTasks.work.card.subtasksLatest', 'Latest: {{ref}}', { ref }),
+ awaitingAcceptance: translate(
+ 'localTasks.work.card.awaitingAcceptance',
+ 'Awaiting acceptance'
+ ),
+ moreInNeedsYou: (count: number): string =>
+ translate('localTasks.work.card.moreInNeedsYou', '{{count}} more in Needs you', {
+ count
+ }),
+ preview: translate('localTasks.work.card.preview', 'Preview'),
+ openFullSession: translate('localTasks.work.card.openFullSession', 'Open full session'),
+ remindLater: translate('localTasks.work.card.remindLater', 'Remind later'),
+ goReview: translate('localTasks.work.card.goReview', 'Review'),
+ duration: cardDuration
+ },
+ dispatch: {
+ placeholder: translate(
+ 'localTasks.work.dispatch.placeholder',
+ 'What needs doing? @ a workspace or repository, then say it'
+ ),
+ mention: translate(
+ 'localTasks.work.dispatch.mention',
+ 'Mention a workspace, repository, task or host'
+ ),
+ clearTarget: translate('localTasks.work.dispatch.clearTarget', 'Remove target'),
+ runBy: (host: string): string =>
+ host
+ ? translate(
+ 'localTasks.work.dispatch.runBy',
+ 'Run by {{host}} · tasks live in its library, the phone reads and writes through it',
+ { host }
+ )
+ : translate(
+ 'localTasks.work.dispatch.runByPaired',
+ 'Tasks live in the library of the host this phone is paired with'
+ ),
+ onSendWorkspace: (workspace: string, host: string): string =>
+ host
+ ? translate(
+ 'localTasks.work.dispatch.onSendWorkspaceOn',
+ 'On send: a task in {{workspace}} ({{host}}), with a new session',
+ { workspace, host }
+ )
+ : translate(
+ 'localTasks.work.dispatch.onSendWorkspace',
+ 'On send: a task in {{workspace}}, with a new session',
+ { workspace }
+ ),
+ onSendTask: (ref: string): string =>
+ translate('localTasks.work.dispatch.onSendTask', 'On send: work continues under {{ref}}', {
+ ref
+ })
+ },
+ mention: {
+ title: translate('localTasks.work.mention.title', 'Mention'),
+ hint: translate(
+ 'localTasks.work.mention.hint',
+ 'Each row says which host it is on; what is on a host that is not connected can’t be picked.'
+ ),
+ search: translate(
+ 'localTasks.work.mention.search',
+ 'Search workspaces, repositories, tasks, hosts'
+ ),
+ workspaces: translate('localTasks.work.mention.workspaces', 'Workspaces'),
+ repositories: translate('localTasks.work.mention.repositories', 'Repositories'),
+ tasks: translate('localTasks.work.mention.tasks', 'Tasks'),
+ hosts: translate('localTasks.work.mention.hosts', 'Hosts'),
+ belongsTo: (ref: string): string =>
+ translate('localTasks.work.mention.belongsTo', 'Belongs to {{ref}}', { ref }),
+ newWorkspaceHere: translate(
+ 'localTasks.work.mention.newWorkspaceHere',
+ 'A new workspace is created in it'
+ ),
+ noWorkspace: noWorkspaceYet(),
+ libraryHost: translate('localTasks.work.mention.libraryHost', 'Holds the task library'),
+ sshHost: translate('localTasks.work.mention.sshHost', 'SSH host'),
+ hostConnected: translate('localTasks.work.mention.hostConnected', 'connected'),
+ hostNotConnected: translate('localTasks.work.mention.hostNotConnected', 'not connected')
+ }
+ }
+}
diff --git a/mobile/src/local-tasks/local-tasks-work-copy-en.ts b/mobile/src/local-tasks/local-tasks-work-copy-en.ts
deleted file mode 100644
index f5b9559c0978..000000000000
--- a/mobile/src/local-tasks/local-tasks-work-copy-en.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import type {
- LocalTaskAttentionKind,
- LocalTaskSessionState
-} from '../../../src/shared/local-tasks/local-task-link-types'
-import type { LocalTaskSnoozePreset } from '../../../src/shared/local-tasks/local-task-snooze-presets'
-import type { LocalTaskReplyStatus } from './use-local-task-attention-actions'
-
-/** Workspaces, sessions, "Needs you", replies and the two automatic rules. */
-export const LOCAL_TASKS_WORK_COPY_EN = {
- segmentAttention: 'Needs you',
- homeNeedsYou: (count: number): string => `Needs you ${count}`,
- attentionUnverifiable: (host: string): string =>
- host
- ? `Can't reach ${host}, so what needs you can't be confirmed.`
- : "Can't reach this host, so what needs you can't be confirmed.",
- attentionEmpty: 'Nothing needs you right now.',
- attentionLoading: 'Reading what needs you…',
- workLoadFailed: 'Workspaces and sessions could not be read.',
- attentionKinds: {
- approval: 'Waiting for your approval',
- question: 'Waiting for your answer',
- blocked: 'Blocked',
- 'unread-done': 'Finished a turn you have not seen'
- } satisfies Record,
- agentSaid: (agent: string, message: string): string => `${agent}: “${message}”`,
- agentStopped: (agent: string): string => `${agent} stopped`,
- someAgent: 'The agent',
- reply: 'Reply',
- replyTitle: (title: string): string => `Reply in “${title}”`,
- replyPlaceholder: 'Next instruction for the agent',
- replyReconnecting: 'Reconnecting…',
- replySessionMissing: "This session's terminal is no longer listed. Open the session instead.",
- openSession: 'Open session',
- markSeen: 'Mark as seen',
- snooze: 'Snooze',
- snoozeTitle: 'Bring it back',
- snoozeChoices: {
- hour: 'In 1 hour',
- tonight: 'Tonight',
- tomorrow: 'Tomorrow morning',
- 'next-week': 'Next week'
- } satisfies Record,
- itemActions: 'Item actions',
- replyStates: {
- sending: 'Sending…',
- delivered: 'Delivered',
- unconfirmed: 'Sent, but not confirmed. Check the session before sending again.'
- } satisfies Record, string>,
- replyNotSent: 'Not sent. Your reply is back in the box.',
- replyNotSentBecause: (reason: string): string =>
- `Not sent: ${reason}. Your reply is back in the box.`,
- editReply: 'Edit and send again',
- workspaceSection: 'Workspace',
- sessionsSection: 'Sessions',
- noWorkspace: 'No workspace yet.',
- inheritedWorkspace: (title: string): string => `Works in the workspace of “${title}”`,
- // Why three: a project without repos starts in any repo on the host (REQ-009), so each says
- // which repos Start looked at and why none of them can hold a new workspace.
- folderProjectHint:
- 'The repos available here are folders, not git repos, so no workspace is created; link an existing one.',
- noSetupHint: "None of this project's repos is on this host, so no workspace can be started here.",
- noRepoOnHostHint: 'This host has no repo yet, so no workspace can be started here.',
- start: 'Start',
- startOwn: 'Start its own',
- startTitle: (title: string): string => `Start “${title}”`,
- startWhere: (name: string, host: string): string => `New workspace ${name} on ${host}`,
- changeLocation: 'Change repo or host',
- pickLocation: 'Repo and host',
- openSessionAction: 'Open a session',
- openSessionTitle: (title: string): string => `New session for “${title}”`,
- sendNow: 'Send the prompt now',
- sendNowOnHint: 'The agent starts on the task right away',
- sendNowOffHint: 'The prompt waits in the input as a draft',
- noAgents: 'No enabled agent was found on this host.',
- loadingAgents: 'Finding agents…',
- workspaceOnly: 'Just create the workspace',
- workspaceOnlyHint: 'No agent starts; open a session later',
- linkExisting: 'Link an existing workspace',
- linkExistingTitle: (title: string): string => `Link a workspace to “${title}”`,
- noWorkspacesToLink: 'This host lists no workspace of this project.',
- workspaceTaken: (title: string): string => `Belongs to “${title}”`,
- workspaceNotListed: 'This host no longer lists this workspace.',
- unlinkWorkspace: 'Unlink workspace',
- unlinkWorkspaceTitle: 'Unlink this workspace?',
- unlinkWorkspaceMessage:
- 'Only the link is removed, together with the sessions linked through it. The workspace and its sessions keep running.',
- openWorkspace: 'Open workspace',
- hostLocal: 'This computer',
- hostOffline: (host: string): string => `${host} is not connected`,
- hostUnknown: "Can't confirm the host's connection",
- checkConnection: 'Check connection',
- ofSubtask: (title: string): string => `For “${title}”`,
- sessionStates: {
- working: 'Working',
- waiting: 'Waiting for you',
- blocked: 'Blocked',
- done: 'Finished a turn',
- idle: 'Idle',
- exited: 'Ended',
- missing: "Can't find this session",
- unknown: 'Status unknown',
- unverifiable: "Can't confirm"
- } satisfies Record,
- starting: 'Starting…',
- startedNotLinked:
- 'The workspace was created but not linked to the task. Use “Link an existing workspace” to link it.',
- sessionNotRecorded:
- 'The agent started, but the session was not recorded on the task. It is in the workspace.',
- launchUnconfirmed: "Couldn't confirm the agent started. Check the workspace before trying again.",
- workspaceUnconfirmed:
- "Couldn't confirm the workspace was created. Check the workspace list before trying again.",
- updateDesktop: "Update this host's Orca to start agents from your phone.",
- // Title case like the upstream items it sits among (Sleep, Pin, Agent Session History).
- linkToTask: 'Link to Task…',
- linkToTaskTitle: (workspace: string): string => `Link "${workspace}" to a task`,
- linkSessionToTask: 'Link this session…',
- linkSessionTitle: 'This session works on',
- noOpenTasks: 'No open tasks on this host.',
- hasWorkspace: (name: string): string => `Already has ${name}`,
- rulesSection: 'Automatic rules',
- ruleStart: 'Start moves the task to In progress',
- ruleStartHint: 'When a workspace or the first session is linked from the task.',
- rulePrMerged: 'A merged PR completes the task',
- rulePrMergedHint: 'Checked on the desktop while its window is open.'
-}
-
-export type LocalTasksWorkCopy = typeof LOCAL_TASKS_WORK_COPY_EN
diff --git a/mobile/src/local-tasks/local-tasks-work-copy.ts b/mobile/src/local-tasks/local-tasks-work-copy.ts
new file mode 100644
index 000000000000..4e60760b0f2c
--- /dev/null
+++ b/mobile/src/local-tasks/local-tasks-work-copy.ts
@@ -0,0 +1,216 @@
+import type { CheckStatus } from '../../../src/shared/github/pull-request-types'
+import type { LocalTaskSessionState } from '../../../src/shared/local-tasks/local-task-link-types'
+import { translate } from '../i18n/mobile-i18n'
+import { localTasksAttentionCopy } from './local-tasks-attention-copy'
+import { localTaskHostOfflineUnnamed, localTasksWorkCardCopy } from './local-tasks-work-card-copy'
+
+/** Workspaces, sessions, "Needs you", replies and the two automatic rules. */
+export function localTasksWorkCopy() {
+ return {
+ ...localTasksAttentionCopy(),
+ ...localTasksWorkCardCopy(),
+ workspaceSection: translate('localTasks.work.workspace.section', 'Workspace'),
+ sessionsSection: translate('localTasks.work.session.section', 'Sessions'),
+ noWorkspace: translate('localTasks.work.workspace.none', 'No workspace yet.'),
+ inheritedWorkspace: (title: string): string =>
+ translate('localTasks.work.workspace.inherited', 'Uses the workspace of “{{title}}”', {
+ title
+ }),
+ // Why three: a project without repositories starts in any repository on the host, so each
+ // says which repositories Start work looked at and why none of them can hold a new workspace.
+ folderProjectHint: translate(
+ 'localTasks.work.start.folderHint',
+ 'The repositories available here are folders, not Git repositories, so no workspace is created; link an existing one.'
+ ),
+ noSetupHint: translate(
+ 'localTasks.work.start.noSetupHint',
+ "None of this project's repositories is on this host, so no workspace can be started here."
+ ),
+ noRepoOnHostHint: translate(
+ 'localTasks.work.start.noRepoOnHostHint',
+ 'This host has no repository in Orca yet, so no workspace can be started here.'
+ ),
+ start: translate('localTasks.work.start.label', 'Start work'),
+ startOwn: translate('localTasks.work.start.own', 'Start work separately'),
+ startAgain: translate('localTasks.work.start.again', 'Start work again'),
+ startTitle: (title: string): string =>
+ translate('localTasks.work.start.title', 'Start work on “{{title}}”', { title }),
+ startWhere: (name: string, host: string): string =>
+ host
+ ? translate('localTasks.work.start.whereOn', 'New workspace {{name}} on {{host}}', {
+ name,
+ host
+ })
+ : translate('localTasks.work.start.where', 'New workspace {{name}}', { name }),
+ changeLocation: translate('localTasks.work.start.changeLocation', 'Change repository or host'),
+ pickLocation: translate('localTasks.work.start.pickLocation', 'Repository and host'),
+ openSessionAction: translate('localTasks.work.session.new', 'New session'),
+ openSessionTitle: (title: string): string =>
+ translate('localTasks.work.session.newTitle', 'New session for “{{title}}”', { title }),
+ noAgents: translate('localTasks.work.agents.none', 'No enabled agent was found on this host.'),
+ agentsUnreadable: translate(
+ 'localTasks.work.agents.unreadable',
+ 'The agents on this host could not be read. Close this sheet and try again.'
+ ),
+ agentsHostOffline: (host: string): string =>
+ host
+ ? translate(
+ 'localTasks.work.agents.hostOffline',
+ '{{host}} is not connected, so its agents can’t be listed.',
+ { host }
+ )
+ : translate(
+ 'localTasks.work.agents.hostOfflineUnnamed',
+ 'The workspace’s host is not connected, so its agents can’t be listed.'
+ ),
+ loadingAgents: translate('localTasks.work.agents.loading', 'Finding agents…'),
+ workspaceOnly: translate('localTasks.work.start.workspaceOnly', 'Just create the workspace'),
+ workspaceOnlyHint: translate(
+ 'localTasks.work.start.workspaceOnlyHint',
+ 'No agent starts; open a session later'
+ ),
+ linkExisting: translate('localTasks.work.workspace.linkExisting', 'Link an existing workspace'),
+ linkExistingTitle: (title: string): string =>
+ translate(
+ 'localTasks.work.workspace.linkExistingTitle',
+ 'Link an existing workspace to “{{title}}”',
+ { title }
+ ),
+ noWorkspacesToLink: translate(
+ 'localTasks.work.workspace.noneToLink',
+ 'No workspaces of this project on its host.'
+ ),
+ searchWorkspaces: translate('localTasks.work.workspace.search', 'Search workspace or branch'),
+ workspaceTaken: (title: string): string =>
+ translate('localTasks.work.workspace.taken', 'Belongs to “{{title}}”', { title }),
+ workspaceNotListed: translate(
+ 'localTasks.work.workspace.notListed',
+ 'This host no longer lists this workspace.'
+ ),
+ unlinkWorkspace: translate('localTasks.work.workspace.unlink', 'Unlink workspace'),
+ unlinkWorkspaceTitle: translate(
+ 'localTasks.work.workspace.unlinkTitle',
+ 'Unlink this workspace?'
+ ),
+ unlinkWorkspaceMessage: translate(
+ 'localTasks.work.workspace.unlinkMessage',
+ 'Only the link is removed, together with the sessions linked through it. The workspace and its sessions keep running.'
+ ),
+ unlinkSession: translate('localTasks.work.session.unlink', 'Unlink session'),
+ unlinkSessionTitle: translate('localTasks.work.session.unlinkTitle', 'Unlink this session?'),
+ unlinkSessionMessage: translate(
+ 'localTasks.work.session.unlinkMessage',
+ 'Only the link is removed. The session keeps running in its workspace.'
+ ),
+ sessionActionsHint: translate('localTasks.work.session.actionsHint', 'Long press to unlink'),
+ openWorkspace: translate('localTasks.work.workspace.open', 'Open workspace'),
+ // Why no host id: the user never sees one; an unnamed host is described instead.
+ hostLocal: translate('localTasks.work.host.local', 'This computer'),
+ hostOffline: (host: string): string =>
+ host
+ ? translate('localTasks.work.host.offline', '{{host}} is not connected', { host })
+ : localTaskHostOfflineUnnamed(),
+ hostUnknown: translate('localTasks.work.host.unknown', 'Can’t confirm the host’s connection'),
+ checkConnection: translate('localTasks.work.host.checkConnection', 'Check connection'),
+ catalogUnavailable: translate(
+ 'localTasks.work.start.catalogUnavailable',
+ 'This host hasn’t published its repositories yet, so Start work and Link an existing workspace can’t be offered.'
+ ),
+ cannotOpenHere: translate(
+ 'localTasks.work.session.cannotOpenHere',
+ 'This link was recorded by an older Orca, so it can’t be opened from here.'
+ ),
+ sessionNoLongerRecorded: translate(
+ 'localTasks.work.session.noLongerRecorded',
+ 'This session is no longer recorded on the task.'
+ ),
+ ofSubtask: (title: string): string =>
+ translate('localTasks.work.session.ofSubtask', 'For “{{title}}”', { title }),
+ sessionStates: {
+ working: translate('localTasks.work.sessionState.working', 'Working'),
+ waiting: translate('localTasks.work.sessionState.waiting', 'Waiting for you'),
+ blocked: translate('localTasks.work.sessionState.blocked', 'Blocked'),
+ done: translate('localTasks.work.sessionState.done', 'Finished a turn'),
+ idle: translate('localTasks.work.sessionState.idle', 'Idle'),
+ exited: translate('localTasks.work.sessionState.exited', 'Ended'),
+ missing: translate('localTasks.work.sessionState.missing', 'Session not found'),
+ unknown: translate('localTasks.work.sessionState.unknown', 'Status unknown'),
+ unverifiable: translate('localTasks.work.sessionState.unverifiable', 'Can’t confirm')
+ } satisfies Record,
+ workspaceGone: translate('localTasks.work.workspace.gone', 'The workspace was deleted.'),
+ review: {
+ pr: (number: number): string =>
+ translate('localTasks.work.review.pr', 'PR #{{number}}', { number }),
+ mr: (number: number): string =>
+ translate('localTasks.work.review.mr', 'MR !{{number}}', { number }),
+ open: (label: string): string =>
+ translate('localTasks.work.review.open', '{{label}} open', { label }),
+ draft: (label: string): string =>
+ translate('localTasks.work.review.draft', '{{label}} draft', { label }),
+ merged: (label: string): string =>
+ translate('localTasks.work.review.merged', '{{label}} merged', { label }),
+ closed: (label: string): string =>
+ translate('localTasks.work.review.closed', '{{label}} closed', { label }),
+ unverifiable: (label: string): string =>
+ translate('localTasks.work.review.unverifiable', '{{label}} state can’t be confirmed', {
+ label
+ })
+ },
+ checks: {
+ success: translate('localTasks.work.checks.success', 'Checks passing'),
+ failure: translate('localTasks.work.checks.failure', 'Checks failing'),
+ pending: translate('localTasks.work.checks.pending', 'Checks running')
+ } satisfies Partial>,
+ starting: translate('localTasks.work.launch.starting', 'Starting…'),
+ startedNotLinked: translate(
+ 'localTasks.work.launch.startedNotLinked',
+ 'The workspace was created but not linked to the task. Use “Link an existing workspace” to link it.'
+ ),
+ sessionNotRecorded: translate(
+ 'localTasks.work.launch.sessionNotRecorded',
+ 'The agent started, but the session was not recorded on the task. It is in the workspace.'
+ ),
+ promptNotDelivered: translate(
+ 'localTasks.work.launch.promptNotDelivered',
+ 'The agent started but did not take the task text, which can happen while it shows a prompt of its own. The text is still on the task.'
+ ),
+ launchUnconfirmed: translate(
+ 'localTasks.work.launch.unconfirmed',
+ "Couldn't confirm the agent started. Check the workspace before trying again."
+ ),
+ workspaceUnconfirmed: translate(
+ 'localTasks.work.launch.workspaceUnconfirmed',
+ "Couldn't confirm the workspace was created. Check the workspace list before trying again."
+ ),
+ updateDesktop: translate(
+ 'localTasks.work.launch.updateDesktop',
+ "Update this host's Orca to start agents from your phone."
+ ),
+ linkToTask: translate('localTasks.work.link.toTask', 'Link to task…'),
+ linkToTaskTitle: (workspace: string): string =>
+ translate('localTasks.work.link.toTaskTitle', 'Link “{{workspace}}” to a task', {
+ workspace
+ }),
+ linkSessionToTask: translate('localTasks.work.link.session', 'Link this session…'),
+ linkSessionTitle: translate('localTasks.work.link.sessionTitle', 'This session works on'),
+ noOpenTasks: translate('localTasks.work.link.noOpenTasks', 'No open tasks on this host.'),
+ hasWorkspace: (name: string): string =>
+ translate('localTasks.work.link.hasWorkspace', 'Has {{name}}', { name }),
+ rulesSection: translate('localTasks.work.rules.section', 'Automatic status rules'),
+ ruleStart: translate(
+ 'localTasks.work.rules.start',
+ 'Starting work moves the task to In Progress'
+ ),
+ ruleStartHint: translate(
+ 'localTasks.work.rules.startHint',
+ 'When a workspace or the first session is linked, a Backlog or Todo task moves to the first In Progress status.'
+ ),
+ rulePrMerged: translate('localTasks.work.rules.prMerged', 'A merged PR completes the task'),
+ rulePrMergedHint: translate(
+ 'localTasks.work.rules.prMergedHint',
+ 'Checked on the desktop while its window is open.'
+ )
+ }
+}
+
+export type LocalTasksWorkCopy = ReturnType
diff --git a/mobile/src/local-tasks/use-local-task-attention-actions.ts b/mobile/src/local-tasks/use-local-task-attention-actions.ts
index ead36ea4f859..f0526cf134e9 100644
--- a/mobile/src/local-tasks/use-local-task-attention-actions.ts
+++ b/mobile/src/local-tasks/use-local-task-attention-actions.ts
@@ -101,14 +101,21 @@ export function useLocalTaskAttentionActions(args: {
(entry) => entry.id === item.taskSessionId
)
setReplies((previous) => ({ ...previous, [item.key]: { kind: 'sending' } }))
+ const work = args.copy.work
const outcome: LocalTaskReplyOutcome = session
? await sendLocalTaskReply({
client: args.client,
session,
text,
- missing: args.copy.work.replySessionMissing
+ reasons: {
+ missing: work.replySessionMissing,
+ notConnected: work.replyNotConnected,
+ inputNotCleared: work.replyInputNotCleared,
+ inputRefused: work.replyInputRefused,
+ chatNotReady: work.replyChatNotReady
+ }
})
- : { kind: 'not-sent', reason: args.copy.work.replySessionMissing }
+ : { kind: 'not-sent', reason: work.replyNoSession }
setReplies((previous) => ({ ...previous, [item.key]: outcome }))
args.library.reload(['attention'])
return outcome
diff --git a/mobile/src/local-tasks/use-local-task-card-context.ts b/mobile/src/local-tasks/use-local-task-card-context.ts
new file mode 100644
index 000000000000..9919dccee0f2
--- /dev/null
+++ b/mobile/src/local-tasks/use-local-task-card-context.ts
@@ -0,0 +1,67 @@
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { localTaskListedHostId, type LocalTaskSite } from './local-task-endpoint'
+import type { LocalTaskCardContext } from './local-task-needs-you-list'
+import { localTaskWorkspaceLinkFor } from './local-task-progress-projection'
+import { localTaskReviewLabel } from './local-task-workspace-review-line'
+import { localTaskWorkspaceVerdict } from './local-task-workspace-row'
+import { useLocalTaskHostDirectory } from './use-local-task-host-labels'
+import { useLocalTaskWorkspaceListing } from './use-local-task-workspace-listing'
+import type { LocalTasksLibrary } from './use-local-tasks-library'
+
+/**
+ * How the page's cards name hosts and PRs. Host names and the paired host's word on its SSH hosts
+ * are read once; the workspace list is read once per set of tasks awaiting acceptance, and only
+ * while the library's host is in contact. Nothing retries or polls.
+ */
+export function useLocalTaskCardContext(args: {
+ site: LocalTaskSite
+ library: Pick
+ reviewTasks: readonly LocalTask[]
+}): { context: LocalTaskCardContext; connected: ReadonlyMap } {
+ const { site, library } = args
+ const listed = (ownerHostId: string) =>
+ localTaskListedHostId(site.endpoint, ownerHostId) ?? ownerHostId
+ const directory = useLocalTaskHostDirectory(
+ site.pairedClient,
+ library.work.links.workspaces.map((link) => listed(link.hostId))
+ )
+ const tasksById = new Map(library.tasks.map((task) => [task.id, task]))
+ const reviewLinks = args.reviewTasks.flatMap((task) => {
+ const link = localTaskWorkspaceLinkFor(task, tasksById, library.work.links)
+ return link ? [{ task, link }] : []
+ })
+ const subject =
+ reviewLinks.length > 0
+ ? reviewLinks
+ .map(({ link }) => [link.taskId, link.hostId, link.worktreeId, link.linkedAt].join(':'))
+ .sort()
+ .join('\u0000')
+ : null
+ const listing = useLocalTaskWorkspaceListing({
+ client: site.client,
+ key: site.endpoint.executionHost ?? site.hostId,
+ subject,
+ open: site.connState === 'connected' && subject !== null
+ })
+ const hostConnected = (ownerHostId: string): boolean | null =>
+ ownerHostId === 'local' ? null : (directory.connected.get(listed(ownerHostId)) ?? null)
+ const context: LocalTaskCardContext = {
+ hostName: (ownerHostId, snapshot) =>
+ ownerHostId === 'local'
+ ? site.label
+ : (directory.labels.get(listed(ownerHostId)) ?? snapshot ?? ''),
+ hostConnected,
+ reviewDetail: (task) => {
+ const entry = reviewLinks.find((candidate) => candidate.task.id === task.id)
+ if (!entry || listing.status !== 'ready') {
+ return null
+ }
+ const inContact = hostConnected(entry.link.hostId) !== false
+ const verdict = localTaskWorkspaceVerdict(entry.link, listing.listing, inContact)
+ return verdict.kind === 'found' && verdict.review
+ ? localTaskReviewLabel(verdict.review, inContact)
+ : null
+ }
+ }
+ return { context, connected: directory.connected }
+}
diff --git a/mobile/src/local-tasks/use-local-task-create-agents.ts b/mobile/src/local-tasks/use-local-task-create-agents.ts
new file mode 100644
index 000000000000..166d49461b2a
--- /dev/null
+++ b/mobile/src/local-tasks/use-local-task-create-agents.ts
@@ -0,0 +1,71 @@
+import { useEffect, useRef, useState } from 'react'
+import type { MobileNewTabAgentOption } from '../session/mobile-new-tab-agent-options'
+import { loadLocalTaskAgentOptions } from './local-task-agent-options'
+import type { LocalTaskSite } from './local-task-endpoint'
+import { readLocalTaskHostContact } from './local-task-host-contact'
+import type { LocalTaskAgentChoices } from './use-local-task-launch'
+
+export type LocalTaskCreateAgents = { status: 'idle' } | LocalTaskAgentChoices
+
+/**
+ * The agents 开工 offers on the host the task will work on (owner-relative id), read once per host
+ * the user picks while the library is in contact; a host already read is not asked again. The
+ * user's default comes first, as the "+" new-tab sheet lists them.
+ */
+export function useLocalTaskCreateAgents(
+ site: LocalTaskSite | null,
+ ownerHostId: string | null
+): LocalTaskCreateAgents {
+ const [read, setRead] = useState<{ hostId: string; agents: LocalTaskAgentChoices } | null>(null)
+ const known = useRef(new Map())
+ const launchClient = site?.client ?? null
+ const pairedClient = site?.pairedClient ?? null
+ useEffect(() => {
+ if (!site || !launchClient || !pairedClient || !ownerHostId) {
+ return
+ }
+ const cached = known.current.get(ownerHostId)
+ if (cached) {
+ setRead({ hostId: ownerHostId, agents: { status: 'ready', agents: cached } })
+ return
+ }
+ let current = true
+ const settle = (agents: LocalTaskAgentChoices) => {
+ if (current) {
+ setRead({ hostId: ownerHostId, agents })
+ }
+ }
+ void readLocalTaskHostContact(launchClient, site.endpoint, ownerHostId).then((contact) => {
+ if (!current) {
+ return
+ }
+ if (contact.kind !== 'in-contact') {
+ settle({
+ status: 'failed',
+ reason: contact.kind === 'out-of-contact' ? 'offline' : 'unverifiable'
+ })
+ return
+ }
+ void loadLocalTaskAgentOptions({
+ client: pairedClient,
+ launchClient,
+ hostCapabilities: site.capabilities,
+ target: { kind: 'create', hostId: ownerHostId }
+ }).then(
+ (agents) => {
+ known.current.set(ownerHostId, agents)
+ settle({ status: 'ready', agents })
+ },
+ () => settle({ status: 'failed', reason: 'read' })
+ )
+ })
+ return () => {
+ current = false
+ }
+ // Why keyed on the host and clients: a new host is asked once, the same one never twice.
+ }, [ownerHostId, launchClient, pairedClient])
+ if (!ownerHostId || !site) {
+ return { status: 'idle' }
+ }
+ return read?.hostId === ownerHostId ? read.agents : { status: 'loading' }
+}
diff --git a/mobile/src/local-tasks/use-local-task-create-source-tab.ts b/mobile/src/local-tasks/use-local-task-create-source-tab.ts
new file mode 100644
index 000000000000..68be67503497
--- /dev/null
+++ b/mobile/src/local-tasks/use-local-task-create-source-tab.ts
@@ -0,0 +1,151 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results'
+import type { LocalTasksHostState } from '../../../src/shared/local-tasks/local-task-types'
+import type { RpcClient } from '../transport/rpc-client'
+import { createLocalTasksApi } from './local-tasks-api'
+import { localTasksCopy } from './local-tasks-copy'
+import { LOCAL_TASK_CREATE_SOURCE_COPY_EN } from './local-task-create-source-copy'
+import {
+ buildLocalTaskCreateSourceRows,
+ isLocalTaskCreateSourceRow,
+ type LocalTaskCreateLibrary,
+ type LocalTaskCreateSourceRow
+} from './local-task-create-source-rows'
+import type { LocalTaskCreateSource } from './use-local-task-create-source'
+
+type Reading = {
+ client: RpcClient
+ status: 'loading' | 'ready' | 'failed'
+ /** The last library read; kept while a later read is pending or failed. */
+ library: LocalTaskCreateLibrary | null
+ hostState: LocalTasksHostState | null
+}
+
+export type LocalTaskCreateSourceTab = {
+ active: boolean
+ rows: readonly LocalTaskCreateSourceRow[]
+ loading: boolean
+ /** Takes a Local row; false for any other row or one that cannot be picked. */
+ select: (row: { kind: string }) => boolean
+}
+
+const NO_ROWS: LocalTaskCreateSourceRow[] = []
+
+function blockedReason(
+ reading: Reading | null,
+ connected: boolean,
+ libraryLabel: string
+): string | null {
+ if (!connected) {
+ return LOCAL_TASK_CREATE_SOURCE_COPY_EN.offline(libraryLabel)
+ }
+ if (reading?.status === 'failed') {
+ return localTasksCopy().loadFailed
+ }
+ if (reading?.hostState === 'read-only') {
+ return LOCAL_TASK_CREATE_SOURCE_COPY_EN.readOnly
+ }
+ if (reading?.hostState && reading.hostState !== 'ready') {
+ return LOCAL_TASK_CREATE_SOURCE_COPY_EN.unavailable
+ }
+ return null
+}
+
+/**
+ * The drawer's Local tab: the library is read each time the user opens the tab while its Orca is
+ * in contact, and again when contact comes back with the tab open — never on a timer, and a failed
+ * read waits for the next of those. Out of contact the last read stays, greyed, behind a notice.
+ */
+export function useLocalTaskCreateSourceTab(
+ source: LocalTaskCreateSource | null | undefined,
+ mode: SmartNameMode,
+ query: string
+): LocalTaskCreateSourceTab {
+ const scope = source?.tab ?? null
+ const client = scope?.client ?? null
+ const connected = scope?.connected === true
+ const active = mode === 'local' && client !== null
+ const [reading, setReading] = useState(null)
+ useEffect(() => {
+ if (!active || !client || !connected) {
+ return
+ }
+ let current = true
+ setReading((previous) => ({
+ client,
+ status: 'loading',
+ library: previous?.client === client ? previous.library : null,
+ hostState: previous?.client === client ? previous.hostState : null
+ }))
+ const api = createLocalTasksApi(client)
+ Promise.all([api.status(), api.projects(), api.tasks(), api.links()]).then(
+ ([status, projects, tasks, links]) => {
+ if (current) {
+ setReading({
+ client,
+ status: 'ready',
+ library: { projects, tasks, links },
+ hostState: status.state
+ })
+ }
+ },
+ () => {
+ if (current) {
+ setReading((previous) =>
+ previous?.client === client ? { ...previous, status: 'failed' } : previous
+ )
+ }
+ }
+ )
+ return () => {
+ current = false
+ }
+ }, [active, client, connected])
+ const own = reading?.client === client ? reading : null
+ const libraryLabel = scope?.libraryLabel ?? ''
+ const orcaProjectId = scope?.orcaProjectId ?? null
+ const rows = useMemo(() => {
+ if (!active) {
+ return NO_ROWS
+ }
+ const blocked = blockedReason(own, connected, libraryLabel)
+ if (!own?.library) {
+ return blocked ? buildLocalTaskCreateSourceRowsForNotice(blocked) : NO_ROWS
+ }
+ return buildLocalTaskCreateSourceRows({
+ library: own.library,
+ orcaProjectId,
+ query,
+ blocked,
+ hasWorkspace: localTasksCopy().work.hasWorkspace
+ })
+ }, [active, connected, libraryLabel, orcaProjectId, own, query])
+ const pickTask = scope?.select
+ const tasks = own?.library?.tasks
+ const select = useCallback(
+ (row: { kind: string }): boolean => {
+ if (!pickTask || !tasks || !isLocalTaskCreateSourceRow(row) || !row.task || row.disabled) {
+ return false
+ }
+ pickTask(row.task, tasks)
+ return true
+ },
+ [pickTask, tasks]
+ )
+ return {
+ active,
+ rows,
+ loading: active && own?.status === 'loading',
+ select
+ }
+}
+
+function buildLocalTaskCreateSourceRowsForNotice(notice: string): LocalTaskCreateSourceRow[] {
+ return buildLocalTaskCreateSourceRows({
+ library: { projects: [], tasks: [], links: { workspaces: [], sessions: [] } },
+ orcaProjectId: null,
+ query: '',
+ blocked: notice,
+ hasWorkspace: () => ''
+ })
+}
diff --git a/mobile/src/local-tasks/use-local-task-create-source.test.tsx b/mobile/src/local-tasks/use-local-task-create-source.test.tsx
new file mode 100644
index 000000000000..e35498d922a6
--- /dev/null
+++ b/mobile/src/local-tasks/use-local-task-create-source.test.tsx
@@ -0,0 +1,291 @@
+import { createElement, useState } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, describe, expect, it } from 'vitest'
+import { LOCAL_TASKS_RUNTIME_CAPABILITY } from '../../../src/shared/local-tasks/local-tasks-capability'
+import type { LocalTask, LocalTaskProject } from '../../../src/shared/local-tasks/local-task-types'
+import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results'
+import type { MobileWorkspaceRepo } from '../components/new-worktree-modal-types'
+import { resolveAvailableSmartModes } from '../tasks/mobile-smart-source-modes'
+import { scopeRpcClientToExecutionHost } from '../transport/execution-host-scoped-rpc-client'
+import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
+import type { RpcResponse } from '../transport/types'
+import { localTasksCopy } from './local-tasks-copy'
+import {
+ useLocalTaskCreateSource,
+ type LocalTaskCreateSource
+} from './use-local-task-create-source'
+import {
+ useLocalTaskCreateSourceTab,
+ type LocalTaskCreateSourceTab
+} from './use-local-task-create-source-tab'
+
+const project: LocalTaskProject = {
+ id: 'project-shop',
+ name: 'Shop',
+ prefix: 'SHOP',
+ aliases: [],
+ repos: [],
+ defaultRepoId: null,
+ lastExecutionHostId: null,
+ rules: { startMovesToInProgress: true, prMergedCompletes: true },
+ statuses: [
+ {
+ id: 'todo',
+ projectId: 'project-shop',
+ category: 'unstarted',
+ name: 'Todo',
+ color: 'gray',
+ position: 0
+ }
+ ],
+ createdAt: 1,
+ updatedAt: 1,
+ revision: 1
+}
+
+function task(number: number, title: string): LocalTask {
+ return {
+ id: `task-${number}`,
+ projectId: project.id,
+ number,
+ ref: `SHOP-${number}`,
+ title,
+ description: '',
+ criteria: [],
+ statusId: 'todo',
+ priority: 0,
+ labels: [],
+ parentId: null,
+ createdAt: number,
+ updatedAt: number,
+ revision: 1
+ }
+}
+
+const tasks = [task(12, 'Export orders as CSV'), task(3, 'Fix checkout rounding')]
+
+type Library = {
+ capabilities?: string[]
+ projectCount?: number
+ sourceHidden?: boolean
+}
+
+/** One Orca's answers; `methods` lists every call it got, in order. */
+function orca(library: Library = {}) {
+ const host = new FakeSession('connected')
+ const methods: string[] = []
+ const answer = (method: string): unknown => {
+ switch (method) {
+ case 'status.get':
+ return { capabilities: library.capabilities ?? [LOCAL_TASKS_RUNTIME_CAPABILITY] }
+ case 'localTasks.status':
+ return {
+ taskHost: true,
+ state: 'ready',
+ reason: null,
+ revision: 1,
+ taskHostId: 'library-1',
+ projectCount: library.projectCount ?? 1,
+ ...(library.sourceHidden === undefined ? {} : { sourceHidden: library.sourceHidden })
+ }
+ case 'localTasks.projects.list':
+ return { projects: [project] }
+ case 'localTasks.tasks.list':
+ return { tasks }
+ case 'localTasks.links.list':
+ return {
+ workspaces: [
+ {
+ taskId: 'task-3',
+ hostId: 'local',
+ kind: 'git-worktree',
+ instanceId: null,
+ worktreeId: 'repo-1::/w/shop-3',
+ folderWorkspaceId: null,
+ repoId: 'repo-1',
+ branchSnapshot: null,
+ nameSnapshot: 'shop-3',
+ hostLabelSnapshot: null,
+ linkedVia: 'start',
+ linkedAt: 1
+ }
+ ],
+ sessions: []
+ }
+ default:
+ throw new Error(`unexpected ${method}`)
+ }
+ }
+ host.sendRequest.mockImplementation(async (method: string): Promise => {
+ methods.push(method)
+ return { id: method, ok: true, result: answer(method) }
+ })
+ return { host, methods }
+}
+
+const localRepo: MobileWorkspaceRepo = { id: 'repo-1', displayName: 'shop', path: '/src/shop' }
+
+type Seen = {
+ source: LocalTaskCreateSource
+ tab: LocalTaskCreateSourceTab
+ name: string
+}
+
+function Harness(props: {
+ desktopClient: FakeSession
+ client: FakeSession | ReturnType
+ repo: MobileWorkspaceRepo
+ mode: SmartNameMode
+ seen: { current: Seen | null }
+}) {
+ const [name, setName] = useState('')
+ const source = useLocalTaskCreateSource({
+ visible: true,
+ desktopClient: props.desktopClient,
+ client: props.client,
+ repo: props.repo,
+ repoIsGit: props.repo.kind !== 'folder',
+ hostLabelById: new Map([['local', 'MacBook']]),
+ hostPlatform: 'darwin',
+ composer: { name, setName, handleClearSmartNameSelection: () => {} }
+ })
+ const tab = useLocalTaskCreateSourceTab(source, props.mode, name)
+ props.seen.current = { source, tab, name }
+ return null
+}
+
+let renderer: ReactTestRenderer | null = null
+afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+})
+
+async function settle(): Promise {
+ for (let turn = 0; turn < 5; turn += 1) {
+ await act(async () => {
+ await Promise.resolve()
+ })
+ }
+}
+
+async function mount(
+ props: Omit[0], 'seen' | 'mode'> & { mode?: SmartNameMode }
+): Promise<{ current: Seen | null }> {
+ const seen: { current: Seen | null } = { current: null }
+ await act(async () => {
+ renderer = create(createElement(Harness, { mode: 'smart', ...props, seen }))
+ })
+ await settle()
+ return seen
+}
+
+const offeredModes = (source: LocalTaskCreateSource) =>
+ resolveAvailableSmartModes({
+ textOnly: false,
+ tasksSupported: true,
+ hasRepo: true,
+ githubAvailable: true,
+ gitlabAvailable: false,
+ linearAvailable: false,
+ jiraAvailable: false,
+ localTaskSourceOffered: source.offered
+ })
+
+describe('Create From → Local on the phone', () => {
+ it('sits right after Smart for a git repo whose library has a project and shows Local', async () => {
+ const { host } = orca()
+ const seen = await mount({ desktopClient: host, client: host, repo: localRepo })
+ expect(offeredModes(seen.current!.source)).toEqual([
+ 'smart',
+ 'local',
+ 'github',
+ 'branches',
+ 'text'
+ ])
+ })
+
+ it.each([
+ ['an Orca older than local tasks', { capabilities: [] }, false],
+ ['Local hidden in Task sources', { sourceHidden: true }, true],
+ ['a library without projects', { projectCount: 0 }, true]
+ ])('is not offered for %s, and never reads its tasks', async (_case, library, asksStatus) => {
+ const { host, methods } = orca(library)
+ const seen = await mount({ desktopClient: host, client: host, repo: localRepo, mode: 'local' })
+ expect(seen.current!.source.offered).toBe(false)
+ expect(seen.current!.tab.active).toBe(false)
+ expect(methods.includes('localTasks.status')).toBe(asksStatus)
+ expect(
+ methods.filter((method) => method.startsWith('localTasks.') && method !== 'localTasks.status')
+ ).toEqual([])
+ })
+
+ it('is not offered for a folder project, which asks nothing', async () => {
+ const { host, methods } = orca()
+ const seen = await mount({
+ desktopClient: host,
+ client: host,
+ repo: { ...localRepo, kind: 'folder' }
+ })
+ expect(seen.current!.source.offered).toBe(false)
+ expect(methods).toEqual([])
+ })
+
+ it("is not offered on a server when the paired Orca can't relay its task calls", async () => {
+ const desktop = orca({ capabilities: [] })
+ const server = scopeRpcClientToExecutionHost(desktop.host, 'runtime:env-1')
+ const seen = await mount({
+ desktopClient: desktop.host,
+ client: server,
+ repo: { ...localRepo, executionHostId: 'runtime:env-1' }
+ })
+ expect(seen.current!.source.offered).toBe(false)
+ expect(desktop.methods.filter((method) => method.startsWith('localTasks.'))).toEqual([])
+ })
+
+ it('leads rows with the title, greys a task that has a workspace, and picking one names the workspace', async () => {
+ const { host } = orca()
+ const seen = await mount({ desktopClient: host, client: host, repo: localRepo, mode: 'local' })
+ const rows = seen.current!.tab.rows
+ expect(rows.map((row) => [row.task?.title, row.task?.ref, row.disabled, row.note])).toEqual([
+ ['Export orders as CSV', 'SHOP-12', false, null],
+ ['Fix checkout rounding', 'SHOP-3', true, localTasksCopy().work.hasWorkspace('shop-3')]
+ ])
+ expect(seen.current!.tab.select(rows[1]!)).toBe(false)
+ await act(async () => {
+ expect(seen.current!.tab.select(rows[0]!)).toBe(true)
+ })
+ expect(seen.current!.source.picked?.id).toBe('task-12')
+ expect(seen.current!.name).toBe('shop-12-export-orders-as-csv')
+ expect(seen.current!.source.create).not.toBeNull()
+ })
+
+ it('out of contact keeps the last read greyed behind the reason, and reads again only once contact is back', async () => {
+ const { host, methods } = orca()
+ const seen = await mount({ desktopClient: host, client: host, repo: localRepo, mode: 'local' })
+ const reads = () => methods.filter((method) => method === 'localTasks.tasks.list').length
+ expect(reads()).toBe(1)
+
+ await act(async () => {
+ host.publishState('disconnected')
+ })
+ await settle()
+ const offline = seen.current!.tab.rows
+ expect(seen.current!.source.offered).toBe(true)
+ expect(offline[0]).toMatchObject({
+ task: null,
+ note: expect.stringContaining('MacBook is not connected')
+ })
+ expect(offline.slice(1).map((row) => [row.task?.ref, row.disabled])).toEqual([
+ ['SHOP-12', true],
+ ['SHOP-3', true]
+ ])
+ expect(reads()).toBe(1)
+
+ await act(async () => {
+ host.publishState('connected')
+ })
+ await settle()
+ expect(reads()).toBe(2)
+ expect(seen.current!.tab.rows[0]?.task?.ref).toBe('SHOP-12')
+ })
+})
diff --git a/mobile/src/local-tasks/use-local-task-create-source.ts b/mobile/src/local-tasks/use-local-task-create-source.ts
new file mode 100644
index 000000000000..537e00793ea3
--- /dev/null
+++ b/mobile/src/local-tasks/use-local-task-create-source.ts
@@ -0,0 +1,202 @@
+import { useCallback, useEffect, useState } from 'react'
+import {
+ getRepoExecutionHostId,
+ LOCAL_EXECUTION_HOST_ID,
+ type ExecutionHostId
+} from '../../../src/shared/execution-host'
+import { suggestLocalTaskWorkspaceName } from '../../../src/shared/local-tasks/local-task-model'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
+import { getHostContextLabel } from '../../../src/shared/worktree/host-context-labels'
+import type { MobileWorkspaceRepo } from '../components/new-worktree-modal-types'
+import { taskRuntimeStatusRead } from '../tasks/mobile-task-runtime-operations'
+import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
+import type { RpcClient } from '../transport/rpc-client'
+import { hostAdvertisesLocalTasks } from './local-tasks-access'
+import { localTasksCopy } from './local-tasks-copy'
+import { LOCAL_TASK_CREATE_SOURCE_COPY_EN } from './local-task-create-source-copy'
+import {
+ createLocalTaskSourcedWorkspace,
+ localTaskCreateOwner,
+ type LocalTaskCreateRun
+} from './local-task-create-source-start'
+import { useLocalTaskLibraryOffersTasks } from './local-task-library-presence'
+import { createLocalTaskOperationIds } from './local-task-operation-ids'
+
+/** What the drawer's Local tab needs from the sheet: where to read, and how to pick. */
+export type LocalTaskCreateSourceTabScope = {
+ /** The library's client while the tab is offered; null otherwise. */
+ client: RpcClient | null
+ connected: boolean
+ /** The library's Orca, named for "X is not connected". */
+ libraryLabel: string
+ /** The picked repo's Orca project; its task projects rank first. */
+ orcaProjectId: string | null
+ select: (task: LocalTask, tasks: readonly LocalTask[]) => void
+}
+
+export type LocalTaskCreateSource = {
+ /** Create From offers Local: a git repo whose Orca advertises local tasks, has a project, shows Local. */
+ offered: boolean
+ /** The task this workspace is for, while the picked repo's library is the one it came from. */
+ picked: LocalTask | null
+ clear: () => void
+ /** Runs instead of the sheet's own create while a task is picked. */
+ create: LocalTaskCreateRun | null
+ tab: LocalTaskCreateSourceTabScope
+}
+
+type Advertised = { client: RpcClient; capabilities: readonly string[] | null }
+
+/**
+ * The library's client once its Orca advertises `local-tasks.v1`, and for a server's library the
+ * paired Orca too, which is what relays the phone's task calls there. Asked once per sheet opening;
+ * an older Orca is never sent a `localTasks.*` call, so Local is simply not offered there.
+ */
+function useAdvertisingLibrary(
+ visible: boolean,
+ desktopClient: RpcClient | null,
+ client: RpcClient | null,
+ key: string
+): { client: RpcClient; capabilities: readonly string[] } | null {
+ const [answer, setAnswer] = useState(null)
+ useEffect(() => {
+ if (!visible || !client || !desktopClient) {
+ return
+ }
+ let current = true
+ const capabilitiesOf = (target: RpcClient, hostKey: string) =>
+ taskRuntimeStatusRead
+ .requestSingleFlight(target, hostKey)
+ .then((reply) => taskRuntimeStatusRead.interpret(reply).capabilities ?? [])
+ void Promise.all([
+ capabilitiesOf(client, key),
+ client === desktopClient ? null : capabilitiesOf(desktopClient, 'self')
+ ]).then(
+ ([own, desktop]) => {
+ if (!current) {
+ return
+ }
+ const advertises =
+ hostAdvertisesLocalTasks(own) && (desktop === null || hostAdvertisesLocalTasks(desktop))
+ setAnswer({ client, capabilities: advertises ? own : null })
+ },
+ () => {}
+ )
+ return () => {
+ current = false
+ }
+ }, [client, desktopClient, key, visible])
+ return answer?.client === client && answer.capabilities
+ ? { client: answer.client, capabilities: answer.capabilities }
+ : null
+}
+
+/** Whether `client` is in contact now, read again whenever its connection changes. */
+function useClientConnected(client: RpcClient | null): boolean {
+ const [, setChanges] = useState(0)
+ useEffect(
+ () => (client ? client.onStateChange(() => setChanges((count) => count + 1)) : undefined),
+ [client]
+ )
+ return client?.getState() === 'connected'
+}
+
+type TaskPick = { client: RpcClient; task: LocalTask; tasks: readonly LocalTask[] }
+
+/**
+ * Create From's Local source on the phone's new-workspace sheet, as the desktop composer has it
+ * (REQ-009): offered for a git repo whose Orca holds a task library with a project and shows
+ * Local; the picked task shows as the source pill, names the workspace, and once created the
+ * workspace is linked in that Orca's library and the task is sent as the agent's first message.
+ * Out of contact the tab stays, greyed, from what was last read; nothing is asked until contact
+ * comes back.
+ */
+export function useLocalTaskCreateSource(args: {
+ visible: boolean
+ /** The paired Orca's client. */
+ desktopClient: RpcClient | null
+ /** The picked repo's: the paired Orca's own, or a server's scoped view. */
+ client: RpcClient | null
+ repo: MobileWorkspaceRepo | null
+ repoIsGit: boolean
+ hostLabelById?: ReadonlyMap
+ hostPlatform: NodeJS.Platform | null
+ composer: Pick
+}): LocalTaskCreateSource {
+ const { repo, composer } = args
+ const owner = repo && args.repoIsGit ? localTaskCreateOwner(repo) : null
+ const advertised = useAdvertisingLibrary(
+ args.visible,
+ args.desktopClient,
+ owner ? args.client : null,
+ owner?.endpoint.key ?? ''
+ )
+ const library = advertised?.client ?? null
+ const connected = useClientConnected(library)
+ const offered = useLocalTaskLibraryOffersTasks(library, connected) === true
+ const labels = { hostLabelById: args.hostLabelById, hostPlatform: args.hostPlatform }
+ const libraryLabel = getHostContextLabel(
+ owner?.endpoint.executionHost ?? LOCAL_EXECUTION_HOST_ID,
+ labels
+ )
+ const [ids] = useState(() => ({
+ link: createLocalTaskOperationIds(),
+ record: createLocalTaskOperationIds()
+ }))
+ const [pick, setPick] = useState(null)
+ // Why derived: a repo on another Orca hides the pick; switching back shows it again.
+ const picked = pick && offered && pick.client === library ? pick : null
+ const { name, setName, handleClearSmartNameSelection } = composer
+ const select = useCallback(
+ (task: LocalTask, tasks: readonly LocalTask[]) => {
+ if (!library) {
+ return
+ }
+ // Why: one source per workspace; a task replaces an issue, PR or branch picked before.
+ handleClearSmartNameSelection()
+ setName(suggestLocalTaskWorkspaceName(task.ref, task.title))
+ setPick({ client: library, task, tasks })
+ },
+ [handleClearSmartNameSelection, library, setName]
+ )
+ const clear = useCallback(() => {
+ if (picked && name === suggestLocalTaskWorkspaceName(picked.task.ref, picked.task.title)) {
+ setName('')
+ }
+ setPick(null)
+ }, [name, picked, setName])
+ const create: LocalTaskCreateRun | null =
+ picked && advertised && owner && repo
+ ? async (request) => {
+ if (picked.client.getState() !== 'connected') {
+ return { error: LOCAL_TASK_CREATE_SOURCE_COPY_EN.offline(libraryLabel) }
+ }
+ return createLocalTaskSourcedWorkspace({
+ client: picked.client,
+ capabilities: advertised.capabilities,
+ ids,
+ task: picked.task,
+ tasks: picked.tasks,
+ repoId: repo.id,
+ ownerHostId: owner.ownerHostId,
+ hostLabel: getHostContextLabel(getRepoExecutionHostId(repo), labels),
+ request,
+ copy: localTasksCopy()
+ })
+ }
+ : null
+ return {
+ offered,
+ picked: picked?.task ?? null,
+ clear,
+ create,
+ tab: {
+ client: offered ? library : null,
+ connected,
+ libraryLabel,
+ orcaProjectId: repo ? getProjectIdentityKey(repo) : null,
+ select
+ }
+ }
+}
diff --git a/mobile/src/local-tasks/use-local-task-create-where.ts b/mobile/src/local-tasks/use-local-task-create-where.ts
new file mode 100644
index 000000000000..9969788a8789
--- /dev/null
+++ b/mobile/src/local-tasks/use-local-task-create-where.ts
@@ -0,0 +1,320 @@
+import { useState } from 'react'
+import {
+ localTaskCreatePlan,
+ localTaskCreatePlanReady,
+ localTaskCreateStartsSession,
+ type LocalTaskCreateWhere
+} from '../../../src/shared/local-tasks/local-task-create-plan'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import type { TuiAgent } from '../../../src/shared/tui-agent'
+import type { LocalTasksApi } from './local-tasks-api'
+import { localTasksCopy } from './local-tasks-copy'
+import {
+ localTaskCreateDefaultRepo,
+ localTaskCreateRepoChoices,
+ localTaskCreateWorkspaceChoices,
+ localTaskCreateWorkspaceGroups,
+ type LocalTaskCreateHosts,
+ type LocalTaskCreateRepoChoice,
+ type LocalTaskCreateWorkspaceChoice
+} from './local-task-create-choices'
+import { localTaskCreateCopy } from './local-task-create-copy'
+import {
+ kickOffCreatedLocalTask,
+ localTaskCreateKickoffBlocked,
+ type LocalTaskCreateKickoff
+} from './local-task-create-kickoff'
+import { localTaskListedHostId, type LocalTaskSite } from './local-task-endpoint'
+import type { LocalTaskDraft } from './local-task-form-state'
+import { localTaskHostLabel } from './local-task-launch-sheet'
+import { createLocalTaskOperationIds } from './local-task-operation-ids'
+import { localTaskSetupsFor } from './local-task-project-setups'
+import { useLocalTaskCreateAgents } from './use-local-task-create-agents'
+import { useLocalTaskCreateWorkspaces } from './use-local-task-create-workspaces'
+import { useLocalTaskHostDirectory } from './use-local-task-host-labels'
+import { useLocalTaskProjectCatalog } from './use-local-task-project-catalog'
+import type { LocalTasksLibrary } from './use-local-tasks-library'
+
+/** What the form runs around its create when work starts somewhere. */
+export type LocalTaskCreateKickoffRun = {
+ /** Why nothing should be created now, asked before the create. */
+ blocked: () => Promise
+ /** After the create: what to tell the user, or null when it all went through. */
+ run: (task: LocalTask) => Promise
+}
+
+/** A draft moved to another project: status and parent belong to the old one. */
+function inProject(draft: LocalTaskDraft, projectId: string | null): Partial {
+ return projectId && projectId !== draft.projectId
+ ? { projectId, statusId: null, parentId: null }
+ : {}
+}
+
+/**
+ * New task's 在哪做 and 开工 (m8): the library host's workspaces and repositories, the agents on
+ * the picked host, and the kickoff the form runs after creating. Without the library's host in
+ * contact both steps stay at 稍后再定 and say why; contact coming back is what reads again.
+ */
+export function useLocalTaskCreateWhere(args: {
+ active: boolean
+ site: LocalTaskSite | undefined
+ api: LocalTasksApi
+ library: LocalTasksLibrary
+ openedProjectId: string
+ draft: LocalTaskDraft
+ patch: (next: Partial) => void
+}) {
+ const create = localTaskCreateCopy()
+ const { library, draft } = args
+ const site = args.site ?? null
+ const inContact = site?.connState === 'connected'
+ const unavailable = !args.active
+ ? null
+ : !site
+ ? create.noSite
+ : !inContact
+ ? create.libraryOffline(site.label)
+ : null
+ const reading = args.active && site !== null && inContact
+ const [whereChoice, setWhereChoice] = useState('existing')
+ const [pickedKey, setPickedKey] = useState(null)
+ const [repoKey, setRepoKey] = useState(null)
+ const [query, setQuery] = useState('')
+ const [workspaceName, setWorkspaceName] = useState('')
+ const [agentChoice, setAgentChoice] = useState(null)
+ const [firstLine, setFirstLine] = useState(() => create.firstLineDefault)
+ const [ids] = useState(() => ({
+ link: createLocalTaskOperationIds(),
+ record: createLocalTaskOperationIds()
+ }))
+
+ const catalog = useLocalTaskProjectCatalog(reading ? site.pairedClient : null)
+ const workspaces = useLocalTaskCreateWorkspaces(
+ reading ? site.client : null,
+ site ? (site.endpoint.executionHost ?? site.hostId) : ''
+ )
+ const ready = catalog.state === 'ready' ? catalog.catalog : null
+ const setups = ready && site ? localTaskSetupsFor(ready, site.endpoint, []) : []
+ const candidates = workspaces.status === 'ready' ? workspaces.candidates : []
+ const listed = (ownerHostId: string) =>
+ (site ? localTaskListedHostId(site.endpoint, ownerHostId) : null) ?? ownerHostId
+ const directory = useLocalTaskHostDirectory(reading ? site.pairedClient : null, [
+ ...setups.map((setup) => setup.hostId),
+ ...candidates.map((candidate) => listed(candidate.hostId))
+ ])
+ const hosts: LocalTaskCreateHosts = {
+ name: (ownerHostId) =>
+ ownerHostId === 'local' && site
+ ? site.label
+ : (directory.labels.get(listed(ownerHostId)) ??
+ localTaskHostLabel(listed(ownerHostId), null)),
+ connected: (ownerHostId) =>
+ ownerHostId === 'local' ? null : (directory.connected.get(listed(ownerHostId)) ?? null)
+ }
+
+ const opened = library.projects.find((project) => project.id === args.openedProjectId) ?? null
+ const current = library.projects.find((project) => project.id === draft.projectId) ?? null
+ const firstOrcaProjectIds = opened?.repos ?? []
+ const choices = localTaskCreateWorkspaceChoices({
+ candidates,
+ listing: workspaces.status === 'ready' ? workspaces.listing : null,
+ mainIds: workspaces.status === 'ready' ? workspaces.mainIds : new Set(),
+ catalog: ready,
+ tasks: library.tasks,
+ links: library.work.links,
+ projects: library.projects,
+ currentProjectId: draft.projectId,
+ hosts
+ })
+ const groups = localTaskCreateWorkspaceGroups({ choices, query, firstOrcaProjectIds, hosts })
+ const picked = choices.find((choice) => choice.key === pickedKey && choice.block === null) ?? null
+ const repos = localTaskCreateRepoChoices({
+ setups,
+ projects: library.projects,
+ currentProjectId: draft.projectId,
+ firstOrcaProjectIds,
+ hosts
+ })
+ const repo =
+ repos.find((choice) => choice.key === repoKey && choice.block === null) ??
+ localTaskCreateDefaultRepo(repos, current)
+
+ const where: LocalTaskCreateWhere = unavailable ? 'later' : whereChoice
+ const targetHostId =
+ where === 'existing'
+ ? (picked?.holder?.hostId ?? picked?.candidate.hostId ?? null)
+ : where === 'new'
+ ? (repo?.setup.ownerHostId ?? null)
+ : null
+ const agents = useLocalTaskCreateAgents(reading ? site : null, targetHostId)
+ const options = agents.status === 'ready' ? agents.agents : []
+ const agent: TuiAgent | null =
+ agentChoice === 'none'
+ ? null
+ : ((options.find((option) => option.agent === agentChoice) ?? options[0])?.agent ?? null)
+ const agentsPending = agentChoice !== 'none' && agents.status === 'loading' && !!targetHostId
+ const agentLabel = options.find((option) => option.agent === agent)?.label ?? null
+ const plan = localTaskCreatePlan({
+ where,
+ existing: picked
+ ? {
+ workspace: picked.candidate.displayName,
+ host: hosts.name(picked.holder?.hostId ?? picked.candidate.hostId),
+ parentRef: picked.owner?.ref ?? null
+ }
+ : null,
+ fresh: repo
+ ? { workspace: workspaceName.trim(), host: hosts.name(repo.setup.ownerHostId) }
+ : null,
+ agent: agentLabel,
+ agentsPending
+ })
+
+ const ownerOf = (choice: LocalTaskCreateWorkspaceChoice | null) => choice?.owner ?? null
+ // Why the project follows the pick: a workspace's task takes the new one as its subtask, and a
+ // free workspace's repository says which project tracks it.
+ const pickWorkspace = (choice: LocalTaskCreateWorkspaceChoice) => {
+ if (choice.block !== null) {
+ return
+ }
+ setPickedKey(choice.key)
+ const owner = ownerOf(choice)
+ const moved = inProject(draft, choice.projectId)
+ const previousOwner = ownerOf(picked)
+ args.patch({
+ ...moved,
+ ...(owner
+ ? { parentId: owner.id }
+ : previousOwner && draft.parentId === previousOwner.id
+ ? { parentId: null }
+ : {})
+ })
+ }
+ const pickRepo = (choice: LocalTaskCreateRepoChoice) => {
+ if (choice.block !== null) {
+ return
+ }
+ setRepoKey(choice.key)
+ args.patch(inProject(draft, choice.projectId))
+ }
+ const changeWhere = (next: LocalTaskCreateWhere) => {
+ setWhereChoice(next)
+ const owner = ownerOf(picked)
+ if (next === 'existing' && picked) {
+ args.patch({
+ ...inProject(draft, picked.projectId),
+ ...(owner ? { parentId: owner.id } : {})
+ })
+ } else if (owner && draft.parentId === owner.id) {
+ args.patch({ parentId: null })
+ }
+ if (next === 'new' && repoKey && repo) {
+ args.patch(inProject(draft, repo.projectId))
+ }
+ }
+ /** A picker's own change: another project or parent no longer works in the owner's workspace. */
+ const draftChanged = (next: Partial) => {
+ const owner = ownerOf(picked)
+ const leavesOwner =
+ owner &&
+ ((next.projectId !== undefined && next.projectId !== owner.projectId) ||
+ (next.parentId !== undefined && next.parentId !== owner.id))
+ if (leavesOwner) {
+ setPickedKey(null)
+ }
+ }
+
+ const kickoff: LocalTaskCreateKickoff | null =
+ plan.kind === 'existing' && picked
+ ? {
+ target: {
+ kind: 'existing',
+ candidate: picked.candidate,
+ holder: picked.holder,
+ hostLabel: plan.host
+ },
+ agent,
+ firstLine
+ }
+ : plan.kind === 'new' && repo
+ ? {
+ target: {
+ kind: 'new',
+ ownerHostId: repo.setup.ownerHostId,
+ repoId: repo.setup.repoId,
+ name: workspaceName.trim(),
+ hostLabel: plan.host
+ },
+ agent,
+ firstLine
+ }
+ : null
+ const words = { copy: localTasksCopy(), create }
+ const run: LocalTaskCreateKickoffRun | null =
+ kickoff && site
+ ? {
+ blocked: () =>
+ localTaskCreateKickoffBlocked({
+ client: site.client,
+ endpoint: site.endpoint,
+ kickoff,
+ words
+ }),
+ run: async (task) => {
+ if (!site.client) {
+ return create.createdNotStarted(task.ref, words.copy.work.hostUnknown)
+ }
+ const message = await kickOffCreatedLocalTask({
+ client: site.client,
+ capabilities: site.capabilities,
+ api: args.api,
+ ids,
+ task,
+ tasks: library.tasks,
+ brief: library.status?.taskBrief ?? true,
+ kickoff,
+ words
+ })
+ library.reload(['links'])
+ return message
+ }
+ }
+ : null
+
+ return {
+ unavailable,
+ where,
+ changeWhere,
+ workspaces: workspaces.status,
+ groups,
+ hasWorkspaces: candidates.length > 0,
+ picked,
+ pickWorkspace,
+ query,
+ setQuery,
+ catalog: catalog.state,
+ repos,
+ repo,
+ pickRepo,
+ workspaceName,
+ setWorkspaceName,
+ hosts,
+ targetHostName: targetHostId ? hosts.name(targetHostId) : '',
+ agents,
+ agent,
+ agentChoice,
+ setAgentChoice,
+ firstLine,
+ setFirstLine,
+ briefOn: library.status?.taskBrief !== false,
+ plan,
+ planReady: localTaskCreatePlanReady(plan),
+ startsSession: localTaskCreateStartsSession({ where, agent: agentLabel, agentsPending }),
+ projectFollows:
+ (where === 'existing' && picked !== null) || (where === 'new' && repoKey !== null),
+ draftChanged,
+ kickoff: run
+ }
+}
+
+export type LocalTaskCreateWhereState = ReturnType
diff --git a/mobile/src/local-tasks/use-local-task-create-workspaces.ts b/mobile/src/local-tasks/use-local-task-create-workspaces.ts
new file mode 100644
index 000000000000..e74ee8985b05
--- /dev/null
+++ b/mobile/src/local-tasks/use-local-task-create-workspaces.ts
@@ -0,0 +1,71 @@
+import { useEffect, useState } from 'react'
+import type { RpcClient } from '../transport/rpc-client'
+import { worktreeCatalogRead } from '../worktree/worktree-catalog-operations'
+import { WORKTREE_PS_FULL_LIMIT } from '../worktree/worktree-catalog-snapshot-client'
+import { readLocalTaskMainWorkspaceIds } from './local-task-create-choices'
+import {
+ readLocalTaskWorkspaceCandidates,
+ type LocalTaskWorkspaceCandidate
+} from './local-task-workspace-candidates'
+import {
+ readLocalTaskWorkspaceListing,
+ type LocalTaskWorkspaceListing
+} from './local-task-workspace-row'
+
+export type LocalTaskCreateWorkspacesState =
+ | { status: 'loading' }
+ | { status: 'failed' }
+ | {
+ status: 'ready'
+ candidates: LocalTaskWorkspaceCandidate[]
+ /** The same page read for PR state; null when the host's reply carries none. */
+ listing: LocalTaskWorkspaceListing | null
+ mainIds: ReadonlySet
+ }
+
+/**
+ * New task's workspaces: one `worktree.ps` of the library's own host, read when the form opens
+ * while that host is in contact (`client` set) and again only when contact comes back. Shared in
+ * flight under `key` with the pickers and the host screen; nothing retries or polls.
+ */
+export function useLocalTaskCreateWorkspaces(
+ client: RpcClient | null,
+ key: string
+): LocalTaskCreateWorkspacesState {
+ const [read, setRead] = useState<{
+ client: RpcClient
+ state: LocalTaskCreateWorkspacesState
+ } | null>(null)
+ useEffect(() => {
+ if (!client) {
+ return
+ }
+ let current = true
+ const settle = (state: LocalTaskCreateWorkspacesState) => {
+ if (current) {
+ setRead({ client, state })
+ }
+ }
+ worktreeCatalogRead
+ .requestSingleFlight(client, key, { limit: WORKTREE_PS_FULL_LIMIT })
+ .then((reply) => {
+ const catalog = worktreeCatalogRead.interpret(reply)
+ if (!catalog.accepted) {
+ settle({ status: 'failed' })
+ return
+ }
+ const rows = catalog.value.worktrees ?? []
+ settle({
+ status: 'ready',
+ candidates: readLocalTaskWorkspaceCandidates(rows),
+ listing: readLocalTaskWorkspaceListing(catalog.value),
+ mainIds: readLocalTaskMainWorkspaceIds(rows)
+ })
+ })
+ .catch(() => settle({ status: 'failed' }))
+ return () => {
+ current = false
+ }
+ }, [client, key])
+ return read?.client === client ? read.state : { status: 'loading' }
+}
diff --git a/mobile/src/local-tasks/use-local-task-form-submit.ts b/mobile/src/local-tasks/use-local-task-form-submit.ts
index 7bbe6a17717e..d228aa7639f7 100644
--- a/mobile/src/local-tasks/use-local-task-form-submit.ts
+++ b/mobile/src/local-tasks/use-local-task-form-submit.ts
@@ -12,6 +12,7 @@ import {
type LocalTaskFormMode
} from './local-task-form-state'
import { createLocalTaskOperationIds, sendLocalTaskWrite } from './local-task-operation-ids'
+import type { LocalTaskCreateKickoffRun } from './use-local-task-create-where'
import type { LocalTasksLibrary } from './use-local-tasks-library'
import { useLocalTaskSubtaskBatch } from './use-local-task-subtask-batch'
@@ -107,7 +108,16 @@ export function useLocalTaskFormSubmit(api: LocalTasksApi, library: LocalTasksLi
return saved
}
- const saveNew = async (draft: LocalTaskDraft): Promise => {
+ const saveNew = async (
+ draft: LocalTaskDraft,
+ kickoff: LocalTaskCreateKickoffRun | null
+ ): Promise => {
+ // Why before the create: a host out of contact leaves nothing half done.
+ const blocked = kickoff ? await kickoff.blocked() : null
+ if (blocked) {
+ write.setError(blocked)
+ return null
+ }
const created = await sendLocalTaskWrite(
ids.create,
createLocalTaskPayload(draft),
@@ -119,6 +129,7 @@ export function useLocalTaskFormSubmit(api: LocalTasksApi, library: LocalTasksLi
}
const taskId = created.value.task.id
library.acceptTasks([created.value.task])
+ const problems: string[] = []
const titles = localTaskDraftTexts(draft.subtasks)
if (titles.length > 0) {
const subtasks = await sendLocalTaskWrite(
@@ -129,11 +140,18 @@ export function useLocalTaskFormSubmit(api: LocalTasksApi, library: LocalTasksLi
if (subtasks.kind === 'ok') {
library.acceptTasks(subtasks.value.tasks)
} else {
- // Why hold the id: the task exists, so leaving the form must open it, not retry it.
- setNotice({ text: copy.savedPartially, taskId })
- return null
+ problems.push(copy.savedPartially)
}
}
+ const kickoffProblem = kickoff ? await kickoff.run(created.value.task) : null
+ if (kickoffProblem) {
+ problems.push(kickoffProblem)
+ }
+ if (problems.length > 0) {
+ // Why hold the id: the task exists, so leaving the form must open it, not retry it.
+ setNotice({ text: problems.join('\n\n'), taskId })
+ return null
+ }
return taskId
}
@@ -141,8 +159,15 @@ export function useLocalTaskFormSubmit(api: LocalTasksApi, library: LocalTasksLi
busy: write.busy,
error: write.error,
notice: notice?.text ?? null,
- save: (mode: LocalTaskFormMode, draft: LocalTaskDraft) =>
- write.run(() => (mode.kind === 'edit' ? saveEdit(mode.task, draft) : saveNew(draft))),
+ /** `kickoff` puts a new task to work once it exists (m8); the form stays busy until it settles. */
+ save: (
+ mode: LocalTaskFormMode,
+ draft: LocalTaskDraft,
+ kickoff: LocalTaskCreateKickoffRun | null = null
+ ) =>
+ write.run(() =>
+ mode.kind === 'edit' ? saveEdit(mode.task, draft) : saveNew(draft, kickoff)
+ ),
...subtaskBatch,
conflict: conflict !== null,
/** Writes the user's own changes over the latest version. */
diff --git a/mobile/src/local-tasks/use-local-task-host-labels.ts b/mobile/src/local-tasks/use-local-task-host-labels.ts
index 2ba14ea568ee..a1925d110f18 100644
--- a/mobile/src/local-tasks/use-local-task-host-labels.ts
+++ b/mobile/src/local-tasks/use-local-task-host-labels.ts
@@ -1,19 +1,31 @@
import { useEffect, useState } from 'react'
import { parseExecutionHostId } from '../../../src/shared/execution-host'
-import { buildHostLabelById } from '../../../src/shared/worktree/host-context-labels'
+import {
+ buildHostLabelById,
+ sshTargetSummaryHostId
+} from '../../../src/shared/worktree/host-context-labels'
import { hostSshTargetSummariesRead } from '../host-screen/host-screen-operations'
import type { RpcClient } from '../transport/rpc-client'
import { relayHostsFeed } from '../worktree/relay-hosts-feed'
+export type LocalTaskHostDirectory = {
+ labels: ReadonlyMap
+ /** The paired host's own word on its SSH targets; absent when it gave none. */
+ connected: ReadonlyMap
+}
+
+const EMPTY_DIRECTORY: LocalTaskHostDirectory = { labels: new Map(), connected: new Map() }
+
/**
- * Names for the SSH hosts and servers a task's workspaces run on, read once from the desktop while
- * `client` is set and only when such a host is in play. A local host needs no lookup.
+ * Names for the SSH hosts and servers a task's workspaces run on, and whether the paired host says
+ * each SSH host is connected, read once from the desktop while `client` is set and only when such a
+ * host is in play. A local host needs no lookup; nothing here retries or polls.
*/
-export function useLocalTaskHostLabels(
+export function useLocalTaskHostDirectory(
client: RpcClient | null,
hostIds: readonly string[]
-): ReadonlyMap {
- const [labels, setLabels] = useState>(new Map())
+): LocalTaskHostDirectory {
+ const [directory, setDirectory] = useState(EMPTY_DIRECTORY)
const kinds = new Set(hostIds.map((hostId) => parseExecutionHostId(hostId)?.kind))
const wantsSsh = kinds.has('ssh')
const wantsServers = kinds.has('runtime')
@@ -35,17 +47,30 @@ export function useLocalTaskHostLabels(
if (!current) {
return
}
- const next = new Map(
+ const labels = new Map(
buildHostLabelById({ sshTargets, hostSettingOverrides: undefined })
)
for (const server of servers ?? []) {
- next.set(server.hostId, server.label)
+ labels.set(server.hostId, server.label)
+ }
+ const connected = new Map()
+ for (const target of sshTargets) {
+ if (typeof target.connected === 'boolean') {
+ connected.set(sshTargetSummaryHostId(target.id), target.connected)
+ }
}
- setLabels(next)
+ setDirectory({ labels, connected })
})
return () => {
current = false
}
}, [client, wantsSsh, wantsServers])
- return labels
+ return directory
+}
+
+export function useLocalTaskHostLabels(
+ client: RpcClient | null,
+ hostIds: readonly string[]
+): ReadonlyMap {
+ return useLocalTaskHostDirectory(client, hostIds).labels
}
diff --git a/mobile/src/local-tasks/use-local-task-launch.ts b/mobile/src/local-tasks/use-local-task-launch.ts
index bed1ca051390..dff7d553315b 100644
--- a/mobile/src/local-tasks/use-local-task-launch.ts
+++ b/mobile/src/local-tasks/use-local-task-launch.ts
@@ -30,12 +30,20 @@ export type LocalTaskLaunchRequest =
name: string
setups: LocalTaskSetup[]
setup: LocalTaskSetup
+ /** Start work again: the task's workspace was deleted, so the new link replaces its link. */
+ replace?: boolean
}
| { mode: 'session'; sessionTask: LocalTask; workspace: LocalTaskWorkspaceLink }
+/**
+ * Why a list could not be offered, kept apart from "no agent": the host is out of contact, its
+ * contact could not be confirmed, or it answered and the read failed.
+ */
+export type LocalTaskAgentsFailure = 'offline' | 'unverifiable' | 'read'
+
export type LocalTaskAgentChoices =
| { status: 'loading' }
- | { status: 'failed' }
+ | { status: 'failed'; reason: LocalTaskAgentsFailure }
| { status: 'ready'; agents: MobileNewTabAgentOption[] }
/** The host a request runs on, as the library's owner names it. */
@@ -50,13 +58,12 @@ function requestHostId(request: LocalTaskLaunchRequest): string {
export function useLocalTaskLaunch(args: {
site: LocalTaskSite
api: LocalTasksApi
- library: Pick
+ library: Pick
copy: LocalTasksCopy
onNavigate: (href: string) => void
}) {
const [request, setRequest] = useState(null)
const [agents, setAgents] = useState({ status: 'loading' })
- const [delivery, setDelivery] = useState<'submit' | 'draft'>('submit')
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const [ids] = useState(() => ({
@@ -87,7 +94,10 @@ export function useLocalTaskLaunch(args: {
return
}
if (contact.kind !== 'in-contact') {
- setAgents({ status: 'failed' })
+ setAgents({
+ status: 'failed',
+ reason: contact.kind === 'out-of-contact' ? 'offline' : 'unverifiable'
+ })
return
}
void loadLocalTaskAgentOptions({
@@ -97,7 +107,7 @@ export function useLocalTaskLaunch(args: {
target: routeId ? { kind: 'existing', routeId } : { kind: 'create', hostId }
}).then(
(options) => current && setAgents({ status: 'ready', agents: options }),
- () => current && setAgents({ status: 'failed' })
+ () => current && setAgents({ status: 'failed', reason: 'read' })
)
})
return () => {
@@ -127,9 +137,10 @@ export function useLocalTaskLaunch(args: {
const prompt = {
text: buildLocalTaskAgentPrompt(
active.sessionTask,
- localTaskAncestors(active.sessionTask, tasksById)
+ localTaskAncestors(active.sessionTask, tasksById),
+ { brief: args.library.status?.taskBrief ?? true }
),
- delivery
+ delivery: args.library.status?.sessionSend ?? 'submit'
}
setBusy(true)
try {
@@ -149,7 +160,8 @@ export function useLocalTaskLaunch(args: {
},
name: active.name,
agent,
- prompt
+ prompt,
+ ...(active.replace ? { replace: true } : {})
})
} else {
if (agent === null) {
@@ -168,10 +180,15 @@ export function useLocalTaskLaunch(args: {
}
setNotice(localTaskLaunchMessage(args.copy, outcome))
args.library.reload(['links'])
+ // Why stay when the text was lost: leaving for the terminal would hide the notice that says so.
+ const textLost =
+ (outcome.kind === 'done' || outcome.kind === 'session-not-recorded') &&
+ outcome.promptNotDelivered === true
if (
- outcome.kind === 'done' ||
- outcome.kind === 'session-not-recorded' ||
- outcome.kind === 'workspace-created'
+ !textLost &&
+ (outcome.kind === 'done' ||
+ outcome.kind === 'session-not-recorded' ||
+ outcome.kind === 'workspace-created')
) {
const href = localTaskWorkspaceHref({
hostId: site.hostId,
@@ -196,7 +213,6 @@ export function useLocalTaskLaunch(args: {
return {
request,
agents,
- delivery,
busy,
notice,
open: async (next: LocalTaskLaunchRequest, hostLabel: string) => {
@@ -218,7 +234,6 @@ export function useLocalTaskLaunch(args: {
}
setRequest((previous) => (previous?.mode === 'start' ? { ...previous, setup } : previous))
},
- toggleDelivery: () => setDelivery((value) => (value === 'submit' ? 'draft' : 'submit')),
launch
}
}
diff --git a/mobile/src/local-tasks/use-local-task-session-acknowledge.test.tsx b/mobile/src/local-tasks/use-local-task-session-acknowledge.test.tsx
index d59fb19e0520..55107c31b368 100644
--- a/mobile/src/local-tasks/use-local-task-session-acknowledge.test.tsx
+++ b/mobile/src/local-tasks/use-local-task-session-acknowledge.test.tsx
@@ -84,7 +84,7 @@ const client: RpcClient = {
close: () => {}
}
-function Probe(props: { taskSessionId: string; progress: LocalTaskProgressSnapshot }) {
+function Probe(props: { taskSessionId: string | null; progress: LocalTaskProgressSnapshot }) {
useAcknowledgeOpenSession(client, props.taskSessionId, props.progress)
return null
}
@@ -101,7 +101,7 @@ afterEach(() => {
vi.useRealTimers()
})
-async function show(progress: LocalTaskProgressSnapshot, taskSessionId = 's1') {
+async function show(progress: LocalTaskProgressSnapshot, taskSessionId: string | null = 's1') {
const element = createElement(Probe, { taskSessionId, progress })
await act(async () => {
if (renderer) {
@@ -113,6 +113,17 @@ async function show(progress: LocalTaskProgressSnapshot, taskSessionId = 's1') {
}
describe('opening a linked session marks its "Needs you" items seen', () => {
+ it('waits for a drawer chat to become visible and acknowledges it once', async () => {
+ vi.setSystemTime(1_000)
+ const progress = snapshot([item('a', 500)])
+ await show(progress, null)
+ expect(acknowledged).toEqual([])
+
+ await show(progress, 's1')
+ await show(progress, 's1')
+ expect(acknowledged).toEqual(['a'])
+ })
+
it('marks nothing while a screen pushed over the session keeps it mounted', async () => {
vi.setSystemTime(1_000)
screen.focused = false
diff --git a/mobile/src/local-tasks/use-local-task-session-acknowledge.ts b/mobile/src/local-tasks/use-local-task-session-acknowledge.ts
index 6a3ee957c24f..a431d1f8cc59 100644
--- a/mobile/src/local-tasks/use-local-task-session-acknowledge.ts
+++ b/mobile/src/local-tasks/use-local-task-session-acknowledge.ts
@@ -4,7 +4,7 @@ import { useFocusEffect } from 'expo-router'
import { localTaskAttentionClearsOnView } from '../../../src/shared/local-tasks/local-task-attention-rules'
import type { LocalTaskProgressSnapshot } from '../../../src/shared/local-tasks/local-task-link-types'
import type { RpcClient } from '../transport/rpc-client'
-import { createLocalTasksApi } from './local-tasks-api'
+import { createLocalTasksApi, type LocalTasksApi } from './local-tasks-api'
import { createLocalTaskOperationIds, sendLocalTaskWrite } from './local-task-operation-ids'
import { localTaskItemsSeenInSession } from './local-task-progress-projection'
@@ -60,7 +60,8 @@ function useSessionVisibleSince(taskSessionId: string | null): number | null {
export function useAcknowledgeOpenSession(
client: RpcClient | null,
taskSessionId: string | null,
- progress: LocalTaskProgressSnapshot | null
+ progress: LocalTaskProgressSnapshot | null,
+ apiOverride?: LocalTasksApi
): void {
const visibleSince = useSessionVisibleSince(taskSessionId)
const acknowledged = useRef(new Set())
@@ -68,7 +69,7 @@ export function useAcknowledgeOpenSession(
if (!client || !taskSessionId || visibleSince === null) {
return
}
- const api = createLocalTasksApi(client)
+ const api = apiOverride ?? createLocalTasksApi(client)
for (const item of localTaskItemsSeenInSession(progress, taskSessionId, visibleSince)) {
if (localTaskAttentionClearsOnView(item) && !acknowledged.current.has(item.key)) {
acknowledged.current.add(item.key)
@@ -79,5 +80,5 @@ export function useAcknowledgeOpenSession(
)
}
}
- }, [client, taskSessionId, progress, visibleSince])
+ }, [client, taskSessionId, progress, visibleSince, apiOverride])
}
diff --git a/mobile/src/local-tasks/use-local-task-subtask-batch.ts b/mobile/src/local-tasks/use-local-task-subtask-batch.ts
index c9f818dbef39..a219c5559439 100644
--- a/mobile/src/local-tasks/use-local-task-subtask-batch.ts
+++ b/mobile/src/local-tasks/use-local-task-subtask-batch.ts
@@ -3,11 +3,7 @@ import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types
import type { LocalTasksApi, LocalTaskWriteOutcome } from './local-tasks-api'
import type { LocalTasksCopy } from './local-tasks-copy'
import { localTaskWriteMessage } from './local-task-failure-message'
-import {
- localTaskDraftTexts,
- parseLocalTaskLabels,
- type LocalTaskDraft
-} from './local-task-form-state'
+import { parseLocalTaskLabels, type LocalTaskDraft } from './local-task-form-state'
import { createLocalTaskOperationIds, sendLocalTaskWrite } from './local-task-operation-ids'
import type { LocalTasksLibrary } from './use-local-tasks-library'
@@ -21,8 +17,7 @@ export function useLocalTaskSubtaskBatch(
) {
const [ids] = useState(() => ({
subtasks: createLocalTaskOperationIds(),
- batch: createLocalTaskOperationIds(),
- criteria: createLocalTaskOperationIds()
+ batch: createLocalTaskOperationIds()
}))
const [partialTaskId, setPartialTaskId] = useState(null)
const [subtaskRetryPending, setSubtaskRetryPending] = useState(false)
@@ -44,10 +39,6 @@ export function useLocalTaskSubtaskBatch(
removeLabels?: string[]
}
} | null
- criteriaKey: string | null
- criteriaDone: number
- criteriaClearing: boolean
- criteriaUnsettled: { taskId: string; criteria: string[] } | null
} | null>(null)
const fail = (outcome: LocalTaskWriteOutcome) => {
setError(localTaskWriteMessage(copy, outcome))
@@ -84,11 +75,7 @@ export function useLocalTaskSubtaskBatch(
batchKey: null,
batchLabels: [],
batchPriorityWritten: false,
- batchUnsettled: null,
- criteriaKey: null,
- criteriaDone: 0,
- criteriaClearing: false,
- criteriaUnsettled: null
+ batchUnsettled: null
}
setPartialTaskId(parent.id)
}
@@ -145,52 +132,6 @@ export function useLocalTaskSubtaskBatch(
pending.batchKey = batchKey
pending.batchLabels = labels
pending.batchPriorityWritten = batchPatch.priority !== undefined
- if (pending.criteriaUnsettled) {
- const unsettled = pending.criteriaUnsettled
- const settled = await sendLocalTaskWrite(
- ids.criteria,
- { taskId: unsettled.taskId, patch: { criteria: unsettled.criteria } },
- api.updateTask
- )
- if (settled.kind !== 'ok') {
- if (settled.kind !== 'unknown') {
- pending.criteriaUnsettled = null
- }
- setError(`${copy.subtasksPartiallySaved} ${localTaskWriteMessage(copy, settled) ?? ''}`)
- return false
- }
- library.acceptTasks([settled.value.task])
- pending.criteriaDone += 1
- pending.criteriaUnsettled = null
- }
- const criteria = localTaskDraftTexts(draft.criteria)
- const criteriaKey = JSON.stringify(criteria)
- if (pending.criteriaKey !== criteriaKey) {
- pending.criteriaClearing =
- criteria.length === 0 && pending.criteriaKey !== null && pending.criteriaKey !== '[]'
- pending.criteriaDone = 0
- pending.criteriaKey = criteriaKey
- }
- while (
- pending.criteriaDone < pending.tasks.length &&
- (criteria.length > 0 || pending.criteriaClearing)
- ) {
- const task = pending.tasks[pending.criteriaDone]
- const updated = await sendLocalTaskWrite(
- ids.criteria,
- { taskId: task.id, patch: { criteria } },
- api.updateTask
- )
- if (updated.kind !== 'ok') {
- if (updated.kind === 'unknown') {
- pending.criteriaUnsettled = { taskId: task.id, criteria }
- }
- setError(`${copy.subtasksPartiallySaved} ${localTaskWriteMessage(copy, updated) ?? ''}`)
- return false
- }
- library.acceptTasks([updated.value.task])
- pending.criteriaDone += 1
- }
pendingSubtasks.current = null
subtaskIntent.current = null
setSubtaskRetryPending(false)
diff --git a/mobile/src/local-tasks/use-local-task-workspace-listing.ts b/mobile/src/local-tasks/use-local-task-workspace-listing.ts
new file mode 100644
index 000000000000..52ada5b7b20d
--- /dev/null
+++ b/mobile/src/local-tasks/use-local-task-workspace-listing.ts
@@ -0,0 +1,59 @@
+import { useEffect, useState } from 'react'
+import type { RpcClient } from '../transport/rpc-client'
+import { worktreeCatalogRead } from '../worktree/worktree-catalog-operations'
+import { WORKTREE_PS_FULL_LIMIT } from '../worktree/worktree-catalog-snapshot-client'
+import {
+ readLocalTaskWorkspaceListing,
+ type LocalTaskWorkspaceListing
+} from './local-task-workspace-row'
+
+export type LocalTaskWorkspaceListingState =
+ | { status: 'loading' }
+ /** The read failed or the host answered something this build cannot read. */
+ | { status: 'unread' }
+ | { status: 'ready'; listing: LocalTaskWorkspaceListing }
+
+/**
+ * The library host's workspace list, read when the task's card shows a linked workspace (`subject`
+ * names it) and the host is in contact (`open`). A new link, or contact coming back, is what reads
+ * again; nothing retries or polls. Shared in flight under `key` with the link pickers and the host
+ * screen.
+ */
+export function useLocalTaskWorkspaceListing(args: {
+ client: RpcClient | null
+ key: string
+ subject: string | null
+ open: boolean
+}): LocalTaskWorkspaceListingState {
+ const { client, key, subject, open } = args
+ const [read, setRead] = useState<{
+ client: RpcClient
+ subject: string
+ state: LocalTaskWorkspaceListingState
+ } | null>(null)
+ useEffect(() => {
+ if (!open || !client || subject === null) {
+ return
+ }
+ let current = true
+ const settle = (state: LocalTaskWorkspaceListingState) => {
+ if (current) {
+ setRead({ client, subject, state })
+ }
+ }
+ worktreeCatalogRead
+ .requestSingleFlight(client, key, { limit: WORKTREE_PS_FULL_LIMIT })
+ .then((reply) => {
+ const catalog = worktreeCatalogRead.interpret(reply)
+ const listing = catalog.accepted ? readLocalTaskWorkspaceListing(catalog.value) : null
+ settle(listing ? { status: 'ready', listing } : { status: 'unread' })
+ })
+ .catch(() => settle({ status: 'unread' }))
+ return () => {
+ current = false
+ }
+ }, [client, key, subject, open])
+ return open && read?.client === client && read.subject === subject
+ ? read.state
+ : { status: 'loading' }
+}
diff --git a/mobile/src/local-tasks/use-local-tasks-library-model.ts b/mobile/src/local-tasks/use-local-tasks-library-model.ts
new file mode 100644
index 000000000000..63e118c18358
--- /dev/null
+++ b/mobile/src/local-tasks/use-local-tasks-library-model.ts
@@ -0,0 +1,134 @@
+import { useMemo } from 'react'
+import { buildLocalTaskIndex } from '../../../src/shared/local-tasks/local-task-grouping'
+import { localTaskDescendantIds } from '../../../src/shared/local-tasks/local-task-tree'
+import type { LocalTask } from '../../../src/shared/local-tasks/local-task-types'
+import { localTaskCardFacts } from './local-task-card-facts'
+import type { LocalTaskSite } from './local-task-endpoint'
+import type { LocalTaskFilter } from './local-task-filter'
+import { localTaskLatestLine } from './local-task-latest-line'
+import {
+ buildLocalTaskGroups,
+ localTaskSegmentCounts,
+ localTaskStatusLookup,
+ type LocalTaskSegment
+} from './local-task-list-projection'
+import {
+ localTaskAttentionRows,
+ localTaskIdsNeedingYou,
+ localTaskNeedsYou,
+ localTaskRowSessionStates,
+ localTaskSessionViews
+} from './local-task-progress-projection'
+import { useLocalTaskCardContext } from './use-local-task-card-context'
+import type { LocalTasksLibrary } from './use-local-tasks-library'
+
+/**
+ * What the library page derives from the loaded library: segment counts, "Needs you", the status
+ * groups, and what each card and line says. Read only from what is loaded, except the host names
+ * and the PR states the card context reads once.
+ */
+export function useLocalTasksLibraryModel(args: {
+ site: LocalTaskSite
+ library: LocalTasksLibrary
+ projectId: string | null
+ segment: 'attention' | LocalTaskSegment
+ view: 'list' | 'board'
+ filter: LocalTaskFilter
+}) {
+ const { site, library, projectId, segment, view, filter } = args
+ const lookup = useMemo(() => localTaskStatusLookup(library.projects), [library.projects])
+ const counts = localTaskSegmentCounts(library.tasks, library.projects, projectId)
+ const inContact = site.connState === 'connected'
+ // Scoped like the desktop view and the other segments (REQ-006); home's global card opens on All.
+ const attentionRows = localTaskAttentionRows({
+ snapshot: library.work.progress,
+ tasks: library.tasks,
+ projects: library.projects,
+ projectId
+ })
+ const groups = buildLocalTaskGroups({
+ tasks: library.tasks,
+ projects: library.projects,
+ projectId,
+ segment: segment === 'attention' ? 'all' : segment,
+ filter,
+ // Out of contact nothing is confirmed as waiting, so the filter shows none.
+ needingYou: inContact ? localTaskIdsNeedingYou(attentionRows) : undefined,
+ nestSubtasks: view === 'list',
+ keepEmpty: view === 'board'
+ })
+ // The All list leads with "Needs you" for the tasks it shows (the desktop's band).
+ const listBand =
+ segment === 'all' && view === 'list'
+ ? localTaskNeedsYou({
+ snapshot: library.work.progress,
+ tasks: library.tasks,
+ projects: library.projects,
+ projectId,
+ visibleIds: new Set(
+ groups.flatMap((group) =>
+ group.rows.flatMap((row) => [
+ row.task.id,
+ ...row.subtasks.map((line) => line.task.id)
+ ])
+ )
+ )
+ })
+ : null
+ const cards = useLocalTaskCardContext({
+ site,
+ library,
+ reviewTasks: [...attentionRows, ...(listBand?.rows ?? [])]
+ .filter((row) => row.review)
+ .map((row) => row.task)
+ })
+ const tasksById = new Map(library.tasks.map((task) => [task.id, task]))
+ const index = buildLocalTaskIndex(library.projects, library.tasks)
+ const sessionsFor = (task: LocalTask) =>
+ localTaskRowSessionStates({
+ task,
+ index,
+ links: library.work.links,
+ snapshot: library.work.progress,
+ inContact
+ })
+ const factsFor = (task: LocalTask) =>
+ localTaskCardFacts({
+ task,
+ index,
+ links: library.work.links,
+ snapshot: library.work.progress,
+ inContact,
+ hostName: cards.context.hostName,
+ hostConnected: cards.context.hostConnected
+ })
+ const latestLineFor = (task: LocalTask) =>
+ localTaskLatestLine(
+ task,
+ localTaskSessionViews({
+ task,
+ tasks: library.tasks,
+ links: library.work.links,
+ snapshot: library.work.progress,
+ inContact
+ }),
+ localTaskDescendantIds(task.id, index.childrenByParentId).flatMap((id) => {
+ const child = index.tasksById.get(id)
+ return child ? [child] : []
+ })
+ )
+ return {
+ lookup,
+ counts,
+ inContact,
+ attentionRows,
+ groups,
+ listBand,
+ cards,
+ tasksById,
+ index,
+ sessionsFor,
+ factsFor,
+ latestLineFor
+ }
+}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts
index a83ded634762..d5d2f083d80d 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts
@@ -43,7 +43,7 @@ describe('bridge client handshake', () => {
{
v: BRIDGE_PROTOCOL_VERSION,
type: 'ready',
- storageKeys: ['orca:terminalSplitLayout'],
+ storageKeys: ['orca:terminalSplitLayout', 'orca:localTasksView'],
connectionPathFrames: true
}
])
diff --git a/mobile/src/mobile-web-shell/page-storage-keys.ts b/mobile/src/mobile-web-shell/page-storage-keys.ts
index 23f50ea46e3c..4434e1dde43a 100644
--- a/mobile/src/mobile-web-shell/page-storage-keys.ts
+++ b/mobile/src/mobile-web-shell/page-storage-keys.ts
@@ -35,7 +35,9 @@ export const PAGE_STORAGE_EXACT_KEYS = [
/** Whether a terminal link opens in Orca's browser or the phone's. */
'orca:terminalLinkOpenMode',
/** The split terminal's chosen direction. */
- 'orca:terminalSplitLayout'
+ 'orca:terminalSplitLayout',
+ /** Fork local-tasks: the project, segment and layout each task library's page was left on. */
+ 'orca:localTasksView'
] as const
/**
@@ -43,7 +45,11 @@ export const PAGE_STORAGE_EXACT_KEYS = [
* reader refines each key against its own list and refuses the whole frame on one it lacks, so
* the host sends these only to a page whose `ready` named them in `storageKeys`.
*/
-export const NEGOTIATED_PAGE_STORAGE_KEYS = ['orca:terminalSplitLayout'] as const
+export const NEGOTIATED_PAGE_STORAGE_KEYS = [
+ 'orca:terminalSplitLayout',
+ /** Fork local-tasks. */
+ 'orca:localTasksView'
+] as const
/** Whether a page that declared `storageKeys` (null: declared nothing) reads this key in `init`. */
export function pageReadsStorageKey(key: string, declared: readonly string[] | null): boolean {
diff --git a/mobile/src/session/use-mobile-session-controller.ts b/mobile/src/session/use-mobile-session-controller.ts
index d62e1a333d5d..5fdb54fdb403 100644
--- a/mobile/src/session/use-mobile-session-controller.ts
+++ b/mobile/src/session/use-mobile-session-controller.ts
@@ -1,7 +1,10 @@
import { useLayoutEffect } from 'react'
import { useMobileStructuredSessionOpening } from './use-mobile-structured-session-opening'
import { useNotificationPaneNavigation } from './use-notification-pane-navigation'
-import { useMobileSessionFoundation } from './use-mobile-session-foundation'
+import {
+ useMobileSessionFoundation,
+ type MobileSessionScopeOverride
+} from './use-mobile-session-foundation'
import { useMobileSessionScreenState } from './use-mobile-session-screen-state'
import { useMobileSessionTerminalRuntime } from './use-mobile-session-terminal-runtime'
import { useMobileSessionFeedbackCapabilities } from './use-mobile-session-feedback-capabilities'
@@ -34,8 +37,8 @@ import { useMobileSessionBulkClose } from './use-mobile-session-bulk-close'
import { useMobileSessionPresentation } from './use-mobile-session-presentation'
import { useMobileSessionPanelRouteActions } from './use-mobile-session-panel-route-actions'
-export function useMobileSessionController() {
- const foundation = useMobileSessionFoundation()
+export function useMobileSessionController(override?: MobileSessionScopeOverride) {
+ const foundation = useMobileSessionFoundation(override)
const screenState = Object.assign(foundation, useMobileSessionScreenState(foundation))
const terminalRuntime = Object.assign(screenState, useMobileSessionTerminalRuntime(screenState))
const feedbackCapabilities = Object.assign(
@@ -97,7 +100,11 @@ export function useMobileSessionController() {
const preferences = useMobileSessionPreferenceFocus(keyboardState)
const tabSwitching = Object.assign(keyboardState, useMobileSessionTabSwitching(keyboardState))
const openNativeSession = useMobileStructuredSessionOpening(tabSwitching)
- useNotificationPaneNavigation({ ...tabSwitching, openNativeSession })
+ useNotificationPaneNavigation({
+ ...tabSwitching,
+ openNativeSession,
+ ...(override ? { paneKeyOverride: override.paneKey } : {})
+ })
const terminalWebview = Object.assign(tabSwitching, useMobileSessionTerminalWebview(tabSwitching))
const terminalSendActions = Object.assign(
terminalWebview,
diff --git a/mobile/src/session/use-mobile-session-foundation.ts b/mobile/src/session/use-mobile-session-foundation.ts
index 9dfaa1c7cf36..a53f374ae138 100644
--- a/mobile/src/session/use-mobile-session-foundation.ts
+++ b/mobile/src/session/use-mobile-session-foundation.ts
@@ -18,20 +18,25 @@ import { useMissingWorktreeBounce } from './use-missing-worktree-bounce'
import { hostRouteWithNotice } from '../host-route-notice'
import { useHostProtocolGates } from '../components/host-protocol-gates-context'
-export function useMobileSessionFoundation() {
- const {
- hostId,
- worktreeId,
- name: routeWorktreeName,
- created,
- warning: createdWarning
- } = useLocalSearchParams<{
+export type MobileSessionScopeOverride = {
+ hostId: string
+ worktreeId: string
+ paneKey: string | null
+}
+
+export function useMobileSessionFoundation(override?: MobileSessionScopeOverride) {
+ const params = useLocalSearchParams<{
hostId: string
worktreeId: string
name?: string
created?: string
warning?: string
}>()
+ const hostId = override?.hostId ?? params.hostId
+ const worktreeId = override?.worktreeId ?? params.worktreeId
+ const routeWorktreeName = override ? undefined : params.name
+ const created = override ? undefined : params.created
+ const createdWarning = override ? undefined : params.warning
const isFolderWorkspaceRoute = worktreeId.startsWith('folder:')
const isFloatingWorkspaceRoute = isFloatingWorkspaceWorktreeId(worktreeId)
const router = useRouteHandoff()
diff --git a/mobile/src/session/use-notification-pane-navigation.test.tsx b/mobile/src/session/use-notification-pane-navigation.test.tsx
index 4d6801adbf7b..2272332237e7 100644
--- a/mobile/src/session/use-notification-pane-navigation.test.tsx
+++ b/mobile/src/session/use-notification-pane-navigation.test.tsx
@@ -96,18 +96,21 @@ const switched: MobileSessionTab[] = []
function Probe({
terminalsLoaded,
sessionTabs = TABS,
- openNativeSession
+ openNativeSession,
+ paneKeyOverride
}: {
terminalsLoaded: boolean
sessionTabs?: MobileSessionTab[]
openNativeSession?: (source: StructuredSessionOpenSource) => Promise
+ paneKeyOverride?: string | null
}): null {
useNotificationPaneNavigation({
sessionTabs,
terminalsLoaded,
switchSessionTab: (tab) => switched.push(tab),
worktreeId: 'wt-1',
- openNativeSession
+ openNativeSession,
+ paneKeyOverride
})
return null
}
@@ -141,6 +144,17 @@ beforeEach(() => {
// Exercise the page hook: shell init carries pane requests because this document at / cannot edit the native route.
describe('the page pane hook', () => {
+ it('uses an explicit drawer pane without consuming or following the page route', () => {
+ bridge.route = { pathname: '/h/host-1/local-tasks', params: { paneKey: `tab-a:${LEAF}` } }
+ pageUrl.params = { paneKey: `tab-a:${LEAF}` }
+ act(() => {
+ create(createElement(Probe, { terminalsLoaded: true, paneKeyOverride: `tab-b:${LEAF}` }))
+ })
+ expect(switched.map((tab) => tab.id)).toEqual(['second'])
+ expect(bridge.cleared).toEqual([])
+ expect(pageUrl.params.paneKey).toBe(`tab-a:${LEAF}`)
+ expect(bridge.listeners).toHaveLength(0)
+ })
it('switches to the pane the page was opened on, which arrives in the first init', () => {
bridge.route = { pathname: '/h/host-1/session/wt-1', params: { paneKey: `tab-b:${LEAF}` } }
render(true)
diff --git a/mobile/src/session/use-notification-pane-navigation.ts b/mobile/src/session/use-notification-pane-navigation.ts
index 3705c6393224..4e827da4aa36 100644
--- a/mobile/src/session/use-notification-pane-navigation.ts
+++ b/mobile/src/session/use-notification-pane-navigation.ts
@@ -16,7 +16,8 @@ export function useNotificationPaneNavigation({
connState,
switchSessionTab,
worktreeId,
- openNativeSession
+ openNativeSession,
+ paneKeyOverride
}: {
sessionTabs: MobileSessionTab[]
terminalsLoaded: boolean
@@ -24,6 +25,7 @@ export function useNotificationPaneNavigation({
switchSessionTab: (tab: MobileSessionTab) => void
worktreeId?: string
openNativeSession?: (source: StructuredSessionOpenSource) => Promise
+ paneKeyOverride?: string | null
}) {
const client = usePageBridgeClient()
const router = useRouter()
@@ -31,18 +33,25 @@ export function useNotificationPaneNavigation({
const urlPaneKey =
firstParam(useLocalSearchParams<{ paneKey?: string | string[] }>().paneKey) || null
// The shell's pane wins: the URL copied it at bootstrap and can be older than a later notification tap.
- const pending = useRef(client.getShellSession()?.route?.params?.paneKey || urlPaneKey)
+ const pending = useRef(
+ paneKeyOverride === undefined
+ ? client.getShellSession()?.route?.params?.paneKey || urlPaneKey
+ : paneKeyOverride
+ )
const applied = useRef(null)
const [asked, setAsked] = useState(0)
useEffect(() => {
// Spend the URL's tap so a remount of this screen cannot replay it over a later tab pick.
- if (urlPaneKey !== null) {
+ if (paneKeyOverride === undefined && urlPaneKey !== null) {
router.setParams({ paneKey: undefined })
}
- }, [router, urlPaneKey])
+ }, [router, urlPaneKey, paneKeyOverride])
useEffect(() => {
+ if (paneKeyOverride !== undefined) {
+ return
+ }
// Acknowledge the initial init separately; read from the client to keep the effect keyed only on client.
const seed = client.getShellSession()?.route?.params?.paneKey ?? null
if (seed !== null && seed !== '') {
@@ -63,7 +72,7 @@ export function useNotificationPaneNavigation({
pending.current = paneKey
setAsked((count) => count + 1)
})
- }, [client])
+ }, [client, paneKeyOverride])
useEffect(() => {
const paneKey = pending.current
diff --git a/mobile/src/tasks/mobile-smart-source-modes.ts b/mobile/src/tasks/mobile-smart-source-modes.ts
index ed1d059f2403..2f29afe36ee3 100644
--- a/mobile/src/tasks/mobile-smart-source-modes.ts
+++ b/mobile/src/tasks/mobile-smart-source-modes.ts
@@ -3,7 +3,7 @@ import { translate } from '../i18n/mobile-i18n'
// Lucide has no brand icons; provider modes use TaskProviderLogo SVGs.
export type SmartModeIcon =
- | { type: 'lucide'; name: 'sparkles' | 'git-branch' | 'case-sensitive' }
+ | { type: 'lucide'; name: 'sparkles' | 'git-branch' | 'case-sensitive' | 'list-todo' }
| { type: 'provider'; provider: 'github' | 'gitlab' | 'linear' | 'jira' }
export type SmartModeOption = {
@@ -20,6 +20,13 @@ export const SMART_MODE_OPTIONS: readonly SmartModeOption[] = [
},
icon: { type: 'lucide', name: 'sparkles' }
},
+ {
+ id: 'local',
+ get label() {
+ return translate('localTasks.sourceTab.label', 'Local')
+ },
+ icon: { type: 'lucide', name: 'list-todo' }
+ },
{ id: 'github', label: 'GitHub', icon: { type: 'provider', provider: 'github' } },
{ id: 'linear', label: 'Linear', icon: { type: 'provider', provider: 'linear' } },
{ id: 'jira', label: 'Jira', icon: { type: 'provider', provider: 'jira' } },
@@ -48,6 +55,7 @@ export type SmartModeAvailabilityInput = {
gitlabAvailable: boolean
linearAvailable: boolean
jiraAvailable: boolean
+ localTaskSourceOffered?: boolean
}
export function resolveAvailableSmartModes(input: SmartModeAvailabilityInput): SmartNameMode[] {
@@ -58,6 +66,8 @@ export function resolveAvailableSmartModes(input: SmartModeAvailabilityInput): S
switch (option.id) {
case 'smart':
return input.tasksSupported
+ case 'local':
+ return input.localTaskSourceOffered === true
case 'github':
return input.tasksSupported && input.hasRepo && input.githubAvailable
case 'gitlab':
diff --git a/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts b/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts
index 53a176735483..1043f15c6d4a 100644
--- a/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts
+++ b/mobile/src/test-support/rpc-recording/adapters/workspace-settings-mounts.ts
@@ -88,6 +88,7 @@ export function workspaceSettingsMounts(
detectedAgentIds: new Set(['codex']),
sshGate: { requiresConnection: false },
composer: { name: 'recorded', createSelection: null, isNameAutoManaged: false },
+ localTaskCreate: null,
note: '',
retiredWorktreeNames: {},
setupCommand: null,
diff --git a/src/cli/local-tasks/local-task-cli-format.ts b/src/cli/local-tasks/local-task-cli-format.ts
index ea3ec3bcbc6b..3213004c2bf5 100644
--- a/src/cli/local-tasks/local-task-cli-format.ts
+++ b/src/cli/local-tasks/local-task-cli-format.ts
@@ -1,5 +1,8 @@
import type { LocalTaskPriority } from '../../shared/local-tasks/local-task-model'
-import type { LocalTaskWorkspaceLink } from '../../shared/local-tasks/local-task-link-types'
+import type {
+ LocalTaskSessionLink,
+ LocalTaskWorkspaceLink
+} from '../../shared/local-tasks/local-task-link-types'
import type { ResolvedTask } from './local-task-cli-library'
/** One task as `orca task` prints it: the title leads, the ref is a detail (REQ-007). */
@@ -14,15 +17,31 @@ export type TaskCliSummary = {
labels: string[]
}
+/** A row of `orca task list`: enough for the dispatcher to see who owns which workspace. */
+export type TaskCliListItem = TaskCliSummary & {
+ workspace: { hostId: string; name: string | null; worktreeId: string | null } | null
+ latestProgress: { at: string; body: string } | null
+}
+
export type TaskCliDetail = TaskCliSummary & {
description: string
- criteria: string[]
workspace: {
hostId: string
kind: LocalTaskWorkspaceLink['kind']
name: string | null
branch: string | null
+ /** What a proposal's `target.worktreeId` names. */
+ worktreeId: string | null
} | null
+ /** The agent's newest writeback (REQ-022). */
+ latestProgress: { at: string; body: string; needsYou: string | null } | null
+ /** Sessions on the task; a proposal's append or stop names one by `sessionLinkId`. */
+ sessions: { sessionLinkId: string; agent: string | null; surface: string; openedAt: string }[]
+}
+
+/** UTC with the date, so an agent never guesses the zone. */
+export function cliTime(at: number): string {
+ return `${new Date(at).toISOString().slice(0, 16).replace('T', ' ')}Z`
}
const PRIORITY_LABELS: Record = {
@@ -48,20 +67,33 @@ export function summarizeTask({ task, project, status }: ResolvedTask): TaskCliS
export function detailTask(
resolved: ResolvedTask,
- link: LocalTaskWorkspaceLink | null
+ link: LocalTaskWorkspaceLink | null,
+ sessions: readonly LocalTaskSessionLink[] = []
): TaskCliDetail {
+ const progress = resolved.task.latestProgress
return {
...summarizeTask(resolved),
description: resolved.task.description,
- criteria: resolved.task.criteria,
workspace: link
? {
hostId: link.hostId,
kind: link.kind,
name: link.nameSnapshot,
- branch: link.branchSnapshot
+ branch: link.branchSnapshot,
+ worktreeId: link.worktreeId
}
- : null
+ : null,
+ latestProgress: progress
+ ? { at: cliTime(progress.at), body: progress.body, needsYou: progress.needsYou ?? null }
+ : null,
+ sessions: sessions
+ .filter((session) => session.taskId === resolved.task.id)
+ .map((session) => ({
+ sessionLinkId: session.id,
+ agent: session.agent,
+ surface: session.surface,
+ openedAt: cliTime(session.openedAt)
+ }))
}
}
@@ -69,9 +101,41 @@ function contextLine(task: TaskCliSummary): string {
return [task.ref, task.status, task.project].filter(Boolean).join(' · ')
}
-export function formatTaskList(result: { tasks: TaskCliSummary[] }): string {
+/** The progress line is cut to its first line; `orca task show` has the whole writeback. */
+const LIST_PROGRESS_MAX = 160
+
+export function listTask(
+ resolved: ResolvedTask,
+ link: LocalTaskWorkspaceLink | null
+): TaskCliListItem {
+ const progress = resolved.task.latestProgress
+ const firstLine =
+ progress?.body
+ .split('\n')
+ .find((line) => line.trim())
+ ?.trim() ?? ''
+ return {
+ ...summarizeTask(resolved),
+ workspace: link
+ ? { hostId: link.hostId, name: link.nameSnapshot, worktreeId: link.worktreeId }
+ : null,
+ latestProgress: progress
+ ? { at: cliTime(progress.at), body: firstLine.slice(0, LIST_PROGRESS_MAX) }
+ : null
+ }
+}
+
+export function formatTaskList(result: { tasks: TaskCliListItem[] }): string {
return result.tasks.length
- ? result.tasks.map((task) => `${task.title}\t${contextLine(task)}`).join('\n')
+ ? result.tasks
+ .map((task) => {
+ const where = task.workspace?.name ? ` · ${task.workspace.name}` : ''
+ const latest = task.latestProgress
+ ? `\n latest (${task.latestProgress.at}): ${task.latestProgress.body}`
+ : ''
+ return `${task.title}\t${contextLine(task)}${where}${latest}`
+ })
+ .join('\n')
: 'No tasks.'
}
@@ -87,8 +151,21 @@ export function formatTaskDetail(task: TaskCliDetail): string {
if (task.description.trim()) {
lines.push('', task.description.trim())
}
- if (task.criteria.length > 0) {
- lines.push('', 'Done criteria:', ...task.criteria.map((item) => `- ${item}`))
+ if (task.latestProgress) {
+ lines.push('', `Latest progress (${task.latestProgress.at}):`, task.latestProgress.body)
+ if (task.latestProgress.needsYou) {
+ lines.push(`Needs the user: ${task.latestProgress.needsYou}`)
+ }
+ }
+ if (task.sessions.length > 0) {
+ lines.push(
+ '',
+ 'Sessions (sessionLinkId · agent · opened):',
+ ...task.sessions.map(
+ (session) =>
+ `- ${session.sessionLinkId} · ${session.agent ?? session.surface} · ${session.openedAt}`
+ )
+ )
}
return lines.join('\n')
}
@@ -98,12 +175,12 @@ export function formatTaskShow(result: { task: TaskCliDetail }): string {
}
export function formatTaskCurrent(result: {
- worktree: { id: string; name: string | null }
+ worktree: { id: string; name: string | null } | null
task: TaskCliDetail | null
}): string {
return result.task
? formatTaskDetail(result.task)
- : `${result.worktree.name ?? result.worktree.id} is not linked to a task.`
+ : `${result.worktree?.name ?? result.worktree?.id ?? 'This session'} is not linked to a task.`
}
export function formatTaskChange(result: { task: TaskCliSummary; change: string }): string {
diff --git a/src/cli/local-tasks/local-task-cli-handlers.ts b/src/cli/local-tasks/local-task-cli-handlers.ts
index 48d37c534929..2d70fe56b532 100644
--- a/src/cli/local-tasks/local-task-cli-handlers.ts
+++ b/src/cli/local-tasks/local-task-cli-handlers.ts
@@ -1,14 +1,22 @@
-import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { findLocalTaskLinkForWorkspace } from '../../shared/local-tasks/local-task-workspace-match'
+import { findLocalTaskSessionForIdentity } from '../../shared/local-tasks/local-task-session-match'
import type { LocalTask } from '../../shared/local-tasks/local-task-types'
-import type { RuntimeWorktreeRecord } from '../../shared/runtime-types'
import type { CommandHandler, HandlerContext } from '../dispatch'
+import { LOCAL_TASK_COMMENT_MAX } from '../../shared/local-tasks/local-task-model'
+import {
+ LOCAL_TASK_DISPATCHER_GUIDE,
+ LOCAL_TASK_WORKER_GUIDE
+} from '../../shared/local-tasks/local-task-brief'
+import { LOCAL_TASK_NEEDS_YOU_MAX } from '../../shared/local-tasks/local-task-progress'
+import { readCommandBody } from '../command-body-input'
+import { LOCAL_TASK_DISPATCHER_CLI_HANDLERS } from './local-task-dispatcher-cli'
import { getOptionalStringFlag, getRepeatedStringFlag, getRequiredStringFlag } from '../flags'
import { printResult } from '../format'
import { RuntimeClientError } from '../runtime/types'
-import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors'
import {
addTaskComment,
+ COMPLETED_FROM_CLI_MESSAGE,
+ currentSessionLinkId,
describeTask,
readTaskLibrary,
resolveTaskRef,
@@ -16,12 +24,15 @@ import {
setTaskStatus,
type TaskLibrary
} from './local-task-cli-library'
+import { LOCAL_TASK_CLI_WRITE_HANDLERS } from './local-task-cli-write-handlers'
+import { currentWorkspace } from './local-task-cli-workspace'
import {
detailTask,
formatTaskChange,
formatTaskCurrent,
formatTaskList,
formatTaskShow,
+ listTask,
summarizeTask
} from './local-task-cli-format'
@@ -68,25 +79,10 @@ function statusFilter(library: TaskLibrary, wanted: string): (task: LocalTask) =
return (task) => ids.has(task.statusId)
}
-async function currentWorkspace(context: HandlerContext): Promise {
- const { flags, client, cwd } = context
- const explicit = await getOptionalWorktreeSelector(flags, 'worktree', cwd, client)
- if (!explicit && client.isRemote) {
- throw new RuntimeClientError(
- 'invalid_argument',
- 'orca task current reads this directory’s workspace; on a paired host pass --worktree .'
- )
- }
- const selector = explicit ?? (await resolveCurrentWorktreeSelector(cwd, client))
- const { result } = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
- worktree: selector
- })
- return result.worktree
-}
-
export const LOCAL_TASK_CLI_HANDLERS: Record = {
+ ...LOCAL_TASK_CLI_WRITE_HANDLERS,
'task list': async ({ client, flags, json }) => {
- const library = await readTaskLibrary(client)
+ const library = await readTaskLibrary(client, { links: true })
const project = getOptionalStringFlag(flags, 'project')
const status = getOptionalStringFlag(flags, 'status')
const filters = [
@@ -95,7 +91,12 @@ export const LOCAL_TASK_CLI_HANDLERS: Record = {
]
const tasks = library.tasks
.filter((task) => filters.every((matches) => matches(task)))
- .map((task) => summarizeTask(describeTask(library, task)))
+ .map((task) =>
+ listTask(
+ describeTask(library, task),
+ library.links.workspaces.find((link) => link.taskId === task.id) ?? null
+ )
+ )
printResult({ ...library.envelope, result: { tasks } }, json, formatTaskList)
},
'task show': async ({ client, flags, json }) => {
@@ -103,16 +104,38 @@ export const LOCAL_TASK_CLI_HANDLERS: Record = {
const resolved = resolveTaskRef(library, getRequiredStringFlag(flags, 'ref'))
const link = library.links.workspaces.find((candidate) => candidate.taskId === resolved.task.id)
printResult(
- { ...library.envelope, result: { task: detailTask(resolved, link ?? null) } },
+ {
+ ...library.envelope,
+ result: { task: detailTask(resolved, link ?? null, library.links.sessions) }
+ },
json,
formatTaskShow
)
},
'task current': async (context) => {
- const [worktree, library] = await Promise.all([
- currentWorkspace(context),
- readTaskLibrary(context.client, { links: true })
- ])
+ const library = await readTaskLibrary(context.client, { links: true })
+ const session = context.flags.has('worktree')
+ ? null
+ : findLocalTaskSessionForIdentity(library.links, {
+ paneKey: process.env.ORCA_PANE_KEY,
+ sessionId: process.env.ORCA_AGENT_SESSION_ID,
+ tabId: process.env.ORCA_TAB_ID
+ })
+ if (session) {
+ const task = library.tasks.find((candidate) => candidate.id === session.taskId)
+ if (task) {
+ printResult(
+ {
+ ...library.envelope,
+ result: { worktree: null, task: detailTask(describeTask(library, task), null) }
+ },
+ context.json,
+ formatTaskCurrent
+ )
+ return
+ }
+ }
+ const worktree = await currentWorkspace(context)
// Why the runtime's own host id: links and its catalog both spell hosts relative to that Orca.
const link = findLocalTaskLinkForWorkspace(library.links, {
id: worktree.id,
@@ -125,7 +148,10 @@ export const LOCAL_TASK_CLI_HANDLERS: Record = {
...library.envelope,
result: {
worktree: { id: worktree.id, name: worktree.displayName || null },
- task: task && link ? detailTask(describeTask(library, task), link) : null
+ task:
+ task && link
+ ? detailTask(describeTask(library, task), link, library.links.sessions)
+ : null
}
},
context.json,
@@ -148,6 +174,9 @@ export const LOCAL_TASK_CLI_HANDLERS: Record = {
const library = await readTaskLibrary(client, { access: 'write' })
const resolved = resolveTaskRef(library, getRequiredStringFlag(flags, 'ref'))
const status = resolveTaskStatus(resolved, name)
+ if (status.category === 'completed' && status.id !== resolved.task.statusId) {
+ throw new RuntimeClientError('invalid_argument', COMPLETED_FROM_CLI_MESSAGE)
+ }
if (status.id === resolved.task.statusId) {
printResult(
{ ...library.envelope, result: { task: summarizeTask(resolved), change: 'unchanged' } },
@@ -165,5 +194,42 @@ export const LOCAL_TASK_CLI_HANDLERS: Record = {
json,
formatTaskChange
)
- }
+ },
+ 'task progress': async ({ client, flags, json, cwd }) => {
+ const body = (
+ await readCommandBody(flags, cwd, {
+ required: true,
+ maximumCharacters: LOCAL_TASK_COMMENT_MAX,
+ tooLargeCode: 'invalid_argument',
+ label: 'Progress'
+ })
+ ).trim()
+ if (!body) {
+ throw new RuntimeClientError('invalid_argument', 'The progress text is empty.')
+ }
+ const needsYou = getOptionalStringFlag(flags, 'needs-you')?.trim() || undefined
+ if (needsYou && needsYou.length > LOCAL_TASK_NEEDS_YOU_MAX) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ `--needs-you must be at most ${LOCAL_TASK_NEEDS_YOU_MAX} characters.`
+ )
+ }
+ const library = await readTaskLibrary(client, { access: 'write', links: true })
+ const resolved = resolveTaskRef(library, getRequiredStringFlag(flags, 'ref'))
+ const sessionLinkId = currentSessionLinkId(library, resolved.task.id)
+ const envelope = await addTaskComment(client, resolved, body, {
+ ...(sessionLinkId ? { sessionLinkId } : {}),
+ ...(needsYou ? { needsYou } : {})
+ })
+ printResult(
+ { ...envelope, result: { task: summarizeTask(resolved), change: 'progress' } },
+ json,
+ formatTaskChange
+ )
+ },
+ 'task guide': async ({ flags }) => {
+ console.log(flags.has('dispatcher') ? LOCAL_TASK_DISPATCHER_GUIDE : LOCAL_TASK_WORKER_GUIDE)
+ },
+ ...LOCAL_TASK_DISPATCHER_CLI_HANDLERS
}
+import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
diff --git a/src/cli/local-tasks/local-task-cli-library.ts b/src/cli/local-tasks/local-task-cli-library.ts
index 8a3bc819fa14..d00d9a2dcb9c 100644
--- a/src/cli/local-tasks/local-task-cli-library.ts
+++ b/src/cli/local-tasks/local-task-cli-library.ts
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import type { z } from 'zod'
import { isExactLocalTaskRef } from '../../shared/local-tasks/local-task-grouping'
+import { findLocalTaskSessionForIdentity } from '../../shared/local-tasks/local-task-session-match'
import { localTaskLinksReplySchema } from '../../shared/local-tasks/local-task-link-read-schemas'
import type { LocalTaskLinks } from '../../shared/local-tasks/local-task-link-types'
import {
@@ -47,7 +48,7 @@ function isMethodNotFound(error: unknown): boolean {
return error instanceof RuntimeClientError && error.code === 'method_not_found'
}
-async function callLibrary(
+export async function callLibrary(
rpc: TaskLibraryRpc,
method: string,
params: unknown,
@@ -183,10 +184,22 @@ const REFUSAL_MESSAGES: Record = {
not_found: 'The task no longer exists.',
stale: 'The task changed while this command ran. Run it again.',
read_only: 'The task library is read-only on this host.',
- status_invalid: 'That status does not belong to the task’s project.'
+ status_invalid: 'That status does not belong to the task’s project.',
+ comment_not_editable: 'That entry cannot be changed.',
+ invalid_argument: 'The task library refused the request as malformed.',
+ desktop_only: 'Only a person can do that, in Orca.'
}
-async function write(
+// Why by detail: the dispatcher refusal reuses not_found so older hosts need no new code.
+const REFUSAL_DETAIL_MESSAGES: Record = {
+ dispatcher:
+ 'Orca does not know this pane as the dispatcher. Run this inside the dispatcher session that Orca started.'
+}
+/** Why an agent's status change was refused, written so the agent knows what to do next. */
+export const COMPLETED_FROM_CLI_MESSAGE =
+ 'Only the user completes a task, in Orca. Move it at most to review, and leave a comment or a progress writeback instead; do not change the status again this round.'
+
+export async function write(
rpc: TaskLibraryRpc,
method: string,
params: Record,
@@ -202,7 +215,9 @@ async function write(
if (value.ok === false) {
throw new RuntimeClientError(
value.code,
- REFUSAL_MESSAGES[value.code] ?? `The task library refused the change (${value.code}).`
+ (value.detail ? REFUSAL_DETAIL_MESSAGES[value.detail] : undefined) ??
+ REFUSAL_MESSAGES[value.code] ??
+ `The task library refused the change (${value.code}).`
)
}
return { value, envelope }
@@ -229,13 +244,84 @@ export async function setTaskStatus(
export async function addTaskComment(
rpc: TaskLibraryRpc,
resolved: ResolvedTask,
- body: string
+ body: string,
+ progress?: { sessionLinkId?: string; needsYou?: string }
): Promise> {
const { envelope } = await write(
rpc,
'localTasks.events.addComment',
- { taskId: resolved.task.id, body },
+ { taskId: resolved.task.id, body, ...(progress ? { progress } : {}) },
+ writeAnySuccess
+ )
+ return envelope
+}
+
+export async function createTask(
+ rpc: TaskLibraryRpc,
+ projectId: string,
+ title: string,
+ description?: string,
+ parentId?: string
+): Promise<{ task: LocalTask; envelope: RuntimeRpcSuccess }> {
+ const { value, envelope } = await write(
+ rpc,
+ 'localTasks.tasks.create',
+ {
+ projectId,
+ title,
+ ...(description ? { description } : {}),
+ ...(parentId ? { parentId } : {})
+ },
+ writeTaskSuccess
+ )
+ return { task: value.task, envelope }
+}
+
+export async function updateTaskTitle(
+ rpc: TaskLibraryRpc,
+ resolved: ResolvedTask,
+ title: string
+): Promise<{ task: LocalTask; envelope: RuntimeRpcSuccess }> {
+ const { value, envelope } = await write(
+ rpc,
+ 'localTasks.tasks.update',
+ { taskId: resolved.task.id, expectedRevision: resolved.task.revision, patch: { title } },
+ writeTaskSuccess
+ )
+ return { task: value.task, envelope }
+}
+
+export async function linkTaskWorkspace(
+ rpc: TaskLibraryRpc,
+ taskId: string,
+ workspace: {
+ hostId: string
+ worktreeId: string
+ instanceId?: string
+ repoId?: string
+ branchSnapshot?: string
+ nameSnapshot?: string
+ }
+): Promise> {
+ const { envelope } = await write(
+ rpc,
+ 'localTasks.links.linkWorkspace',
+ { taskId, kind: 'git-worktree', linkedVia: 'manual', ...workspace },
writeAnySuccess
)
return envelope
}
+
+/** The session link this process runs in, when it belongs to the task: matched by ORCA_PANE_KEY. */
+export function currentSessionLinkId(
+ library: TaskLibrary,
+ taskId: string,
+ paneKey: string | undefined = process.env.ORCA_PANE_KEY
+): string | undefined {
+ const session = findLocalTaskSessionForIdentity(library.links, {
+ paneKey,
+ sessionId: process.env.ORCA_AGENT_SESSION_ID,
+ tabId: process.env.ORCA_TAB_ID
+ })
+ return session?.taskId === taskId ? session.id : undefined
+}
diff --git a/src/cli/local-tasks/local-task-cli-workspace.ts b/src/cli/local-tasks/local-task-cli-workspace.ts
new file mode 100644
index 000000000000..7c387c6146aa
--- /dev/null
+++ b/src/cli/local-tasks/local-task-cli-workspace.ts
@@ -0,0 +1,20 @@
+import type { RuntimeWorktreeRecord } from '../../shared/runtime-types'
+import type { HandlerContext } from '../dispatch'
+import { RuntimeClientError } from '../runtime/types'
+import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors'
+
+export async function currentWorkspace(context: HandlerContext): Promise {
+ const { flags, client, cwd } = context
+ const explicit = await getOptionalWorktreeSelector(flags, 'worktree', cwd, client)
+ if (!explicit && client.isRemote) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ 'orca task current reads this directory’s workspace; on a paired host pass --worktree .'
+ )
+ }
+ const selector = explicit ?? (await resolveCurrentWorktreeSelector(cwd, client))
+ const { result } = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
+ worktree: selector
+ })
+ return result.worktree
+}
diff --git a/src/cli/local-tasks/local-task-cli-write-handlers.ts b/src/cli/local-tasks/local-task-cli-write-handlers.ts
new file mode 100644
index 000000000000..8bcac830a849
--- /dev/null
+++ b/src/cli/local-tasks/local-task-cli-write-handlers.ts
@@ -0,0 +1,114 @@
+import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
+import { findLocalTaskSessionForIdentity } from '../../shared/local-tasks/local-task-session-match'
+import {
+ LOCAL_TASK_DESCRIPTION_MAX,
+ LOCAL_TASK_TITLE_MAX
+} from '../../shared/local-tasks/local-task-model'
+import type { CommandHandler } from '../dispatch'
+import { readCommandBody } from '../command-body-input'
+import { getOptionalStringFlag, getRepeatedStringFlag, getRequiredStringFlag } from '../flags'
+import { printResult } from '../format'
+import { RuntimeClientError } from '../runtime/types'
+import {
+ createTask,
+ describeTask,
+ linkTaskWorkspace,
+ readTaskLibrary,
+ resolveTaskRef,
+ updateTaskTitle
+} from './local-task-cli-library'
+import { formatTaskChange, summarizeTask } from './local-task-cli-format'
+import { currentWorkspace } from './local-task-cli-workspace'
+
+export const LOCAL_TASK_CLI_WRITE_HANDLERS: Record = {
+ 'task create': async ({ client, flags, json, cwd }) => {
+ const title = getRepeatedStringFlag(flags, 'title').join(' ').trim()
+ if (!title || title.length > LOCAL_TASK_TITLE_MAX) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ `Title must be 1–${LOCAL_TASK_TITLE_MAX} characters.`
+ )
+ }
+ const description = await readCommandBody(flags, cwd, {
+ required: false,
+ maximumCharacters: LOCAL_TASK_DESCRIPTION_MAX,
+ tooLargeCode: 'invalid_argument',
+ label: 'Description'
+ })
+ const library = await readTaskLibrary(client, { access: 'write', links: true })
+ const parentRef = getOptionalStringFlag(flags, 'parent')
+ const parent = parentRef ? resolveTaskRef(library, parentRef).task : null
+ const projectName = getOptionalStringFlag(flags, 'project')
+ const matchingProjects = projectName
+ ? library.projects.filter((project) =>
+ [project.name, project.prefix, ...project.aliases].some(
+ (name) => name.toLowerCase() === projectName.toLowerCase()
+ )
+ )
+ : []
+ const session = findLocalTaskSessionForIdentity(library.links, {
+ paneKey: process.env.ORCA_PANE_KEY,
+ sessionId: process.env.ORCA_AGENT_SESSION_ID,
+ tabId: process.env.ORCA_TAB_ID
+ })
+ const sessionTask = session ? library.tasks.find((task) => task.id === session.taskId) : null
+ const projectId =
+ parent?.projectId ??
+ matchingProjects[0]?.id ??
+ sessionTask?.projectId ??
+ (library.projects.length === 1 ? library.projects[0].id : null)
+ if (!projectId || (projectName && matchingProjects.length !== 1)) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ 'Pass --project to choose the task project.'
+ )
+ }
+ const { task, envelope } = await createTask(client, projectId, title, description, parent?.id)
+ printResult(
+ {
+ ...envelope,
+ result: { task: summarizeTask(describeTask(library, task)), change: 'created' }
+ },
+ json,
+ formatTaskChange
+ )
+ },
+ 'task update': async ({ client, flags, json }) => {
+ const title = getRequiredStringFlag(flags, 'title').trim()
+ if (!title || title.length > LOCAL_TASK_TITLE_MAX) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ `Title must be 1–${LOCAL_TASK_TITLE_MAX} characters.`
+ )
+ }
+ const library = await readTaskLibrary(client, { access: 'write' })
+ const resolved = resolveTaskRef(library, getRequiredStringFlag(flags, 'ref'))
+ const { task, envelope } = await updateTaskTitle(client, resolved, title)
+ printResult(
+ {
+ ...envelope,
+ result: { task: summarizeTask(describeTask(library, task)), change: 'updated' }
+ },
+ json,
+ formatTaskChange
+ )
+ },
+ 'task link': async (context) => {
+ const library = await readTaskLibrary(context.client, { access: 'write' })
+ const resolved = resolveTaskRef(library, getRequiredStringFlag(context.flags, 'ref'))
+ const worktree = await currentWorkspace(context)
+ const envelope = await linkTaskWorkspace(context.client, resolved.task.id, {
+ hostId: worktree.hostId ?? LOCAL_EXECUTION_HOST_ID,
+ worktreeId: worktree.id,
+ ...(worktree.instanceId ? { instanceId: worktree.instanceId } : {}),
+ ...(worktree.repoId ? { repoId: worktree.repoId } : {}),
+ ...(worktree.branch ? { branchSnapshot: worktree.branch } : {}),
+ ...(worktree.displayName ? { nameSnapshot: worktree.displayName } : {})
+ })
+ printResult(
+ { ...envelope, result: { task: summarizeTask(resolved), change: 'linked' } },
+ context.json,
+ formatTaskChange
+ )
+ }
+}
diff --git a/src/cli/local-tasks/local-task-cli.test.ts b/src/cli/local-tasks/local-task-cli.test.ts
index 834bb32732d1..f10cb8b72f4a 100644
--- a/src/cli/local-tasks/local-task-cli.test.ts
+++ b/src/cli/local-tasks/local-task-cli.test.ts
@@ -69,6 +69,14 @@ const statuses = [
name: 'In Review',
color: 'blue',
position: 2
+ },
+ {
+ id: 'done',
+ projectId: 'shop',
+ category: 'completed',
+ name: 'Done',
+ color: 'green',
+ position: 3
}
]
@@ -126,7 +134,23 @@ const links = {
linkedAt: 0
}
],
- sessions: []
+ sessions: [
+ {
+ id: 'link-12',
+ taskId: 'task-12',
+ hostId: 'local',
+ worktreeId: checkout.id,
+ instanceId: null,
+ surface: 'terminal',
+ tabId: 'tab-1',
+ paneKey: 'tab-1:pane-a',
+ sessionId: null,
+ providerSession: null,
+ agent: 'claude',
+ linkedVia: 'start',
+ openedAt: 0
+ }
+ ]
}
function host(
@@ -187,10 +211,20 @@ describe('orca task', () => {
it('lists tasks title first, narrowed by project prefix and status category', async () => {
host()
await main(['task', 'list', '--project', 'shop', '--status', 'started'], '/repo')
- expect(printed()).toBe('Export orders as CSV\tSHOP-12 · In Progress · Shop')
+ expect(printed()).toBe('Export orders as CSV\tSHOP-12 · In Progress · Shop · checkout')
})
- it('shows a task by an earlier prefix with its description, criteria and workspace', async () => {
+ it('lists each task with its own workspace, so a proposal can name its worktreeId', async () => {
+ host()
+ await main(['task', 'list', '--project', 'shop', '--json'], '/repo')
+ const listed = JSON.parse(printed()).result.tasks
+ expect(listed[0]).toMatchObject({
+ ref: 'SHOP-12',
+ workspace: { hostId: 'local', name: 'checkout', worktreeId: checkout.id }
+ })
+ })
+
+ it('shows a task by an earlier prefix with its description and workspace', async () => {
host()
await main(['task', 'show', 'store-12'], '/repo')
expect(printed().split('\n')).toEqual([
@@ -201,11 +235,29 @@ describe('orca task', () => {
'',
'Shop owners need a file for their accountant.',
'',
- 'Done criteria:',
- '- Columns match the order table'
+ 'Sessions (sessionLinkId · agent · opened):',
+ '- link-12 · claude · 1970-01-01 00:00Z'
])
})
+ it("creates a subtask in its parent's project and renames a task", async () => {
+ host({
+ write: () => ({ ok: true, task: { ...tasks[0], id: 'task-13', title: 'Flaky retry' } })
+ })
+ await main(['task', 'create', 'Flaky', 'retry', '--parent', 'SHOP-12'], '/repo')
+ expect(writeParams('localTasks.tasks.create')).toMatchObject({
+ projectId: 'shop',
+ parentId: 'task-12',
+ title: 'Flaky retry'
+ })
+ await main(['task', 'update', 'SHOP-12', '--title', 'Retry on reconnect'], '/repo')
+ expect(writeParams('localTasks.tasks.update')).toMatchObject({
+ taskId: 'task-12',
+ expectedRevision: 7,
+ patch: { title: 'Retry on reconnect' }
+ })
+ })
+
it('names the task of the workspace the current directory is in', async () => {
host()
await main(['task', 'current', '--json'], '/repo/checkout/src')
@@ -214,6 +266,64 @@ describe('orca task', () => {
expect(result.task).toMatchObject({ ref: 'SHOP-12', title: 'Export orders as CSV' })
})
+ it('prefers the linked session when current runs outside its workspace', async () => {
+ host()
+ process.env.ORCA_PANE_KEY = 'tab-1:pane-a'
+ try {
+ await main(['task', 'current', '--json'], '/somewhere-else')
+ } finally {
+ delete process.env.ORCA_PANE_KEY
+ }
+ const result = JSON.parse(printed()).result
+ expect(result.worktree).toBeNull()
+ expect(result.task.ref).toBe('SHOP-12')
+ expect(writeParams('worktree.show')).toBeUndefined()
+ })
+
+ it('uses an explicit worktree selector even inside a linked session', async () => {
+ host()
+ process.env.ORCA_PANE_KEY = 'tab-1:pane-a'
+ try {
+ await main(['task', 'current', '--worktree', 'name:checkout', '--json'], '/somewhere-else')
+ } finally {
+ delete process.env.ORCA_PANE_KEY
+ }
+ expect(writeParams('worktree.show')).toBeDefined()
+ expect(JSON.parse(printed()).result.worktree).toMatchObject({ id: checkout.id })
+ })
+
+ it('creates a child task in its parent project with a description', async () => {
+ host({ write: () => ({ ok: true, task: { ...tasks[1], parentId: 'task-12' } }) })
+ await main(
+ ['task', 'create', 'Follow up', '--parent', 'SHOP-12', '--body', 'Check retries'],
+ '/repo'
+ )
+ expect(writeParams('localTasks.tasks.create')).toMatchObject({
+ projectId: 'shop',
+ parentId: 'task-12',
+ title: 'Follow up',
+ description: 'Check retries'
+ })
+ })
+
+ it('updates a title with revision and links the selected workspace', async () => {
+ host({ write: () => ({ ok: true, task: { ...tasks[0], title: 'New title' } }) })
+ await main(['task', 'update', 'SHOP-12', '--title', 'New title'], '/repo')
+ expect(writeParams('localTasks.tasks.update')).toMatchObject({
+ taskId: 'task-12',
+ expectedRevision: 7,
+ patch: { title: 'New title' }
+ })
+ host()
+ await main(['task', 'link', 'SHOP-12'], '/repo/checkout')
+ expect(writeParams('localTasks.links.linkWorkspace')).toMatchObject({
+ taskId: 'task-12',
+ hostId: 'local',
+ worktreeId: checkout.id,
+ linkedVia: 'manual'
+ })
+ })
+
it('comments with every word after the ref, as one idempotent write', async () => {
host()
await main(['task', 'comment', 'SHOP-12', 'Tests', 'pass', 'now'], '/repo')
@@ -241,7 +351,7 @@ describe('orca task', () => {
await main(['task', 'status', 'SHOP-12', 'Shipped'], '/repo')
expect(writeParams('localTasks.tasks.update')).toBeUndefined()
expect(console.error).toHaveBeenCalledWith(
- expect.stringContaining('Statuses: Todo, In Progress, In Review')
+ expect.stringContaining('Statuses: Todo, In Progress, In Review, Done')
)
})
@@ -253,6 +363,232 @@ describe('orca task', () => {
)
})
+ it('writes back progress tied to the session it runs in, with what it needs', async () => {
+ host()
+ process.env.ORCA_PANE_KEY = 'tab-1:pane-a'
+ try {
+ await main(
+ [
+ 'task',
+ 'progress',
+ 'SHOP-12',
+ '--body',
+ 'Found the retry loop',
+ '--needs-you',
+ 'Pick a timeout'
+ ],
+ '/repo'
+ )
+ } finally {
+ delete process.env.ORCA_PANE_KEY
+ }
+ expect(writeParams('localTasks.events.addComment')).toMatchObject({
+ taskId: 'task-12',
+ body: 'Found the retry loop',
+ progress: { sessionLinkId: 'link-12', needsYou: 'Pick a timeout' }
+ })
+ expect(printed()).toBe('progress\tExport orders as CSV\tSHOP-12 · In Progress · Shop')
+ })
+
+ it('writes back without a session link outside a session of the task', async () => {
+ host()
+ await main(['task', 'progress', 'SHOP-13', '--body', 'Looked at refunds'], '/repo')
+ expect(writeParams('localTasks.events.addComment')).toMatchObject({
+ taskId: 'task-13',
+ progress: {}
+ })
+ })
+
+ it('refuses an empty writeback before touching the library', async () => {
+ host()
+ await main(['task', 'progress', 'SHOP-12', '--body', ' '], '/repo')
+ expect(writeParams('localTasks.events.addComment')).toBeUndefined()
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('empty'))
+ })
+
+ it('refuses to complete a task, telling the agent what to do instead', async () => {
+ host()
+ await main(['task', 'status', 'SHOP-12', 'completed'], '/repo')
+ expect(writeParams('localTasks.tasks.update')).toBeUndefined()
+ expect(console.error).toHaveBeenCalledWith(
+ expect.stringContaining('Only the user completes a task')
+ )
+ })
+
+ it('prints the worker guide, and the dispatcher guide on request', async () => {
+ host()
+ await main(['task', 'guide'], '/repo')
+ expect(printed()).toContain('orca task progress [ --body-file ')
+ vi.mocked(console.log).mockClear()
+ await main(['task', 'guide', '--dispatcher'], '/repo')
+ expect(printed()).toContain('orca task propose --body-file ')
+ })
+
+ describe('dispatcher commands', () => {
+ const PANE = 'tab-d:pane-d'
+ const proposalReply = (items: unknown[]) => ({
+ ok: true,
+ proposal: {
+ id: 'p1',
+ conversationId: 'c1',
+ summary: 'Follow PRs',
+ items,
+ status: 'pending',
+ keptItemIndexes: null,
+ results: null,
+ createdAt: 1,
+ decidedAt: null,
+ decidedSource: null
+ }
+ })
+ async function inPane(run: () => Promise) {
+ process.env.ORCA_PANE_KEY = PANE
+ try {
+ await run()
+ } finally {
+ delete process.env.ORCA_PANE_KEY
+ }
+ }
+
+ it('proposes items named by ref, resolved to task ids, from the dispatcher pane', async () => {
+ const items = [
+ { kind: 'append', taskId: 'SHOP-12', sessionLinkId: 'link-12', text: 'Write the repro' },
+ { kind: 'subtask', parentTaskId: 'store-12', title: 'Backend', prompt: 'Do it' }
+ ]
+ host({ write: () => proposalReply([]) })
+ await inPane(() =>
+ main(
+ ['task', 'propose', '--body', JSON.stringify({ summary: 'Follow PRs', items })],
+ '/repo'
+ )
+ )
+ expect(writeParams('localTasks.proposals.create')).toMatchObject({
+ paneKey: PANE,
+ summary: 'Follow PRs',
+ items: [
+ { kind: 'append', taskId: 'task-12' },
+ { kind: 'subtask', parentTaskId: 'task-12' }
+ ]
+ })
+ expect(printed()).toContain('The user confirms or dismisses it in Orca')
+ })
+
+ it('fills in the only session of a task an append names', async () => {
+ const items = [{ kind: 'append', taskId: 'SHOP-12', text: 'Write the repro' }]
+ host({ write: () => proposalReply([]) })
+ await inPane(() => main(['task', 'propose', '--body', JSON.stringify(items)], '/repo'))
+ expect(writeParams('localTasks.proposals.create')).toMatchObject({
+ items: [{ kind: 'append', taskId: 'task-12', sessionLinkId: 'link-12' }]
+ })
+ })
+
+ it('passes on a ref of a task on another host for Orca to find when the proposal runs', async () => {
+ const items = [{ kind: 'append', taskId: 'SSH-7', text: 'Keep going' }]
+ host({ write: () => proposalReply([]) })
+ await inPane(() => main(['task', 'propose', '--body', JSON.stringify(items)], '/repo'))
+ expect(writeParams('localTasks.proposals.create')).toMatchObject({
+ items: [{ kind: 'append', taskId: 'SSH-7' }]
+ })
+ })
+
+ it('explains a malformed proposal and never sends it', async () => {
+ host()
+ await inPane(() =>
+ main(['task', 'propose', '--body', '[{"kind":"start","title":"x"}]'], '/repo')
+ )
+ expect(writeParams('localTasks.proposals.create')).toBeUndefined()
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Expected start: {'))
+ })
+
+ it('refuses a field the item kind does not take, instead of dropping it', async () => {
+ host()
+ const items = [
+ { kind: 'subtask', parentTaskId: 'SHOP-12', title: 'Probe', prompt: 'Read', target: {} }
+ ]
+ await inPane(() => main(['task', 'propose', '--body', JSON.stringify(items)], '/repo'))
+ expect(writeParams('localTasks.proposals.create')).toBeUndefined()
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('unknown field target'))
+ })
+
+ it('refuses dispatcher commands outside a session Orca started', async () => {
+ host()
+ await main(['task', 'inbox'], '/repo')
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('ORCA_PANE_KEY'))
+ })
+
+ it('prints the inbox with its cursor, and acks that cursor', async () => {
+ host({
+ write: (method) =>
+ method === 'localTasks.dispatcher.inbox'
+ ? {
+ ok: true,
+ inbox: {
+ conversationId: 'c1',
+ items: [
+ {
+ kind: 'progress',
+ at: Date.UTC(2026, 10, 5, 9, 30),
+ taskId: 'task-12',
+ taskRef: 'SHOP-12',
+ summary: 'Export orders as CSV: Found the loop',
+ needsYou: 'Pick a timeout'
+ }
+ ],
+ cursor: 'CUR',
+ more: false,
+ watchedTaskCount: 1
+ }
+ }
+ : { ok: true }
+ })
+ await inPane(() => main(['task', 'inbox'], '/repo'))
+ expect(printed().split('\n')).toEqual([
+ '2026-11-05 09:30Z progress SHOP-12 Export orders as CSV: Found the loop',
+ ' needs the user: Pick a timeout',
+ 'When handled: orca task inbox --ack CUR'
+ ])
+ await inPane(() => main(['task', 'inbox', '--ack', 'CUR'], '/repo'))
+ expect(writeParams('localTasks.dispatcher.ack')).toMatchObject({
+ paneKey: PANE,
+ cursor: 'CUR'
+ })
+ })
+
+ it('watches a task by ref', async () => {
+ host()
+ await inPane(() => main(['task', 'watch', 'SHOP-13'], '/repo'))
+ expect(writeParams('localTasks.dispatcher.watch')).toMatchObject({
+ paneKey: PANE,
+ taskId: 'task-13'
+ })
+ })
+
+ it('lists sessions waiting for the user and tasks waiting for review together', async () => {
+ host({
+ write: (method) =>
+ method === 'localTasks.attention.get'
+ ? {
+ observedAt: 1,
+ sessions: [],
+ items: [
+ {
+ key: 'k',
+ taskId: 'task-13',
+ taskSessionId: 's',
+ kind: 'approval',
+ agent: 'codex',
+ lastMessage: 'Allow npm install?',
+ since: 1,
+ replyable: false
+ }
+ ]
+ }
+ : { ok: true }
+ })
+ await main(['task', 'needs-you'], '/repo')
+ expect(printed()).toBe('SHOP-13 Refund flow: codex waits to approve — Allow npm install?')
+ })
+ })
it('refuses to write to a library a newer Orca left read-only', async () => {
host({ state: 'read-only' })
await main(['task', 'comment', 'SHOP-12', 'hello'], '/repo')
diff --git a/src/cli/local-tasks/local-task-command-specs.ts b/src/cli/local-tasks/local-task-command-specs.ts
index 42861bd65295..2e0bb952ed9c 100644
--- a/src/cli/local-tasks/local-task-command-specs.ts
+++ b/src/cli/local-tasks/local-task-command-specs.ts
@@ -4,6 +4,36 @@ const LIBRARY_NOTE =
"Reads the task library of the Orca this CLI talks to: this computer's, or the paired server named by --environment / --pairing-code."
export const LOCAL_TASK_COMMAND_SPECS: CommandSpec[] = [
+ {
+ path: ['task', 'create'],
+ summary: 'Create a local task',
+ usage:
+ 'orca task create [--parent ][] [--project ] [--body-file ] [--json]',
+ allowedFlags: [...GLOBAL_FLAGS, 'title', 'parent', 'project', 'body', 'body-file'],
+ positionalArgs: ['title'],
+ variadicPositional: true,
+ repeatableFlags: ['title'],
+ notes: ['Uses the current task project when run inside a linked session.'],
+ examples: ['orca task create "Investigate retry timeout" --parent SHOP-12']
+ },
+ {
+ path: ['task', 'update'],
+ summary: 'Update a local task title',
+ usage: 'orca task update ][ --title [--json]',
+ allowedFlags: [...GLOBAL_FLAGS, 'ref', 'title'],
+ positionalArgs: ['ref'],
+ notes: ['Uses the task revision so a concurrent change is refused.'],
+ examples: ['orca task update SHOP-12 --title "Export orders"']
+ },
+ {
+ path: ['task', 'link'],
+ summary: 'Link a local task to a workspace',
+ usage: 'orca task link ][ [--worktree ] [--json]',
+ allowedFlags: [...GLOBAL_FLAGS, 'ref', 'worktree'],
+ positionalArgs: ['ref'],
+ notes: ['Links the current workspace unless --worktree selects another.'],
+ examples: ['orca task link SHOP-12']
+ },
{
path: ['task', 'list'],
summary: 'List local tasks, title first',
@@ -17,7 +47,7 @@ export const LOCAL_TASK_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['task', 'show'],
- summary: 'Show one local task with its description and done criteria',
+ summary: 'Show one local task with its description',
usage: 'orca task show ][ [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'ref'],
positionalArgs: ['ref'],
@@ -29,11 +59,11 @@ export const LOCAL_TASK_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['task', 'current'],
- summary: 'Show the local task the current workspace belongs to',
+ summary: 'Show the task linked to this session or workspace',
usage: 'orca task current [--worktree ] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree'],
notes: [
- 'Resolves the Orca-managed workspace that contains the current directory; on a paired host pass --worktree.',
+ 'Checks the current pane or agent session first, then resolves the workspace containing the current directory; on a paired host pass --worktree.',
'A workspace belongs to at most one task; an unlinked workspace prints that it has none.'
],
examples: ['orca task current', 'orca task current --worktree name:checkout --json']
@@ -58,9 +88,81 @@ export const LOCAL_TASK_COMMAND_SPECS: CommandSpec[] = [
variadicPositional: true,
repeatableFlags: ['status'],
notes: [
- "Takes a status name of the task's project in any case, or a category such as completed.",
- 'Setting the status the task already has changes nothing.'
+ "Takes a status name of the task's project in any case, or a category such as review.",
+ 'Setting the status the task already has changes nothing.',
+ 'A completed status is refused: only the user completes a task, in Orca.'
+ ],
+ examples: ['orca task status SHOP-12 "In Review"', 'orca task status SHOP-12 review']
+ },
+ {
+ path: ['task', 'progress'],
+ summary: 'Write back the result of a round on a local task',
+ usage:
+ 'orca task progress ][ (--body | --body-file ) [--needs-you ] [--json]',
+ allowedFlags: [...GLOBAL_FLAGS, 'ref', 'body', 'body-file', 'needs-you'],
+ positionalArgs: ['ref'],
+ notes: [
+ 'Three parts: what you did; the conclusion or output; the next step or what you need.',
+ 'Write the text to a UTF-8 file and pass --body-file; inline multi-line text breaks in shells.',
+ 'Inside a session started from the task, the writeback is tied to that session.'
+ ],
+ examples: [
+ 'orca task progress SHOP-12 --body-file result.md',
+ 'orca task progress SHOP-12 --body-file result.md --needs-you "Pick the retry timeout"'
+ ]
+ },
+ {
+ path: ['task', 'guide'],
+ summary: 'Print the rules an agent follows on a local task',
+ usage: 'orca task guide [--dispatcher]',
+ allowedFlags: [...GLOBAL_FLAGS, 'dispatcher'],
+ notes: ['--dispatcher prints the rules for the dispatcher session instead.'],
+ examples: ['orca task guide', 'orca task guide --dispatcher']
+ },
+ {
+ path: ['task', 'propose'],
+ summary: 'Propose tasks and sessions for the user to confirm (dispatcher only)',
+ usage: 'orca task propose (--body | --body-file ) [--json]',
+ allowedFlags: [...GLOBAL_FLAGS, 'body', 'body-file'],
+ notes: [
+ 'Run inside the dispatcher session. The user confirms or dismisses in Orca, which then executes.',
+ 'Items: start, subtask, append, stop, link, record. Name tasks by ref. See orca task guide --dispatcher.'
],
- examples: ['orca task status SHOP-12 "In Review"', 'orca task status SHOP-12 completed']
+ examples: ['orca task propose --body-file proposal.json']
+ },
+ {
+ path: ['task', 'inbox'],
+ summary: 'Read what changed on the tasks the dispatcher watches',
+ usage: 'orca task inbox [--limit ] [--ack ] [--json]',
+ allowedFlags: [...GLOBAL_FLAGS, 'limit', 'ack'],
+ notes: [
+ 'Run inside the dispatcher session. Reading changes nothing; pass the printed cursor to --ack once handled.'
+ ],
+ examples: ['orca task inbox --json', 'orca task inbox --ack ']
+ },
+ {
+ path: ['task', 'watch'],
+ summary: 'Have the dispatcher follow a local task',
+ usage: 'orca task watch ][ [--json]',
+ allowedFlags: [...GLOBAL_FLAGS, 'ref'],
+ positionalArgs: ['ref'],
+ notes: ['Run inside the dispatcher session. Finished tasks stop being watched on their own.'],
+ examples: ['orca task watch SHOP-12']
+ },
+ {
+ path: ['task', 'unwatch'],
+ summary: 'Stop the dispatcher following a local task',
+ usage: 'orca task unwatch ][ [--json]',
+ allowedFlags: [...GLOBAL_FLAGS, 'ref'],
+ positionalArgs: ['ref'],
+ examples: ['orca task unwatch SHOP-12']
+ },
+ {
+ path: ['task', 'needs-you'],
+ summary: 'List sessions and tasks waiting for the user',
+ usage: 'orca task needs-you [--json]',
+ allowedFlags: [...GLOBAL_FLAGS],
+ notes: [LIBRARY_NOTE],
+ examples: ['orca task needs-you --json']
}
]
diff --git a/src/cli/local-tasks/local-task-dispatcher-cli.ts b/src/cli/local-tasks/local-task-dispatcher-cli.ts
new file mode 100644
index 000000000000..fdebb5a501b3
--- /dev/null
+++ b/src/cli/local-tasks/local-task-dispatcher-cli.ts
@@ -0,0 +1,276 @@
+import { z } from 'zod'
+import { LocalTaskProposalItemSchema } from '../../shared/local-tasks/local-task-dispatcher-rpc-params'
+import { inboxSchema, proposalSchema } from '../../shared/local-tasks/local-task-dispatcher-schemas'
+import type { LocalTaskInbox } from '../../shared/local-tasks/local-task-dispatcher-types'
+import { localTaskProgressSnapshotSchema } from '../../shared/local-tasks/local-task-link-read-schemas'
+import { readCommandBody } from '../command-body-input'
+import type { CommandHandler } from '../dispatch'
+import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
+import { printResult } from '../format'
+import { RuntimeClientError } from '../runtime/types'
+import { cliTime } from './local-task-cli-format'
+import {
+ localTaskProposalFormatHints,
+ localTaskProposalItemProblems
+} from './local-task-proposal-check'
+import {
+ callLibrary,
+ readTaskLibrary,
+ resolveTaskRef,
+ write,
+ type TaskLibrary
+} from './local-task-cli-library'
+
+const PROPOSAL_MAX = 200_000
+const PROPOSAL_BODY = z.union([
+ z.array(z.unknown()).transform((items) => ({ summary: undefined, items })),
+ z.object({ summary: z.string().optional(), items: z.array(z.unknown()) })
+])
+const ATTENTION_WORDS: Record = {
+ approval: 'waits to approve',
+ question: 'waits for an answer',
+ blocked: 'is blocked',
+ 'unread-done': 'finished a round, unread'
+}
+
+/** The dispatcher's own pane; Orca finds the conversation by it (REQ-030). */
+function dispatcherPane(): string {
+ const paneKey = process.env.ORCA_PANE_KEY
+ if (!paneKey) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ 'Run this inside the dispatcher session that Orca started; ORCA_PANE_KEY is not set.'
+ )
+ }
+ return paneKey
+}
+
+/** A task by its id or any ref the task page accepts. */
+function taskIdOf(library: TaskLibrary, value: string): string {
+ if (library.tasks.some((task) => task.id === value)) {
+ return value
+ }
+ try {
+ return resolveTaskRef(library, value).task.id
+ } catch (error) {
+ // Why pass it on: a task on another host is in that host's library, read when the proposal runs.
+ if (error instanceof RuntimeClientError && error.code === 'not_found') {
+ return value
+ }
+ throw error
+ }
+}
+
+/** Lets a proposal name tasks by ref, as the dispatcher reads them in `orca task list`. */
+function resolveProposalRefs(library: TaskLibrary, item: unknown): unknown {
+ if (!item || typeof item !== 'object') {
+ return item
+ }
+ const record: Record = Object.fromEntries(Object.entries(item))
+ for (const key of ['taskId', 'parentTaskId']) {
+ if (typeof record[key] === 'string') {
+ record[key] = taskIdOf(library, record[key])
+ }
+ }
+ if ((record.kind === 'append' || record.kind === 'stop') && !record.sessionLinkId) {
+ record.sessionLinkId = onlySessionOf(library, record.taskId)
+ }
+ return record
+}
+
+/** An append or stop may leave out the session when the task has exactly one. */
+function onlySessionOf(library: TaskLibrary, taskId: unknown): string | undefined {
+ const sessions = library.links.sessions.filter((session) => session.taskId === taskId)
+ const here = library.tasks.some((task) => task.id === taskId)
+ // Why not for another library's task: Orca finds its only session when the proposal runs.
+ if (sessions.length === 1 || typeof taskId !== 'string' || !here) {
+ return sessions[0]?.id
+ }
+ const ref = library.tasks.find((task) => task.id === taskId)?.ref ?? taskId
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ sessions.length === 0
+ ? `${ref} has no session to send to or stop.`
+ : `${ref} has ${sessions.length} sessions; name one with "sessionLinkId" (see orca task show ${ref}).`
+ )
+}
+
+function formatInbox(result: { inbox: LocalTaskInbox }): string {
+ const { inbox } = result
+ // Why the cursor even when empty: hidden notes still use the page and must be acked past.
+ if (inbox.items.length === 0) {
+ return [
+ `Nothing new on the ${inbox.watchedTaskCount} watched task(s).`,
+ ...(inbox.more
+ ? ['More items wait.', `Move past this page: orca task inbox --ack ${inbox.cursor}`]
+ : [])
+ ].join('\n')
+ }
+ const lines = inbox.items.map((item) => {
+ const needs = item.needsYou ? `\n needs the user: ${item.needsYou}` : ''
+ return `${cliTime(item.at)} ${item.kind} ${item.taskRef ?? '-'} ${item.summary}${needs}`
+ })
+ const more = inbox.more ? ['More items wait; ack these, then read again.'] : []
+ return [...lines, ...more, `When handled: orca task inbox --ack ${inbox.cursor}`].join('\n')
+}
+
+export const LOCAL_TASK_DISPATCHER_CLI_HANDLERS: Record = {
+ 'task propose': async ({ client, flags, json, cwd }) => {
+ const paneKey = dispatcherPane()
+ const text = await readCommandBody(flags, cwd, {
+ required: true,
+ maximumCharacters: PROPOSAL_MAX,
+ tooLargeCode: 'invalid_argument',
+ label: 'Proposal'
+ })
+ let parsedJson: unknown
+ try {
+ parsedJson = JSON.parse(text)
+ } catch {
+ throw new RuntimeClientError('invalid_argument', 'The proposal is not valid JSON.')
+ }
+ const body = PROPOSAL_BODY.safeParse(parsedJson)
+ if (!body.success) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ 'Write the proposal as { "summary": "...", "items": [ ... ] } or as an array of items.'
+ )
+ }
+ const problems = localTaskProposalItemProblems(body.data.items)
+ if (problems.length > 0) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ `The proposal items do not match the format: ${problems.slice(0, 3).join('; ')}`
+ )
+ }
+ const library = await readTaskLibrary(client, { access: 'write', links: true })
+ const items = LocalTaskProposalItemSchema.array()
+ .min(1)
+ .max(20)
+ .safeParse(body.data.items.map((item) => resolveProposalRefs(library, item)))
+ if (!items.success) {
+ const failing = new Set(
+ items.error.issues.map((issue) => issue.path[0]).filter((at) => typeof at === 'number')
+ )
+ const hints = localTaskProposalFormatHints(body.data.items, failing)
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ [
+ `The proposal items do not match the format: ${items.error.issues
+ .slice(0, 3)
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
+ .join('; ')}`,
+ ...(hints.length > 0
+ ? [`Expected ${hints.join(' | ')}`]
+ : ['See orca task guide --dispatcher'])
+ ].join('. ')
+ )
+ }
+ const { value, envelope } = await write(
+ client,
+ 'localTasks.proposals.create',
+ { paneKey, items: items.data, ...(body.data.summary ? { summary: body.data.summary } : {}) },
+ z.object({ proposal: proposalSchema })
+ )
+ printResult(
+ { ...envelope, result: { proposal: value.proposal } },
+ json,
+ () =>
+ `Proposed ${value.proposal.items.length} item(s). The user confirms or dismisses it in Orca; you will hear the outcome in orca task inbox.`
+ )
+ },
+ 'task inbox': async ({ client, flags, json }) => {
+ const paneKey = dispatcherPane()
+ const ack = getOptionalStringFlag(flags, 'ack')
+ if (ack) {
+ const { envelope } = await write(
+ client,
+ 'localTasks.dispatcher.ack',
+ { paneKey, cursor: ack },
+ z.object({})
+ )
+ printResult({ ...envelope, result: { acked: true } }, json, () => 'Inbox acknowledged.')
+ return
+ }
+ const limitText = getOptionalStringFlag(flags, 'limit')
+ const limit = limitText ? Number.parseInt(limitText, 10) : undefined
+ const { value, envelope } = await callLibrary(
+ client,
+ 'localTasks.dispatcher.inbox',
+ { paneKey, ...(limit && limit > 0 ? { limit: Math.min(limit, 100) } : {}) },
+ z.union([
+ z.object({ ok: z.literal(true), inbox: inboxSchema }),
+ z.object({ ok: z.literal(false), code: z.string(), detail: z.string().optional() })
+ ])
+ )
+ if (!value.ok) {
+ throw new RuntimeClientError(
+ value.code,
+ value.detail === 'dispatcher'
+ ? 'Orca does not know this pane as the dispatcher. Run this inside the dispatcher session that Orca started.'
+ : `The task library refused the read (${value.code}).`
+ )
+ }
+ printResult({ ...envelope, result: { inbox: value.inbox } }, json, formatInbox)
+ },
+ 'task watch': async ({ client, flags, json }) => {
+ const paneKey = dispatcherPane()
+ const library = await readTaskLibrary(client, { access: 'write' })
+ const taskId = taskIdOf(library, getRequiredStringFlag(flags, 'ref'))
+ const { envelope } = await write(
+ client,
+ 'localTasks.dispatcher.watch',
+ { paneKey, taskId },
+ z.object({})
+ )
+ printResult({ ...envelope, result: { watching: taskId } }, json, () => 'Watching.')
+ },
+ 'task unwatch': async ({ client, flags, json }) => {
+ const paneKey = dispatcherPane()
+ const library = await readTaskLibrary(client, { access: 'write' })
+ const taskId = taskIdOf(library, getRequiredStringFlag(flags, 'ref'))
+ const { envelope } = await write(
+ client,
+ 'localTasks.dispatcher.unwatch',
+ { paneKey, taskId },
+ z.object({})
+ )
+ printResult({ ...envelope, result: { watching: null } }, json, () => 'Stopped watching.')
+ },
+ 'task needs-you': async ({ client, json }) => {
+ const library = await readTaskLibrary(client)
+ const { value: snapshot, envelope } = await callLibrary(
+ client,
+ 'localTasks.attention.get',
+ {},
+ localTaskProgressSnapshotSchema
+ )
+ const byId = new Map(library.tasks.map((task) => [task.id, task]))
+ const sessions = snapshot.items.map((item) => ({
+ taskRef: byId.get(item.taskId)?.ref ?? null,
+ title: byId.get(item.taskId)?.title ?? null,
+ kind: item.kind,
+ agent: item.agent,
+ lastMessage: item.lastMessage,
+ since: item.since
+ }))
+ const reviewIds = new Set(
+ library.projects.flatMap((project) =>
+ project.statuses.filter((status) => status.category === 'review').map((status) => status.id)
+ )
+ )
+ const review = library.tasks
+ .filter((task) => reviewIds.has(task.statusId))
+ .map((task) => ({ taskRef: task.ref, title: task.title }))
+ printResult({ ...envelope, result: { sessions, review } }, json, (result) => {
+ const lines = [
+ ...result.sessions.map(
+ (row) =>
+ `${row.taskRef ?? '-'} ${row.title ?? 'A task'}: ${row.agent ?? 'a session'} ${ATTENTION_WORDS[row.kind] ?? 'waits'}${row.lastMessage ? ` — ${row.lastMessage}` : ''}`
+ ),
+ ...result.review.map((row) => `${row.taskRef} ${row.title}: waits for the user to review`)
+ ]
+ return lines.length > 0 ? lines.join('\n') : 'Nothing waits for the user.'
+ })
+ }
+}
diff --git a/src/cli/local-tasks/local-task-handler-group.ts b/src/cli/local-tasks/local-task-handler-group.ts
index 5d0dc872b453..45dd02fddd47 100644
--- a/src/cli/local-tasks/local-task-handler-group.ts
+++ b/src/cli/local-tasks/local-task-handler-group.ts
@@ -2,6 +2,22 @@ import type { HandlerGroup } from '../handler-group-manifest'
export const LOCAL_TASK_HANDLER_GROUP: HandlerGroup = {
name: 'local-tasks',
- keys: ['task list', 'task show', 'task current', 'task comment', 'task status'],
+ keys: [
+ 'task list',
+ 'task show',
+ 'task current',
+ 'task create',
+ 'task update',
+ 'task link',
+ 'task comment',
+ 'task status',
+ 'task progress',
+ 'task guide',
+ 'task propose',
+ 'task inbox',
+ 'task watch',
+ 'task unwatch',
+ 'task needs-you'
+ ],
load: async () => (await import('./local-task-cli-handlers.js')).LOCAL_TASK_CLI_HANDLERS
}
diff --git a/src/cli/local-tasks/local-task-proposal-check.ts b/src/cli/local-tasks/local-task-proposal-check.ts
new file mode 100644
index 000000000000..0dcd04679239
--- /dev/null
+++ b/src/cli/local-tasks/local-task-proposal-check.ts
@@ -0,0 +1,55 @@
+import { LOCAL_TASK_PROPOSAL_ITEM_FORMATS } from '../../shared/local-tasks/local-task-brief'
+import { LocalTaskProposalItemSchema } from '../../shared/local-tasks/local-task-dispatcher-rpc-params'
+
+const FIELDS_BY_KIND: ReadonlyMap> = new Map(
+ LocalTaskProposalItemSchema.options.map((option) => [
+ option.shape.kind.value,
+ new Set(Object.keys(option.shape))
+ ])
+)
+
+const FORMATS: ReadonlyMap = new Map(
+ Object.entries(LOCAL_TASK_PROPOSAL_ITEM_FORMATS)
+)
+
+function kindOf(item: unknown): string | undefined {
+ return item && typeof item === 'object' && 'kind' in item && typeof item.kind === 'string'
+ ? item.kind
+ : undefined
+}
+
+/**
+ * What is wrong with each submitted item before the schema sees it: an unknown kind, or a field
+ * that kind does not take. Why reject extra fields: the schema would drop them silently, and the
+ * dispatcher would believe its intent (say, a subtask's workspace) was kept.
+ */
+export function localTaskProposalItemProblems(items: readonly unknown[]): string[] {
+ return items.flatMap((item, at) => {
+ if (!item || typeof item !== 'object') {
+ return [`item ${at}: not an object`]
+ }
+ const kind = kindOf(item) ?? ''
+ const fields = FIELDS_BY_KIND.get(kind)
+ if (!fields) {
+ return [`item ${at}: kind must be one of ${[...FIELDS_BY_KIND.keys()].join(', ')}`]
+ }
+ const unknown = Object.keys(item).filter((key) => !fields.has(key))
+ return unknown.length > 0
+ ? [
+ `item ${at} (${kind}): unknown field ${unknown.join(', ')}. Write it as ${FORMATS.get(kind)}`
+ ]
+ : []
+ })
+}
+
+/** The format line of each kind among the failing items, so one retry can get it right. */
+export function localTaskProposalFormatHints(
+ items: readonly unknown[],
+ failingIndexes: ReadonlySet
+): string[] {
+ const kinds = new Set([...failingIndexes].flatMap((at) => kindOf(items[at]) ?? []))
+ return [...kinds].flatMap((kind) => {
+ const format = FORMATS.get(kind)
+ return format ? [`${kind}: ${format}`] : []
+ })
+}
diff --git a/src/main/ipc/desktop-runtime-sender-lifecycle.test.ts b/src/main/ipc/desktop-runtime-sender-lifecycle.test.ts
new file mode 100644
index 000000000000..06ee2119fb54
--- /dev/null
+++ b/src/main/ipc/desktop-runtime-sender-lifecycle.test.ts
@@ -0,0 +1,111 @@
+import { EventEmitter } from 'node:events'
+import { describe, expect, it } from 'vitest'
+import SyncDatabase from '../sqlite/sync-database'
+import { LocalTasksDatabase } from '../local-tasks/local-tasks-database'
+import { LocalTasksService } from '../local-tasks/local-tasks-service'
+import { RuntimeSubscriptionRegistry } from '../runtime/runtime-subscription-registry'
+import { DesktopRuntimeSenderLifecycle } from './desktop-runtime-sender-lifecycle'
+
+void SyncDatabase
+
+function setup() {
+ const subscriptions = new RuntimeSubscriptionRegistry()
+ const registered: string[] = []
+ const runtime = {
+ registerSubscriptionCleanup(id: string, cleanup: () => void, connectionId?: string) {
+ registered.push(id)
+ subscriptions.register(id, cleanup, connectionId)
+ },
+ cleanupSubscription: (id: string) => subscriptions.cleanup(id),
+ cleanupSubscriptionsForConnection: (connectionId: string) =>
+ subscriptions.cleanupForConnection(connectionId)
+ }
+ const sender = Object.assign(new EventEmitter(), { id: 7 })
+ const lifecycle = new DesktopRuntimeSenderLifecycle(runtime)
+ const db = LocalTasksDatabase.openTransient()
+ const service = new LocalTasksService(db, () => 1_000)
+ const project = service.createProject({
+ clientOperationId: 'project',
+ name: 'Shop',
+ prefix: 'shop'
+ })
+ if (!project.ok) {
+ throw new Error(project.code)
+ }
+ service.registerDispatcher({ clientOperationId: 'register', paneKey: 'tab:pane' })
+ const created = service.createProposal({
+ clientOperationId: 'proposal',
+ paneKey: 'tab:pane',
+ items: [{ kind: 'start', title: 'Task', prompt: 'Work', target: { repoId: 'repo' } }]
+ })
+ if (!created.ok) {
+ throw new Error(created.code)
+ }
+ service.decideProposal(
+ { clientOperationId: 'decide', proposalId: created.proposal.id, decision: 'confirmed' },
+ 'desktop'
+ )
+ return {
+ db,
+ service,
+ lifecycle,
+ sender,
+ runtime,
+ subscriptions,
+ registered,
+ proposalId: created.proposal.id
+ }
+}
+
+describe('desktop proposal claim document lifetime', () => {
+ it.each(['did-navigate', 'render-process-gone', 'destroyed'])(
+ 'rejects a claim invoked after %s retired its connection',
+ async (event) => {
+ const { db, service, lifecycle, sender, runtime, registered, proposalId } = setup()
+ try {
+ const scope = lifecycle.callScopeFor(sender)
+ let resume: (() => void) | undefined
+ const admission = new Promise((resolve) => {
+ resume = resolve
+ })
+ const pending = admission.then(() =>
+ service.claimProposal(
+ { clientOperationId: 'claim', proposalId },
+ scope.connectionId,
+ runtime,
+ scope.connectionSignal
+ )
+ )
+ sender.emit(event)
+ expect(scope.connectionSignal.aborted).toBe(true)
+ resume?.()
+ expect(await pending).toMatchObject({ ok: false, code: 'stale' })
+ expect(registered).toEqual([])
+ expect(service.dispatcher.proposalResult(proposalId)).toMatchObject({
+ ok: true,
+ proposal: { status: 'confirmed' }
+ })
+ } finally {
+ db.close()
+ }
+ }
+ )
+
+ it('releases an active claim before its library closes', async () => {
+ const { db, service, lifecycle, sender, runtime, subscriptions, proposalId } = setup()
+ const scope = lifecycle.callScopeFor(sender)
+ expect(
+ service.claimProposal(
+ { clientOperationId: 'claim', proposalId },
+ scope.connectionId,
+ runtime,
+ scope.connectionSignal
+ )
+ ).toMatchObject({ ok: true, proposal: { executionActive: true } })
+ expect(() => db.close()).not.toThrow()
+ await expect(
+ subscriptions.cleanupAndWait(`local-task-proposal-owner:${proposalId}`)
+ ).resolves.toBeUndefined()
+ sender.emit('destroyed')
+ })
+})
diff --git a/src/main/ipc/desktop-runtime-sender-lifecycle.ts b/src/main/ipc/desktop-runtime-sender-lifecycle.ts
index b00ab4711fb1..5906d29bac77 100644
--- a/src/main/ipc/desktop-runtime-sender-lifecycle.ts
+++ b/src/main/ipc/desktop-runtime-sender-lifecycle.ts
@@ -1,9 +1,15 @@
-import type { WebContents } from 'electron'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
+type RuntimeSender = {
+ readonly id: number
+ on(event: 'did-navigate' | 'render-process-gone', listener: () => void): void
+ once(event: 'destroyed', listener: () => void): void
+}
+
type SenderState = {
connectionId: string
- sender: WebContents
+ connectionAbort: AbortController
+ sender: RuntimeSender
subscriptions: Map
}
@@ -15,20 +21,25 @@ export class DesktopRuntimeSenderLifecycle {
constructor(private readonly runtime: CleanupRuntime) {}
- connectionIdFor(sender: WebContents): string {
+ connectionIdFor(sender: RuntimeSender): string {
return this.stateFor(sender).connectionId
}
- subscriptionsFor(sender: WebContents): Map {
+ callScopeFor(sender: RuntimeSender): { connectionId: string; connectionSignal: AbortSignal } {
+ const state = this.stateFor(sender)
+ return { connectionId: state.connectionId, connectionSignal: state.connectionAbort.signal }
+ }
+
+ subscriptionsFor(sender: RuntimeSender): Map {
return this.stateFor(sender).subscriptions
}
- existingSubscriptionsFor(sender: WebContents): Map | null {
+ existingSubscriptionsFor(sender: RuntimeSender): Map | null {
const state = this.senders.get(sender.id)
return state?.sender === sender ? state.subscriptions : null
}
- private stateFor(sender: WebContents): SenderState {
+ private stateFor(sender: RuntimeSender): SenderState {
const existing = this.senders.get(sender.id)
if (existing?.sender === sender) {
return existing
@@ -38,6 +49,7 @@ export class DesktopRuntimeSenderLifecycle {
}
const state: SenderState = {
connectionId: this.mintConnectionId(sender.id),
+ connectionAbort: new AbortController(),
sender,
subscriptions: new Map()
}
@@ -54,10 +66,12 @@ export class DesktopRuntimeSenderLifecycle {
return
}
const retiredConnectionId = state.connectionId
+ state.connectionAbort.abort()
if (destroyed) {
this.senders.delete(state.sender.id)
} else {
state.connectionId = this.mintConnectionId(state.sender.id)
+ state.connectionAbort = new AbortController()
}
for (const controller of state.subscriptions.values()) {
controller.abort()
diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts
index f11318a9cfe1..301f01980a57 100644
--- a/src/main/ipc/runtime.ts
+++ b/src/main/ipc/runtime.ts
@@ -73,6 +73,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
if (event.senderFrame !== event.sender.mainFrame) {
throw new Error('Runtime RPC call must originate from the current main frame')
}
+ const callScope = desktopSenders.callScopeFor(event.sender)
return new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }).dispatch(
{
id: 'desktop-ipc',
@@ -84,7 +85,16 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
clientId: 'desktop-renderer',
caller: DESKTOP_RPC_CALLER,
clientKind: 'runtime',
- connectionId: desktopSenders.connectionIdFor(event.sender),
+ connectionId: callScope.connectionId,
+ connectionSignal: callScope.connectionSignal,
+ beforeInvoke: () => {
+ if (
+ callScope.connectionSignal.aborted ||
+ event.senderFrame !== event.sender.mainFrame
+ ) {
+ throw new Error('Runtime RPC caller document retired before invocation')
+ }
+ },
clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES
}
)
diff --git a/src/main/local-tasks/local-task-attention-monitor.ts b/src/main/local-tasks/local-task-attention-monitor.ts
index 051aed4fbde9..ccab26ff97c6 100644
--- a/src/main/local-tasks/local-task-attention-monitor.ts
+++ b/src/main/local-tasks/local-task-attention-monitor.ts
@@ -15,11 +15,6 @@ import type {
LocalTaskSessionState
} from '../../shared/local-tasks/local-task-link-types'
import type { LocalTaskChangeScope } from '../../shared/local-tasks/local-task-types'
-import { parsePaneKey } from '../../shared/stable-pane-id'
-import {
- structuredAgentSessionPaneKey,
- structuredAgentSessionTabId
-} from '../../shared/agent-session/view/structured-agent-session-projection'
import {
nextLocalTaskSnoozeExpiry,
readLocalTaskAttentionStates,
@@ -29,7 +24,13 @@ import {
import type { LocalTaskHostSessionSource } from './local-task-host-session-source'
import { hasLocalTaskSessionLinks, readLocalTaskLinks } from './local-task-link-rows'
import { LocalTaskPaneMemory } from './local-task-pane-memory'
+import { findLocalTaskPane, latestLocalTaskRow } from './local-task-host-pane-match'
import { claimLocalTaskSessionPane } from './local-task-session-links'
+import {
+ readLocalTaskTurnEnd,
+ writeLocalTaskTurnEnds,
+ type LocalTaskTurnEnd
+} from './local-task-turn-records'
import type { LocalTasksDatabase } from './local-tasks-database'
// Why: hook events arrive per tool call; one judgment per window bounds the work and the notices.
@@ -45,6 +46,7 @@ type Judgment = {
items: LocalTaskAttentionItem[]
staleAt: number | null
claims: { sessionLinkId: string; paneKey: string }[]
+ turnEnds: LocalTaskTurnEnd[]
/** Each session's own pane, claimed or found now. */
panes: Map
}
@@ -161,6 +163,9 @@ export class LocalTaskAttentionMonitor {
console.error('[local-tasks] claiming session panes failed', error)
}
}
+ if (judgment.turnEnds.length > 0 && !this.db.readOnly) {
+ this.db.transaction(() => writeLocalTaskTurnEnds(this.db, judgment.turnEnds, now))
+ }
const content = JSON.stringify([judgment.sessions, judgment.items])
if (this.published !== null && content !== this.published) {
this.onChange(['attention'])
@@ -179,12 +184,13 @@ export class LocalTaskAttentionMonitor {
items: [],
staleAt: null,
claims: [],
+ turnEnds: [],
panes: new Map()
}
let paneKeys: readonly string[] | null = null
const knownPaneKeys = (): readonly string[] => (paneKeys ??= this.source.paneKeys())
for (const link of [...links].sort((a, b) => a.id.localeCompare(b.id))) {
- const paneKey = link.paneKey ?? this.findPane(link, knownPaneKeys, claimedPanes)
+ const paneKey = link.paneKey ?? findLocalTaskPane(link, knownPaneKeys, claimedPanes)
if (paneKey && !link.paneKey) {
claimedPanes.add(paneKey)
judgment.claims.push({ sessionLinkId: link.id, paneKey })
@@ -205,6 +211,7 @@ export class LocalTaskAttentionMonitor {
judgment: Judgment
): void {
const prose = paneKey ? this.panes.proseOf(paneKey) : null
+ const saved = readLocalTaskTurnEnd(this.db, link.id)
const progress = (
state: LocalTaskSessionState,
row: LocalTaskStatusRow | null = null,
@@ -212,7 +219,7 @@ export class LocalTaskAttentionMonitor {
): LocalTaskSessionProgress => ({
taskSessionId: link.id,
state,
- lastMessage: localTaskRowWords(row, prose),
+ lastMessage: localTaskRowWords(row, prose) || saved?.body || null,
stateSince: row?.stateStartedAt ?? null,
unread
})
@@ -225,7 +232,7 @@ export class LocalTaskAttentionMonitor {
judgment.sessions.push(progress('unknown'))
return
}
- const row = latestRow(this.source.rowsForPane(paneKey))
+ const row = latestLocalTaskRow(this.source.rowsForPane(paneKey))
if (!row) {
// Why: only a pane that reported this process can be said to have stopped; a quiet one may
// be an agent without status hooks, or a pane the window has not reattached yet.
@@ -241,9 +248,18 @@ export class LocalTaskAttentionMonitor {
const state = localTaskRowState(row, this.source.terminalLive(paneKey), now)
const key = localTaskAttentionKey(row.paneKey, row.stateStartedAt)
const mark = marks.get(key)
+ const words = localTaskRowWords(row, this.panes.proseOf(paneKey))
const finishedTurn =
state === 'done' && row.sessionBoundary !== true && row.restoredUnconfirmed !== true
const unread = finishedTurn && (mark?.seenAt ?? null) === null
+ if (finishedTurn && (saved?.key !== key || (words !== null && words !== saved.body))) {
+ judgment.turnEnds.push({
+ sessionLinkId: link.id,
+ taskId: link.taskId,
+ key,
+ body: words || saved?.body || ''
+ })
+ }
const staleAt = localTaskRowStaleAt(row)
if (staleAt !== null && staleAt > now) {
judgment.staleAt = Math.min(judgment.staleAt ?? staleAt, staleAt)
@@ -255,7 +271,7 @@ export class LocalTaskAttentionMonitor {
row: LIVE_STATES.includes(state) ? row : null,
structured: link.surface === 'chat' || row.structuredHost !== undefined,
unread: finishedTurn,
- lastMessage: localTaskRowWords(row, this.panes.proseOf(paneKey))
+ lastMessage: words
})
if (!item) {
return
@@ -267,27 +283,6 @@ export class LocalTaskAttentionMonitor {
}
}
- /** The host's own pane for a session recorded before its pane reported. */
- private findPane(
- link: LocalTaskSessionLink,
- knownPaneKeys: () => readonly string[],
- claimedPanes: ReadonlySet
- ): string | null {
- if (link.surface === 'chat') {
- return link.sessionId
- ? structuredAgentSessionPaneKey(structuredAgentSessionTabId(link.sessionId), link.sessionId)
- : null
- }
- if (!link.tabId) {
- return null
- }
- // Why only a lone candidate: in a split tab any reporting pane may be another agent's.
- const candidates = knownPaneKeys().filter(
- (paneKey) => parsePaneKey(paneKey)?.tabId === link.tabId && !claimedPanes.has(paneKey)
- )
- return candidates.length === 1 ? candidates[0] : null
- }
-
private armDeadline(judgment: Judgment, now: number): void {
if (this.deadlineTimer) {
clearTimeout(this.deadlineTimer)
@@ -325,14 +320,3 @@ export class LocalTaskAttentionMonitor {
this.deadlineTimer = null
}
}
-
-/** A structured session can hold several subjects on one pane; the newest report speaks. */
-function latestRow(rows: readonly LocalTaskStatusRow[]): LocalTaskStatusRow | null {
- let latest: LocalTaskStatusRow | null = null
- for (const row of rows) {
- if (!latest || row.receivedAt > latest.receivedAt) {
- latest = row
- }
- }
- return latest
-}
diff --git a/src/main/local-tasks/local-task-criteria-fold.ts b/src/main/local-tasks/local-task-criteria-fold.ts
new file mode 100644
index 000000000000..62798bfa8d6b
--- /dev/null
+++ b/src/main/local-tasks/local-task-criteria-fold.ts
@@ -0,0 +1,66 @@
+import type SyncDatabase from '../sqlite/sync-database'
+import { rowString } from './local-tasks-sqlite-row'
+
+const MARKER = 'criteriaFoldedIntoDescription'
+
+export function descriptionWithLegacyCriteria(
+ description: string,
+ criteria: readonly string[]
+): string {
+ const items = criteria.filter((item) => item.trim().length > 0)
+ if (items.length === 0) {
+ return description
+ }
+ const section = `完成标准\n${items.map((item) => `- ${item}`).join('\n')}`
+ return description.endsWith(section)
+ ? description
+ : `${description.trimEnd()}${description.trim() ? '\n\n' : ''}${section}`
+}
+
+export function descriptionWithoutTrackedLegacyCriteria(
+ description: string,
+ criteria: readonly string[]
+): string {
+ const section = descriptionWithLegacyCriteria('', criteria)
+ if (!section) {
+ return description
+ }
+ if (description === section) {
+ return ''
+ }
+ return description.endsWith(`\n\n${section}`)
+ ? description.slice(0, -section.length - 2)
+ : description
+}
+
+export function readTrackedLegacyCriteria(text: string): string[] {
+ try {
+ const value: unknown = JSON.parse(text)
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : []
+ } catch {
+ return []
+ }
+}
+
+/** Preserve old criteria once in the task description without changing the database version. */
+export function foldLocalTaskCriteriaIntoDescription(database: SyncDatabase): void {
+ if (database.prepare('SELECT 1 FROM local_task_preferences WHERE key = ?').get(MARKER)) {
+ return
+ }
+ const rows = database
+ .prepare("SELECT id, description, criteria FROM local_tasks WHERE criteria != '[]'")
+ .all()
+ const update = database.prepare(
+ 'UPDATE local_tasks SET description = ?, criteria = ?, revision = revision + 1 WHERE id = ?'
+ )
+ for (const row of rows) {
+ const items = readTrackedLegacyCriteria(rowString(row, 'criteria'))
+ const description = rowString(row, 'description')
+ const folded = descriptionWithLegacyCriteria(description, items)
+ update.run(folded, folded === description ? '[]' : JSON.stringify(items), rowString(row, 'id'))
+ }
+ if (rows.length > 0) {
+ database.prepare('UPDATE local_task_meta SET revision = revision + 1 WHERE singleton = 1').run()
+ }
+ database.prepare('INSERT INTO local_task_preferences (key, value) VALUES (?, ?)').run(MARKER, '1')
+}
diff --git a/src/main/local-tasks/local-task-dispatcher-inbox.ts b/src/main/local-tasks/local-task-dispatcher-inbox.ts
new file mode 100644
index 000000000000..7d9631f0063d
--- /dev/null
+++ b/src/main/local-tasks/local-task-dispatcher-inbox.ts
@@ -0,0 +1,245 @@
+import type {
+ LocalTaskInbox,
+ LocalTaskInboxItem
+} from '../../shared/local-tasks/local-task-dispatcher-types'
+import {
+ describeLocalTaskInboxEvent,
+ inboxLine,
+ LOCAL_TASK_INBOX_EVENT_KINDS
+} from '../../shared/local-tasks/local-task-inbox-copy'
+import type { LocalTaskProgressSnapshot } from '../../shared/local-tasks/local-task-link-types'
+import type { LocalTask, LocalTaskResult } from '../../shared/local-tasks/local-task-types'
+import type { SqliteRow } from '../sqlite/sqlite-statement'
+import type { LocalTasksDatabase } from './local-tasks-database'
+import { readLocalTaskProject } from './local-task-project-store'
+import { describeSettledProposal, unreportedLocalTaskProposals } from './local-task-proposal-rows'
+import { readLocalTask } from './local-task-store'
+import { reportForwardedFacts, type ForwardedPage } from './local-task-dispatcher-remote'
+import { rowJsonObject, rowNullableString, rowNumber, rowString } from './local-tasks-sqlite-row'
+
+const INBOX_EVENT_KINDS = LOCAL_TASK_INBOX_EVENT_KINDS.map((kind) => `'${kind}'`).join(', ')
+
+const ATTENTION_WORDS: Record = {
+ approval: 'waits for the user to approve',
+ question: 'waits for the user to answer',
+ blocked: 'is blocked',
+ 'unread-done': 'finished a round the user has not seen'
+}
+
+export type LocalTaskInboxCursor = {
+ eventAt: number
+ eventRowid: number
+ attention: string[]
+ proposals: string[]
+ /** The last forwarded fact from another library this page covered; 0 for none. */
+ factRowid: number
+}
+
+export function encodeInboxCursor(cursor: LocalTaskInboxCursor): string {
+ return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url')
+}
+
+export function decodeInboxCursor(text: string): LocalTaskInboxCursor | null {
+ try {
+ const value: unknown = JSON.parse(Buffer.from(text, 'base64url').toString('utf8'))
+ if (!value || typeof value !== 'object') {
+ return null
+ }
+ const record: Record = Object.fromEntries(Object.entries(value))
+ const { eventAt, eventRowid, attention, proposals, factRowid } = record
+ const strings = (list: unknown): list is string[] =>
+ Array.isArray(list) && list.every((entry) => typeof entry === 'string')
+ if (
+ typeof eventAt !== 'number' ||
+ typeof eventRowid !== 'number' ||
+ !strings(attention) ||
+ !strings(proposals)
+ ) {
+ return null
+ }
+ return {
+ eventAt,
+ eventRowid,
+ attention,
+ proposals,
+ factRowid: typeof factRowid === 'number' ? factRowid : 0
+ }
+ } catch {
+ return null
+ }
+}
+
+export function parseStoredKeys(text: string | null): string[] {
+ if (!text) {
+ return []
+ }
+ try {
+ const value: unknown = JSON.parse(text)
+ return Array.isArray(value)
+ ? value.filter((entry): entry is string => typeof entry === 'string')
+ : []
+ } catch {
+ return []
+ }
+}
+
+const oneLine = inboxLine
+
+/** What changed on a conversation's watched tasks since its last ack; reading changes nothing. */
+export function readLocalTaskInbox(
+ db: LocalTasksDatabase,
+ conversation: SqliteRow,
+ watched: Set,
+ attention: LocalTaskProgressSnapshot,
+ limit: number,
+ forwarded: ForwardedPage,
+ remoteWatchCount: number
+): LocalTaskInbox {
+ const conversationId = rowString(conversation, 'id')
+ const tasks = new Map()
+ const taskOf = (taskId: string): LocalTask | null => {
+ if (!tasks.has(taskId)) {
+ tasks.set(taskId, readLocalTask(db, taskId))
+ }
+ return tasks.get(taskId) ?? null
+ }
+ const after = {
+ at: rowNumber(conversation, 'inbox_event_at'),
+ rowid: rowNumber(conversation, 'inbox_event_rowid')
+ }
+ // Why the (time, rowid) pair: a deleted newest row frees its rowid, but not its time.
+ const events = db
+ .prepare(
+ `SELECT e.rowid AS seq, e.task_id, e.kind, e.source, e.body, e.payload, e.created_at
+ FROM local_task_events e JOIN local_task_conversation_watches w
+ ON w.task_id = e.task_id AND w.conversation_id = ?
+ WHERE e.kind IN (${INBOX_EVENT_KINDS})
+ AND (e.created_at > ? OR (e.created_at = ? AND e.rowid > ?))
+ ORDER BY e.created_at, e.rowid LIMIT ?`
+ )
+ .all(conversationId, after.at, after.at, after.rowid, limit + 1)
+ const shown = events.slice(0, limit)
+ const items: LocalTaskInboxItem[] = shown.flatMap(
+ (event) => eventItem(db, event, taskOf(rowString(event, 'task_id'))) ?? []
+ )
+ const last = shown.at(-1)
+ const acked = new Set(parseStoredKeys(rowNullableString(conversation, 'acked_attention')))
+ const waiting = attention.items.filter((item) => watched.has(item.taskId) && !acked.has(item.key))
+ for (const item of waiting) {
+ const task = taskOf(item.taskId)
+ const said = item.lastMessage ? ` — ${item.lastMessage}` : ''
+ items.push({
+ kind: 'needs-you',
+ at: item.since,
+ taskId: item.taskId,
+ taskRef: task?.ref ?? null,
+ summary: oneLine(
+ `${task?.title ?? 'A task'}: ${item.agent ?? 'a session'} ${ATTENTION_WORDS[item.kind] ?? 'waits'}${said}`
+ )
+ })
+ }
+ const settled = unreportedLocalTaskProposals(db, conversationId)
+ for (const proposal of settled) {
+ items.push({
+ kind: 'proposal',
+ at: proposal.decidedAt ?? proposal.createdAt,
+ taskId: null,
+ taskRef: null,
+ summary: oneLine(describeSettledProposal(proposal))
+ })
+ }
+ items.push(...forwarded.items)
+ items.sort((a, b) => a.at - b.at)
+ return {
+ conversationId,
+ items,
+ cursor: encodeInboxCursor({
+ eventAt: last ? rowNumber(last, 'created_at') : after.at,
+ eventRowid: last ? rowNumber(last, 'seq') : after.rowid,
+ attention: waiting.map((item) => item.key),
+ proposals: settled.map((proposal) => proposal.id),
+ factRowid: forwarded.lastRowid
+ }),
+ more: events.length > limit || forwarded.more,
+ watchedTaskCount: watched.size + remoteWatchCount
+ }
+}
+
+function eventItem(
+ db: LocalTasksDatabase,
+ event: SqliteRow,
+ task: LocalTask | null
+): LocalTaskInboxItem | null {
+ const project = task ? readLocalTaskProject(db, task.projectId) : null
+ const copy = describeLocalTaskInboxEvent(
+ {
+ kind: rowString(event, 'kind'),
+ source: rowString(event, 'source'),
+ body: rowNullableString(event, 'body'),
+ payload: rowJsonObject(event, 'payload')
+ },
+ task,
+ (statusId) => project?.statuses.find((status) => status.id === statusId)?.category ?? null
+ )
+ if (!copy) {
+ return null
+ }
+ return {
+ kind: copy.kind,
+ at: rowNumber(event, 'created_at'),
+ taskId: rowString(event, 'task_id'),
+ taskRef: task?.ref ?? null,
+ summary: copy.summary,
+ ...(copy.needsYou ? { needsYou: copy.needsYou } : {})
+ }
+}
+
+/**
+ * Moves a conversation's inbox past what the cursor covered: events, needs-you items still being
+ * judged, settled proposals and forwarded facts. Tasks that finished are no longer followed.
+ */
+export function ackLocalTaskInbox(
+ db: LocalTasksDatabase,
+ conversation: SqliteRow,
+ cursorText: string,
+ liveAttention: ReadonlySet,
+ now: number
+): LocalTaskResult]