diff --git a/frontend-v2/src/components/workspace-panel/source.ts b/frontend-v2/src/components/workspace-panel/source.ts index c9db45c63..62edd87ed 100644 --- a/frontend-v2/src/components/workspace-panel/source.ts +++ b/frontend-v2/src/components/workspace-panel/source.ts @@ -4,6 +4,10 @@ import { getChatWorkspaceDiff, getChatWorkspaceEntries, getChatWorkspaceFile, + getTaskRunWorkspaceChanges, + getTaskRunWorkspaceDiff, + getTaskRunWorkspaceEntries, + getTaskRunWorkspaceFile, getWorkflowRunWorkspaceChanges, getWorkflowRunWorkspaceDiff, getWorkflowRunWorkspaceEntries, @@ -15,10 +19,11 @@ import { type WorkspaceFile, } from '@/lib/workspace-api/generated' -/** Whose checkout the panel shows: a conversation's, or the one a workflow run's Agents share. */ +/** Whose checkout the panel shows: a conversation's, a task run's, or the one a workflow run's Agents share. */ export type WorkspaceSource = | { kind: 'chat'; tenantId: string; conversationId: string } | { kind: 'run'; tenantId: string; runId: string } + | { kind: 'task'; tenantId: string; taskId: string; runId: string } /** The four read-only views of one checkout, and the query-key prefix every one of them shares. */ export interface WorkspaceReader { @@ -41,6 +46,16 @@ export function workspaceReader(source: WorkspaceSource): WorkspaceReader { diff: (path) => getChatWorkspaceDiff(t, c, { path }), } } + if (source.kind === 'task') { + const { tenantId: t, taskId: k, runId: r } = source + return { + key: ['workspace', 'task', t, k, r], + entries: (path) => getTaskRunWorkspaceEntries(t, k, r, path ? { path } : undefined), + file: (path) => getTaskRunWorkspaceFile(t, k, r, { path }), + changes: () => getTaskRunWorkspaceChanges(t, k, r), + diff: (path) => getTaskRunWorkspaceDiff(t, k, r, { path }), + } + } const { tenantId: t, runId: r } = source const demo = cdaseWorkspaceReader(t, r) if (demo) return demo diff --git a/frontend-v2/src/components/workspace-panel/use-panel.ts b/frontend-v2/src/components/workspace-panel/use-panel.ts index 56cea4a47..b788f385a 100644 --- a/frontend-v2/src/components/workspace-panel/use-panel.ts +++ b/frontend-v2/src/components/workspace-panel/use-panel.ts @@ -38,3 +38,6 @@ export function useWorkspacePanel() { ) return { open, setOpen, request, navigation } } + +/** A page's workspace panel, for a host that keeps the toggle outside the view showing the panel. */ +export type WorkspacePanelState = ReturnType diff --git a/frontend-v2/src/components/workspace-panel/workspace-panel.tsx b/frontend-v2/src/components/workspace-panel/workspace-panel.tsx index 891a0d284..308821369 100644 --- a/frontend-v2/src/components/workspace-panel/workspace-panel.tsx +++ b/frontend-v2/src/components/workspace-panel/workspace-panel.tsx @@ -28,11 +28,14 @@ export interface OpenRequest { */ export function WorkspacePanel({ source, + caption, revision, request, onClose, }: { source: WorkspaceSource + /** Names whose checkout this is, on a page where several could be shown. */ + caption?: string revision: string request?: OpenRequest onClose: () => void @@ -88,6 +91,7 @@ export function WorkspacePanel({ ))} + {caption && {caption}}
- {run.status === 'pending' && ( + {!readOnly && run.status === 'pending' && ( )} - {(run.status === 'running' || run.status === 'awaiting_input') && ( + {!readOnly && (run.status === 'running' || run.status === 'awaiting_input') && ( )} - {finished && ( + {!readOnly && finished && ( )} - {run.status === 'succeeded' && run.finalOutput && ( + {!readOnly && run.status === 'succeeded' && run.finalOutput && ( )} - workspace.setOpen(!workspace.open)} /> + {!hostedWorkspace && workspace.setOpen(!workspace.open)} />} workspace.setOpen(false)} /> + !hostedWorkspace && workspace.open && workspace.setOpen(false)} /> } >
- {run.status === 'pending' && viewMode === 'overview' && ( + {!readOnly && run.status === 'pending' && viewMode === 'overview' && (

还没开始:点右上角「启动」,填写输入后运行

diff --git a/frontend-v2/src/features/chat/workflow-run/run-tenant.ts b/frontend-v2/src/features/chat/workflow-run/run-tenant.ts new file mode 100644 index 000000000..f71eac72e --- /dev/null +++ b/frontend-v2/src/features/chat/workflow-run/run-tenant.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from 'react' +import { useChatStore } from '../store' + +/** The tenant of a run view that a page outside chat hosts; chat itself leaves it unset. */ +export const RunTenantContext = createContext(undefined) + +/** The tenant a run view reads: its hosting page's, otherwise the one chat is connected to. */ +export function useRunTenant(): string { + const hosted = useContext(RunTenantContext) + const connected = useChatStore((s) => s.tenantId) + return hosted ?? connected ?? '' +} diff --git a/frontend-v2/src/features/chat/workflow-run/translations.ts b/frontend-v2/src/features/chat/workflow-run/translations.ts index 19242651f..856a9b08d 100644 --- a/frontend-v2/src/features/chat/workflow-run/translations.ts +++ b/frontend-v2/src/features/chat/workflow-run/translations.ts @@ -181,7 +181,7 @@ export const workflowRunTranslations = { "workflowRun.result.title.failed": "运行失败", "workflowRun.result.title.cancelled": "已取消", "workflowRun.result.body.succeeded": - "本轮工作流已成功结束。可在路径中回顾各步骤,或从顶栏再跑一次。", + "本轮工作流已成功结束。可在路径中回顾各步骤。", "workflowRun.result.body.failed": "运行在某个步骤失败后结束。打开路径查看失败节点与详情。", "workflowRun.result.body.cancelled": @@ -189,7 +189,7 @@ export const workflowRunTranslations = { "workflowRun.result.showOverview": "查看全图", "workflowRun.result.openArtifacts": "打开最近成果", "workflowRun.result.pathHint": - "点路径上的「结果」或步骤可切换回顾;再跑一次请用顶栏按钮。", + "点路径上的「结果」或步骤可切换回顾。", "workflowRun.result.pathChip": "结果", "workflowRun.result.finishedToastTitle": "运行已结束", "workflowRun.result.finishedToastDescription": "可切到舞台查看结果摘要。", diff --git a/frontend-v2/src/features/collaboration/README.md b/frontend-v2/src/features/collaboration/README.md index 066e42f61..6d8a0ce5f 100644 --- a/frontend-v2/src/features/collaboration/README.md +++ b/frontend-v2/src/features/collaboration/README.md @@ -7,7 +7,7 @@ 协作页有自己的侧栏(`page.tsx`),右侧是子路由(`index.ts`): - `/collaboration`:任务看板(默认)或列表(`tasks-page.tsx`)。看板占满窗口高度,每列各自滚动。侧栏的视图「我的任务 / 待我处理 / 全部任务」都是同一份全团队任务列表的筛选;页头的小队切换把任一视图收窄到一个小队,我所在的小队排在前面,选中后新建的任务默认指派给它,这只是筛选,不隔离可见范围。这些都写在 URL 里(`?view=`、`?squad=`、`?assignee=`、`?layout=list`),链接发给队友看到的是同一个视图。没有推送:有 Agent 在执行时列表每 3 秒重读,否则 15 秒。 -- `/collaboration/tasks/:taskId`:一个任务(`task-page.tsx`)。左边是「Agent 运行」与「讨论」两个页签和一个输入框,右边是任务信息、指派、执行和状态推进。 +- `/collaboration/tasks/:taskId`:一个任务(`task-page.tsx`)。左边是「Agent 运行」、运行过工作流时才有的「工作流」和「讨论」页签,下面一个输入框,右边是任务信息、指派、执行和状态推进。 - `/collaboration/squads`:旧地址,带着查询参数重定向到 `/settings/squads`。 协作里的一切都属于团队;个人的对话留在对话页。运行过程用 `src/components/agent-thread` 的线程视图,和对话页、工作流运行页是同一套。 @@ -15,10 +15,13 @@ ## 已经有的 - 任务列表显示状态、标题、项目、指派对象,以及谁在干活(某个 Agent 执行中、等待追问)。看板按「待处理 / 进行中 / 待验收 / 已完成」分列,「已取消」默认隐藏。把卡片拖到另一列,等同于点任务里对应的推进按钮:拖动时只有合法的下一步亮起,状态逐级推进,不能跳过待验收;Agent 正在执行的卡片拿不起来;指派给 Agent 的任务只能在任务里「开始处理」进入进行中,不能拖进去。松手后卡片立即移动,服务端拒绝(例如 `409 run_active`)时退回原列,并弹出提示说明原因,几秒后自动消失。拖动只支持指针,键盘用任务页的按钮。窄屏下看板变成单列。 -- **拆分**:大需求在任务页点「拆分」,选一个已发布的工作流(默认「需求拆分」)运行,任务的标题和描述是输入。跑完后输出的 `tasks` 变成这个任务的子任务:待处理、未指派、同一个项目,任务页列出它们,看板上子任务标着「拆分自 …」,大需求标着「N 个子任务」,Tech Lead 再把它们分给人和 Agent。拆分中按钮变成「拆分中…」,失败时任务页写明原因并可「重新拆分」。子任务不能再拆。工作流运行本身在工作流页的运行历史里。 +- **拆分**:大需求在任务页点「拆分」,选一个已发布的工作流(默认「需求拆分」)运行,任务的标题和描述是输入。跑完后输出的 `tasks` 变成这个任务的子任务:待处理、未指派、同一个项目,任务页列出它们,看板上子任务标着「拆分自 …」,大需求标着「N 个子任务」,Tech Lead 再把它们分给人和 Agent。拆分中按钮变成「拆分中…」,失败时任务页写明原因并可「重新拆分」。子任务不能再拆。工作流运行也在工作流页的运行历史里。 +- **工作流页签**:任务运行过工作流时出现,列出每次运行的工作流、状态、发起人、时间和结果,点一行在页签里打开,和对话页同一个运行视图(`?tab=workflows&workflow=<运行 id>`,链接发给队友看到同一次运行)。点「开始拆分」直接打开这次运行。这里只读:不能启动、停止或再跑一次,从这里开的运行不属于这个任务,页头的「工作区」看运行改了哪些文件;打开运行时输入框收起,子任务也默认折叠(可以展开),把高度让给画布;运行标题左边的返回按钮回到列表。工作流启动被拒绝的记录没有运行可打开,只写明原因。 - 任务指派给人、可执行的预装 Agent 或未归档的小队;指派给小队不挑具体的人:每个队员的「我的任务」都有它,队员还没评论或开始处理时它也在「待我处理」,卡片标出是哪个小队派来的。 - **执行**:指派给 Agent 的任务在右侧选模型后「开始处理」,用你的身份运行,任务进入进行中,一轮结束自动进入待验收;停止或服务重启后回到待处理。Agent 执行期间不能手动推进状态。已经有运行时按钮是「重新执行」,会结束空闲的旧会话。运行开始后和对话页一样显示准备阶段:列出全部阶段、当前第几步和已用时,首次输出后收起为总用时。项目第一次用某个 Agent 要先在 Node 里安装,这段时间算在「安装插件」里。 +- **工作区**:页头的「工作区」打开当前运行的文件和 diff,占掉右侧属性栏的位置,可以拖宽,关掉后属性栏恢复。每次 Agent 运行和每次工作流运行都有自己的目录,互相看不到改动,所以面板标明正在看哪次运行;没有运行在屏幕上时,它打开最新一次。 - **派给人的任务**:负责人本人可以在右侧「用 Agent 处理」:从自己能用的全部 Agent 与模型里选一个,用自己的身份开一次运行,之后和 Agent 任务一样(跑完进入待验收、可停止、只有发起人能追问)。其他人看不到这组控件,输入框只发评论;负责人不用 Agent 时照旧手动推进。 +- **用工作流处理(演示)**:`VITE_DEMO_MOCK=1` 时,负责人在右侧多一个「用工作流处理」:选工作流,编辑需求,启动后运行出现在工作流页签。运行是真实的;它和任务的关联、预填的需求都是 `src/lib/demo/task-workflows.ts` 的 mock,跑完由负责人手动提交验收。 - **派给小队的任务**:和派给人的任务一样,任一队员都能用自己能用的 Agent 与模型开运行;同一时间一个运行,只有发起人能追问,其他队员可以重新执行。队外的人可以评论,不能开运行。 - **运行过程**:全团队都能看每次运行的思考、工具调用和回答,按运行切换;运行结束的原因(停止、被重新执行替代、任务结束、重启中断)和最近一轮的结果写在运行上方。 - **讨论与 @**:能发给这个任务的 Agent 时(指派了 Agent、它没在执行、调用者是当前运行的发起人或还没有运行),输入框默认发给 Agent,评论同时全团队可见;关掉「发给 Agent」就只发团队评论;发不了时开关关闭并说明原因。输入 `@` 弹出工作区成员和 Agent:选成员是提及,被提及的人在回复前会在「待我处理」里看到这个任务,卡片标着谁 @ 了他;选这个任务的 Agent 等同打开「发给 Agent」。只有从菜单选中的对象算数,正文里手打的「@名字」不路由。 diff --git a/frontend-v2/src/features/collaboration/collaboration.css b/frontend-v2/src/features/collaboration/collaboration.css index cdd0ec900..cb01f1601 100644 --- a/frontend-v2/src/features/collaboration/collaboration.css +++ b/frontend-v2/src/features/collaboration/collaboration.css @@ -84,7 +84,8 @@ .mc-task-bar h2 { flex: 1; min-width: 0; font-size: 16px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mc-back { display: inline-flex; padding: 4px; border-radius: 6px; color: var(--mc-muted); } .mc-back:hover { background: var(--accent); color: var(--foreground); } -.mc-task-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 300px; } +.mc-task-body { flex: 1; height: 100%; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 300px; } +.mc-task-body.wide { grid-template-columns: minmax(0, 1fr); } .mc-task-main-col { display: flex; flex-direction: column; min-height: 0; min-width: 0; padding: 16px 24px; gap: 12px; } .mc-task-side-col { min-height: 0; overflow: auto; border-left: 1px solid var(--mc-line); background: var(--mc-surface); } .mc-side { display: flex; flex-direction: column; gap: 20px; padding: 16px 18px; } @@ -97,6 +98,13 @@ .mc-tabs button[aria-selected="true"] { color: var(--foreground); border-bottom-color: var(--brand); font-weight: 500; } .mc-tabs b { margin-left: 4px; font-weight: 500; font-size: 11px; color: var(--mc-muted); } .mc-tab-body { flex: 1; min-height: 240px; display: flex; flex-direction: column; overflow: auto; } +.mc-family-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 12px; } +.mc-family-toggle { flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; padding: 0; border: 0; background: none; color: var(--mc-muted); font: inherit; font-size: 12px; } +.mc-family-toggle:hover { color: var(--foreground); } +.mc-workflow-outcome { flex-shrink: 0; max-width: 40%; color: var(--mc-muted); font-size: 12px; text-align: right; overflow-wrap: anywhere; } +.mc-workflow-back { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; margin-left: -6px; border: 0; border-radius: 6px; background: none; color: var(--mc-muted); } +.mc-workflow-back:hover { color: var(--foreground); background: var(--mc-surface); } +.mc-workflow-run { flex: 1; min-height: 480px; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--mc-line); border-radius: 12px; } .mc-run-pane { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; } .mc-run-tabs { display: flex; gap: 6px; flex-wrap: wrap; } .mc-run-tab { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border: 1px solid var(--mc-line); border-radius: 999px; background: var(--background); font: inherit; font-size: 12px; color: var(--secondary-foreground); } diff --git a/frontend-v2/src/features/collaboration/task-decomposition.tsx b/frontend-v2/src/features/collaboration/task-decomposition.tsx index a82ddb82d..e52d07614 100644 --- a/frontend-v2/src/features/collaboration/task-decomposition.tsx +++ b/frontend-v2/src/features/collaboration/task-decomposition.tsx @@ -1,7 +1,7 @@ import { useState, type FormEvent } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router' -import { CircleAlert, CornerDownRight, ListTree, LoaderCircle } from 'lucide-react' +import { ChevronDown, ChevronRight, CircleAlert, CornerDownRight, ListTree, LoaderCircle } from 'lucide-react' import { Button } from '@/components/ui/button' import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' @@ -19,7 +19,8 @@ export function DecomposeDialog({ tenantId, task, onClose, onStarted }: { tenantId: string task: Task onClose: () => void - onStarted: () => void + /** The split started; `created` says which run is doing it. */ + onStarted: (created: TaskDecomposition) => void }) { const client = useQueryClient() const workflows = useQuery({ queryKey: ['workflows', tenantId], queryFn: () => listWorkflows(tenantId), enabled: !!tenantId }) @@ -33,7 +34,7 @@ export function DecomposeDialog({ tenantId, task, onClose, onStarted }: { onSuccess: created => { client.setQueryData(['task-decompositions', tenantId, task.id], (before?: { items: TaskDecomposition[] }) => ({ items: [created, ...(before?.items ?? []).filter(d => d.id !== created.id)] })) void client.invalidateQueries({ queryKey: ['task-decompositions', tenantId, task.id] }) - onStarted() + onStarted(created) }, onError: e => setError(decompositionMessageFor(e)), }) @@ -67,10 +68,13 @@ export function DecomposeDialog({ tenantId, task, onClose, onStarted }: { /** * The task's family on its page: for a big requirement, its latest decomposition and the subtasks - * it wrote; for a subtask, the requirement it came from. + * it wrote; for a subtask, the requirement it came from. The subtasks fold away, and start folded + * when `folded` says the page needs the room; a person's own choice then holds. */ -export function TaskFamily({ tenantId, task, latest, onRetry }: { tenantId: string; task: Task; latest?: TaskDecomposition; onRetry: () => void }) { +export function TaskFamily({ tenantId, task, latest, folded = false, onRetry }: { tenantId: string; task: Task; latest?: TaskDecomposition; folded?: boolean; onRetry: () => void }) { const list = useTaskList(tenantId) + const [picked, setPicked] = useState() + const open = picked ?? !folded const all = list.data?.items ?? [] if (task.parentId) { const parent = all.find(t => t.id === task.parentId) @@ -81,8 +85,13 @@ export function TaskFamily({ tenantId, task, latest, onRetry }: { tenantId: stri const subtasks = all.filter(t => t.parentId === task.id).sort((a, b) => b.createdAt.localeCompare(a.createdAt)) if (!latest && subtasks.length === 0) return null return
- {latest && } - {subtasks.length > 0 &&
    +
    + {latest && } + {subtasks.length > 0 && } +
    + {open && subtasks.length > 0 &&
      {subtasks.map(sub =>
    • {taskStatusLabel(sub.status)} {sub.title} diff --git a/frontend-v2/src/features/collaboration/task-detail.tsx b/frontend-v2/src/features/collaboration/task-detail.tsx index 54d2cb95b..d1c89d243 100644 --- a/frontend-v2/src/features/collaboration/task-detail.tsx +++ b/frontend-v2/src/features/collaboration/task-detail.tsx @@ -1,4 +1,4 @@ -import { Bot, CirclePlay, CircleStop, RotateCcw, Users } from 'lucide-react' +import { Bot, CirclePlay, CircleStop, RotateCcw, Users, Workflow } from 'lucide-react' import { Button } from '@/components/ui/button' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' import type { SquadSummary } from '@/lib/squads-api/generated' @@ -26,7 +26,7 @@ export interface RunControls { * One task's facts and the moves on it: who owns it, running its Agent, and the manual status * moves. While the Agent runs a turn, the run owns the status, so manual moves wait for it. */ -export function TaskDetailView({ task, me, inSquad, canClose, projectName, squads, busy, error, run, onStatus, onAssign }: { +export function TaskDetailView({ task, me, inSquad, canClose, projectName, squads, busy, error, run, onWorkflow, onStatus, onAssign }: { task: Task /** The caller's user id: a person's task runs an Agent only for that person. */ me: string @@ -43,6 +43,8 @@ export function TaskDetailView({ task, me, inSquad, canClose, projectName, squad /** Error from the last write attempt. */ error: string run: RunControls + /** Opens handling the task with a workflow; only the demo offers it, on a person's task. */ + onWorkflow?: () => void onStatus: (status: TaskStatusProperty) => void onAssign: (next: TaskAssigneeRef | null) => void }) { @@ -85,6 +87,9 @@ export function TaskDetailView({ task, me, inSquad, canClose, projectName, squad {run.hasRuns ? : } {run.starting ? '正在启动…' : run.hasRuns ? '重新执行' : personal ? '用 Agent 处理' : '开始处理'} + {personal && onWorkflow && } {task.activeRun && } diff --git a/frontend-v2/src/features/collaboration/task-page.test.tsx b/frontend-v2/src/features/collaboration/task-page.test.tsx index dd7cb9a38..a69da6b60 100644 --- a/frontend-v2/src/features/collaboration/task-page.test.tsx +++ b/frontend-v2/src/features/collaboration/task-page.test.tsx @@ -1,12 +1,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import type { ReactNode } from 'react' import { MemoryRouter, Route, Routes } from 'react-router' -import { beforeEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { UserContext } from '@/app/auth/user' import type { CollaborationTarget, User } from '@/api/generated.schemas' import { type Conversation, emptyConversation } from '@/components/agent-thread/model/types' import * as workflowsApi from '@/lib/workflows/api/generated' +import * as workspaceApi from '@/lib/workspace-api/generated' import * as api from './tasks-api/generated' import type { Task, TaskDecomposition, TaskRun, TaskTimeline } from './tasks-api/generated' import TaskPage from './task-page' @@ -30,7 +32,14 @@ vi.mock('./tasks-api/generated', () => ({ createTaskComment: vi.fn(), setTaskStatus: vi.fn(), setTaskAssignee: vi.fn(), updateTask: vi.fn(), listTasks: vi.fn(), listTaskDecompositions: vi.fn(), startTaskDecomposition: vi.fn(), })) -vi.mock('@/lib/workflows/api/generated', () => ({ listWorkflows: vi.fn() })) +vi.mock('@/lib/workflows/api/generated', () => ({ + listWorkflows: vi.fn(), listWorkflowRuns: vi.fn(), createWorkflowRun: vi.fn(), updateWorkflowRunInput: vi.fn(), startWorkflowRun: vi.fn(), +})) +vi.mock('@/lib/workspace-api/generated', () => ({ getTaskRunWorkspaceChanges: vi.fn(), getWorkflowRunWorkspaceChanges: vi.fn() })) +// The run view has its own tests; here it shows which run the page opened and how. +vi.mock('@/features/chat/workflow-run/run-pane', () => ({ + default: ({ runId, tenantId, readOnly, leading }: { runId: string; tenantId: string; readOnly: boolean; leading: ReactNode }) =>
      {leading}

      运行视图 {runId} {tenantId} {readOnly ? '只读' : '可写'}

      , +})) const me: User = { id: 'user-me', displayName: '演示用户', status: 'active', version: 1, createdAt: '2026-10-01T00:00:00Z', deletedAt: null } const agent = { type: 'agent' as const, id: 'agent-1', displayName: 'Claude Code', identifier: 'official/ora-space.claude' } @@ -249,8 +258,76 @@ it('splits a requirement with the 需求拆分 workflow and lists the subtasks i await waitFor(() => expect(api.startTaskDecomposition).toHaveBeenCalledWith('tenant', 'task-1', { workflowId: 'wf-split' }, expect.objectContaining({ headers: { 'Idempotency-Key': expect.any(String) } }))) expect(await screen.findByText(/「需求拆分」拆出了 2 个子任务/)).toBeInTheDocument() + // The run is open, so the subtasks stay folded until asked for. + expect(await screen.findByText('运行视图 wr-1 tenant 只读')).toBeInTheDocument() + expect(screen.queryByRole('list', { name: '子任务' })).not.toBeInTheDocument() + await userEvent.click(await screen.findByRole('button', { name: '展开子任务' })) const subtasks = await screen.findByRole('list', { name: '子任务' }) expect(within(subtasks).getAllByRole('link').map(a => a.textContent)).toEqual(['登录页', '注册页']) + await userEvent.click(screen.getByRole('button', { name: '收起子任务' })) + expect(screen.queryByRole('list', { name: '子任务' })).not.toBeInTheDocument() +}) + +it('keeps the subtasks open outside a run, and the workflow tab between the other two', async () => { + vi.mocked(api.getTask).mockResolvedValue(requirement) + vi.mocked(api.listTaskDecompositions).mockResolvedValue({ items: [decomposition({ status: 'succeeded', createdCount: 1 })] }) + vi.mocked(api.listTasks).mockResolvedValue({ nextCursor: '', items: [requirement, task({ id: 'sub-1', title: '登录页', parentId: 'task-1', assignee: null })] }) + renderPage() + expect(await screen.findByRole('list', { name: '子任务' })).toBeInTheDocument() + expect(screen.getAllByRole('tab', { name: /Agent 运行|工作流|讨论/ }).map(t => t.textContent?.replace(/\s*\d+$/, ''))).toEqual(['Agent 运行', '工作流', '讨论']) +}) + +it('opens the run as soon as a split starts, read-only, and goes back to the list', async () => { + vi.mocked(api.getTask).mockResolvedValue(requirement) + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue({ workflows: [{ id: 'wf-split', namespace: 'local', name: '需求拆分', publishedVersion: 'v1', createdAt: 0, updatedAt: 0 }] }) + let started = false + vi.mocked(api.startTaskDecomposition).mockImplementation(async () => { started = true; return decomposition() }) + vi.mocked(api.listTaskDecompositions).mockImplementation(async () => ({ items: started ? [decomposition()] : [] })) + renderPage() + await userEvent.click(await screen.findByRole('button', { name: '拆分' })) + expect(screen.queryByRole('tab', { name: /工作流/ })).not.toBeInTheDocument() + const dialog = await screen.findByRole('dialog') + await waitFor(() => expect(within(dialog).getByLabelText('工作流')).toHaveValue('wf-split')) + await userEvent.click(within(dialog).getByRole('button', { name: '开始拆分' })) + expect(await screen.findByText('运行视图 wr-1 tenant 只读')).toBeInTheDocument() + expect(screen.getByRole('tab', { name: '工作流 1' })).toHaveAttribute('aria-selected', 'true') + // The run takes the room the composer would. + expect(screen.queryByLabelText('评论或给 Agent 的消息')).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: '返回工作流列表' })) + expect(await screen.findByRole('list', { name: '工作流运行记录' })).toHaveTextContent('正在拆分') + expect(screen.getByLabelText('评论或给 Agent 的消息')).toBeInTheDocument() +}) + +it('lists every workflow the task ran and opens the one picked', async () => { + vi.mocked(api.getTask).mockResolvedValue(requirement) + vi.mocked(api.listTaskDecompositions).mockResolvedValue({ items: [ + decomposition({ id: 'split-3', workflowRunId: 'wr-3', status: 'succeeded', createdCount: 4 }), + decomposition({ id: 'split-2', workflowRunId: 'wr-2', status: 'failed', failure: '工作流的输出里没有 tasks 数组。' }), + decomposition({ id: 'split-1', workflowRunId: '', status: 'failed', failure: '工作流启动被拒绝。' }), + ] }) + renderPage() + await userEvent.click(await screen.findByRole('tab', { name: '工作流 3' })) + const list = await screen.findByRole('list', { name: '工作流运行记录' }) + expect(within(list).getAllByRole('listitem').map(row => row.textContent)).toEqual([ + expect.stringContaining('拆出 4 个子任务'), expect.stringContaining('工作流的输出里没有 tasks 数组。'), expect.stringContaining('工作流启动被拒绝。'), + ]) + // A run the host refused has nothing to open. + expect(within(list).getAllByRole('button')).toHaveLength(2) + await userEvent.click(within(list).getAllByRole('button')[1]!) + expect(await screen.findByText('运行视图 wr-2 tenant 只读')).toBeInTheDocument() + await userEvent.click(screen.getByRole('tab', { name: /讨论/ })) + await userEvent.click(screen.getByRole('tab', { name: /工作流/ })) + expect(await screen.findByRole('list', { name: '工作流运行记录' })).toBeInTheDocument() +}) + +it('opens a shared link on its run, and on the list when the run is gone', async () => { + vi.mocked(api.getTask).mockResolvedValue(requirement) + vi.mocked(api.listTaskDecompositions).mockResolvedValue({ items: [decomposition({ status: 'succeeded', createdCount: 2 })] }) + const { unmount } = renderPage('?tab=workflows&workflow=wr-1') + expect(await screen.findByText('运行视图 wr-1 tenant 只读')).toBeInTheDocument() + unmount() + renderPage('?tab=workflows&workflow=gone') + expect(await screen.findByRole('list', { name: '工作流运行记录' })).toBeInTheDocument() }) it('shows a running split and holds the button', async () => { @@ -317,3 +394,102 @@ it('lets a workspace owner confirm work someone else asked for', async () => { await userEvent.click(await screen.findByRole('button', { name: '确认完成' })) await waitFor(() => expect(api.setTaskStatus).toHaveBeenCalledWith('tenant', 'task-1', { status: 'done', version: 1 })) }) + +it('opens the newest workflow run with its workspace from the page header', async () => { + localStorage.clear() + vi.mocked(api.getTask).mockResolvedValue(requirement) + vi.mocked(api.listTaskDecompositions).mockResolvedValue({ items: [decomposition({ status: 'succeeded', createdCount: 2 })] }) + vi.mocked(workspaceApi.getWorkflowRunWorkspaceChanges).mockResolvedValue({ baseCommit: 'abc', files: [], truncated: false }) + renderPage() + + const button = await screen.findByRole('button', { name: '工作区' }) + expect(button).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByRole('complementary', { name: '任务属性' })).toBeInTheDocument() + await userEvent.click(button) + expect(await screen.findByText('运行视图 wr-1 tenant 只读')).toBeInTheDocument() + expect(button).toHaveAttribute('aria-pressed', 'true') + // The panel takes the side column's place, and closing it brings the column back. + expect(await screen.findByRole('complementary', { name: '工作区' })).toBeInTheDocument() + expect(screen.queryByRole('complementary', { name: '任务属性' })).not.toBeInTheDocument() + await waitFor(() => expect(workspaceApi.getWorkflowRunWorkspaceChanges).toHaveBeenCalledWith('tenant', 'wr-1')) + await userEvent.click(button) + expect(button).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByRole('complementary', { name: '任务属性' })).toBeInTheDocument() + expect(screen.getByText('运行视图 wr-1 tenant 只读')).toBeInTheDocument() + localStorage.clear() +}) + +it('shows the workspace of the Agent run on screen', async () => { + localStorage.clear() + vi.mocked(api.getTaskTimeline).mockResolvedValue({ runs: [run({ id: 'run-1', state: 'ended' }), run({ id: 'run-2', state: 'idle' })], comments: [] }) + vi.mocked(workspaceApi.getTaskRunWorkspaceChanges).mockResolvedValue({ baseCommit: 'abc', files: [], truncated: false }) + renderPage('?run=run-1') + + await userEvent.click(await screen.findByRole('button', { name: '工作区' })) + const panel = await screen.findByRole('complementary', { name: '工作区' }) + expect(within(panel).getByText('Agent 运行 1')).toBeInTheDocument() + await waitFor(() => expect(workspaceApi.getTaskRunWorkspaceChanges).toHaveBeenCalledWith('tenant', 'task-1', 'run-1')) + localStorage.clear() +}) + +it('offers no workspace before anything has run', async () => { + renderPage() + await screen.findByRole('button', { name: '编辑' }) + expect(screen.queryByRole('button', { name: '工作区' })).not.toBeInTheDocument() +}) + +describe('handling a task with a workflow', () => { + const human = { type: 'human' as const, id: 'user-me', displayName: '演示用户', identifier: '' } + const workflow = (id: string, name: string) => ({ id, namespace: 'team', name, publishedVersion: 'v1', createdAt: 1, updatedAt: 1 }) + const summary = { id: 'wf-run-1', name: '修复登录页', projectId: 'project-1', workflowId: 'wf-dev', version: 'v1', startedAt: 2, finishedAt: null, createdAt: 2 } + + afterEach(() => { vi.unstubAllEnvs() }) + + it('is not offered without the demo mock', async () => { + vi.mocked(api.getTask).mockResolvedValue(task({ assignee: human })) + renderPage() + await screen.findByRole('button', { name: '用 Agent 处理' }) + expect(screen.queryByRole('button', { name: '用工作流处理' })).not.toBeInTheDocument() + }) + + it('starts the picked workflow with the requirement and opens the run on the task', async () => { + vi.stubEnv('VITE_DEMO_MOCK', '1') + const user = userEvent.setup() + let started = false + vi.mocked(api.getTask).mockResolvedValue(task({ assignee: human })) + vi.mocked(api.setTaskStatus).mockResolvedValue(task({ assignee: human, status: 'running' })) + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue({ workflows: [workflow('wf-split', '需求拆分'), workflow('wf-dev', '需求开发')] }) + vi.mocked(workflowsApi.listWorkflowRuns).mockImplementation(async () => ({ runs: started ? [{ ...summary, status: 'running' as const }] : [] })) + vi.mocked(workflowsApi.createWorkflowRun).mockResolvedValue({ run: { id: 'wf-run-1' } } as never) + vi.mocked(workflowsApi.updateWorkflowRunInput).mockResolvedValue({} as never) + vi.mocked(workflowsApi.startWorkflowRun).mockImplementation(async () => { started = true; return {} as never }) + renderPage() + + await user.click(await screen.findByRole('button', { name: '用工作流处理' })) + const dialog = screen.getByRole('dialog') + await waitFor(() => expect(within(dialog).getByLabelText('工作流')).toHaveDisplayValue('需求开发(v1)')) + const prompt = within(dialog).getByLabelText('需求') + expect(prompt).toHaveValue('# 修复登录页\n\n按钮错位\n\n## 约束\n- ') + await user.type(prompt, '不新增依赖') + await user.click(within(dialog).getByRole('button', { name: '启动工作流' })) + + await screen.findByText('运行视图 wf-run-1 tenant 只读') + expect(workflowsApi.createWorkflowRun).toHaveBeenCalledWith('tenant', 'wf-dev', { projectId: 'project-1', name: '修复登录页' }) + expect(workflowsApi.updateWorkflowRunInput).toHaveBeenCalledWith('tenant', 'wf-run-1', { input: '# 修复登录页\n\n按钮错位\n\n## 约束\n- 不新增依赖' }) + expect(api.setTaskStatus).toHaveBeenCalledWith('tenant', 'task-1', { status: 'running', version: 1 }) + expect(screen.getByRole('tab', { name: '工作流 1' })).toHaveAttribute('aria-selected', 'true') + }) + + it('lists the handling run with its outcome', async () => { + vi.stubEnv('VITE_DEMO_MOCK', '1') + vi.mocked(api.getTask).mockResolvedValue(task({ assignee: human })) + vi.mocked(workflowsApi.listWorkflows).mockResolvedValue({ workflows: [workflow('wf-dev', '需求开发')] }) + vi.mocked(workflowsApi.listWorkflowRuns).mockResolvedValue({ runs: [{ ...summary, status: 'succeeded' }, { ...summary, id: 'other', name: '别的任务', status: 'running' }] }) + renderPage('?tab=workflows') + + const row = await screen.findByRole('button', { name: /查看工作流运行 需求开发/ }) + expect(row).toHaveTextContent('处理 · 演示用户 发起') + expect(row).toHaveTextContent('处理完成') + expect(screen.getAllByRole('listitem')).toHaveLength(1) + }) +}) diff --git a/frontend-v2/src/features/collaboration/task-page.tsx b/frontend-v2/src/features/collaboration/task-page.tsx index 5a011bece..78fa75963 100644 --- a/frontend-v2/src/features/collaboration/task-page.tsx +++ b/frontend-v2/src/features/collaboration/task-page.tsx @@ -1,18 +1,27 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link, useParams, useSearchParams } from 'react-router' -import { ChevronLeft, ClipboardList, ListTree, Pencil } from 'lucide-react' +import { ChevronLeft, ClipboardList, ListTree, PanelRight, Pencil } from 'lucide-react' import { useUser } from '@/app/auth/user' import { Button } from '@/components/ui/button' import { getApiV2TenantsTidProjects } from '@/lib/projects-api/generated' +import { demoMockEnabled } from '@/lib/demo/flags' +import { useDemoTaskRuns } from '@/lib/demo/task-workflows' import { listSquads } from '@/lib/squads-api/generated' import { PanelDialog } from '@/components/panel/panel-dialog' +import { WorkspaceNavigationProvider } from '@/components/workspace-panel/navigation' +import type { WorkspaceSource } from '@/components/workspace-panel/source' +import { useWorkspacePanel } from '@/components/workspace-panel/use-panel' +import { WorkspacePanel } from '@/components/workspace-panel/workspace-panel' +import { WorkspaceSplit } from '@/components/workspace-panel/workspace-split' import { RunPane } from './run-pane' import { TaskComposer, type CommentDraft } from './task-composer' import { DecomposeDialog, TaskFamily } from './task-decomposition' import { TaskDetailView } from './task-detail' import { TaskDiscussion } from './task-discussion' import { TaskForm, type TaskDraft } from './task-form' +import { TaskWorkflowDialog } from './task-workflow-dialog' +import { TaskWorkflows } from './task-workflows' import { projectLabel, taskMessageFor, taskMoveMessageFor, taskRunMessageFor, taskStatusLabel } from './task-model' import { createTaskComment, getTask, getTaskTimeline, listTaskExecutors, setTaskAssignee, setTaskStatus, startTaskRun, stopTaskRun, updateTask, @@ -35,13 +44,16 @@ export default function TaskPage() { const { tenantId, role } = useWorkspace() const client = useQueryClient() const [params, setParams] = useSearchParams() - const tab = params.get('tab') === 'discussion' ? 'discussion' : 'runs' + const rawTab = params.get('tab') + const tab = rawTab === 'discussion' || rawTab === 'workflows' ? rawTab : 'runs' const pickedRun=params.get("run") const [error, setError] = useState('') const [composeError, setComposeError] = useState('') const [editing, setEditing] = useState(false) const [decomposing, setDecomposing] = useState(false) + const [handling, setHandling] = useState(false) const [pickedExecutor, setExecutorId] = useState('') + const workspace = useWorkspacePanel() const live = (task?: Task) => !!task?.activeRun const taskQuery = useQuery({ queryKey: ['task', tenantId, taskId], queryFn: () => getTask(tenantId, taskId), enabled: !!tenantId && !!taskId, @@ -75,9 +87,23 @@ export default function TaskPage() { }, [runState, client, tenantId, taskId]) const decompositions = useDecompositions(tenantId, taskId) const splitting = decompositions.latest?.status === 'running' + const demoRuns = useDemoTaskRuns(tenantId, task) + const workflowRuns = [...demoRuns, ...decompositions.items].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) const runs = timeline.data?.runs ?? [] const comments = timeline.data?.comments ?? [] const selected = pickedRun ? runs.find(r=>r.id===pickedRun) : runs.at(-1) + // A link to a run the task no longer lists falls back to the list. + const openedWorkflow = tab === 'workflows' ? workflowRuns.find(d => d.workflowRunId && d.workflowRunId === params.get('workflow')) : undefined + const newestRun = workflowRuns.find(d => d.workflowRunId) + // Every run has its own checkout, so the panel shows the run on screen, in place of the side column. + const workflowRunId = openedWorkflow?.workflowRunId + const agentRunId = tab === 'runs' ? selected?.id : undefined + const workspaceSource = useMemo(() => { + if (!tenantId) return undefined + if (workflowRunId) return { kind: 'run', tenantId, runId: workflowRunId } + return agentRunId ? { kind: 'task', tenantId, taskId, runId: agentRunId } : undefined + }, [tenantId, taskId, workflowRunId, agentRunId]) + const workspaceShown = workspace.open && !!workspaceSource const refresh = async () => { await Promise.all([ client.invalidateQueries({ queryKey: ['task', tenantId, taskId] }), @@ -88,12 +114,29 @@ export default function TaskPage() { } const showTab = (next: 'runs' | 'discussion', id?:string) => { const p = new URLSearchParams(params) + p.delete('workflow') if(id)p.set('run',id) if (next === 'runs') p.delete('tab') else p.set('tab', next) setParams(p, { replace: true }) } const showRun = (id: string) => showTab('runs',id) + const showWorkflows = (runId?: string) => { + const p = new URLSearchParams(params) + p.set('tab', 'workflows') + if (runId) p.set('workflow', runId) + else p.delete('workflow') + setParams(p, { replace: true }) + } + + // The button works from any tab: with no run on screen it brings one up with its workspace. + const toggleWorkspace = () => { + if (workspaceSource) { workspace.setOpen(!workspace.open); return } + if (tab === 'workflows' && newestRun) showWorkflows(newestRun.workflowRunId) + else if (selected) showRun(selected.id) + else if (newestRun) showWorkflows(newestRun.workflowRunId) + workspace.setOpen(true) + } const settle = (updated: Task) => { client.setQueryData(['task', tenantId, updated.id], updated) @@ -153,28 +196,37 @@ export default function TaskPage() {

      {task.title}

      {canDecompose(task) && } + {(newestRun || selected) && } -
      + + workspace.setOpen(false)} />}> +
      {task.description &&

      {task.description}

      } {task.workflowSources?.map(source=>

      查看来源巡检报告 · {source.name}{source.submittedAt ? ` · ${new Date(source.submittedAt).toLocaleString()}` : ''}

      )} - setDecomposing(true)} /> + setDecomposing(true)} />
      + {(workflowRuns.length > 0 || tab === 'workflows') && }
      - {timeline.isError + {tab === 'workflows' + ? + : timeline.isError ?

      运行与讨论加载失败,请刷新重试。

      : tab === 'runs' ? pickedRun&&!selected&&!timeline.isPending ?

      这次运行已不存在或不可见。

      : : }
      - { try { await comment.mutateAsync(draft); return true } catch { return false } }} /> + {!openedWorkflow && { try { await comment.mutateAsync(draft); return true } catch { return false } }} />}
      - }
      +
      +
      + + {decomposing && setDecomposing(false)} onStarted={created => { setDecomposing(false); showWorkflows(created.status === 'running' ? created.workflowRunId : undefined) }} />} - {decomposing && setDecomposing(false)} onStarted={() => setDecomposing(false)} />} + {handling && setHandling(false)} onStarted={runId => { + setHandling(false) + if (task.status === 'pending') move.mutate({ status: 'running', target: task }) + showWorkflows(runId) + }} />} {editing && } eyebrow="任务" title={`编辑 ${task.title}`} subtitle="修改标题、描述与所属项目;状态与指派有各自的入口。" onClose={() => setEditing(false)}> save.mutate({ draft, target: task })} onCancel={() => setEditing(false)} /> diff --git a/frontend-v2/src/features/collaboration/task-workflow-dialog.tsx b/frontend-v2/src/features/collaboration/task-workflow-dialog.tsx new file mode 100644 index 000000000..eb8c4ac76 --- /dev/null +++ b/frontend-v2/src/features/collaboration/task-workflow-dialog.tsx @@ -0,0 +1,61 @@ +import { useState, type FormEvent } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Workflow } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field' +import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' +import { Textarea } from '@/components/ui/textarea' +import { PanelDialog } from '@/components/panel/panel-dialog' +import { DEMO_HANDLING_WORKFLOW, demoPromptFor, demoTaskRunsKey, startDemoTaskWorkflow } from '@/lib/demo/task-workflows' +import { listWorkflows } from '@/lib/workflows/api/generated' +import type { Task } from './tasks-api/generated' + +/** + * Pick a workflow, write the requirement it works from, and start it on the task. Only the demo offers + * this (see `lib/demo/task-workflows.ts`); the assignee reviews the result and submits it by hand. + */ +export function TaskWorkflowDialog({ tenantId, task, onClose, onStarted }: { + tenantId: string + task: Task + onClose: () => void + onStarted: (workflowRunId: string) => void +}) { + const client = useQueryClient() + const workflows = useQuery({ queryKey: ['workflows', tenantId], queryFn: () => listWorkflows(tenantId), enabled: !!tenantId }) + const published = (workflows.data?.workflows ?? []).filter(w => w.publishedVersion) + const [picked, setPicked] = useState('') + const workflowId = picked || (published.find(w => w.name === DEMO_HANDLING_WORKFLOW) ?? published[0])?.id || '' + const [prompt, setPrompt] = useState(() => demoPromptFor(task)) + const start = useMutation({ + mutationFn: () => startDemoTaskWorkflow(tenantId, task, workflowId, prompt.trim()), + onSuccess: async runId => { + await client.invalidateQueries({ queryKey: demoTaskRunsKey(tenantId, task.id) }) + onStarted(runId) + }, + }) + const submit = (event: FormEvent) => { event.preventDefault(); start.mutate() } + return } eyebrow="用工作流处理" title={`处理 ${task.title}`} + subtitle="选一个团队工作流,写清楚需求。工作流按需求执行,你来判断结果。" onClose={onClose}> +
      +
      + + 工作流 + setPicked(e.target.value)}> + {published.length === 0 && {workflows.isLoading ? '正在加载…' : '没有可用的工作流'}} + {published.map(w => {w.name}({w.publishedVersion}))} + + + + 需求 +