From 25044c3f1c84af88aabb22ee547b77059faea3ca Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Thu, 5 Nov 2026 10:03:45 +0000 Subject: [PATCH 1/8] refactor(v2): let a workflow run view live outside chat The run view took its tenant from the chat store, down to the Theater's node sessions, so no other page could host it. A host now passes its tenant, can show the run read-only and can put its own control at the start of the header. Chat is unchanged. --- .../features/chat/workflow-run/demo-host.ts | 4 +-- .../chat/workflow-run/run-flow.test.tsx | 19 ++++++++++- .../workflow-run/run-node-session-chat.tsx | 4 +-- .../features/chat/workflow-run/run-pane.tsx | 34 +++++++++++-------- .../features/chat/workflow-run/run-tenant.ts | 12 +++++++ 5 files changed, 54 insertions(+), 19 deletions(-) create mode 100644 frontend-v2/src/features/chat/workflow-run/run-tenant.ts diff --git a/frontend-v2/src/features/chat/workflow-run/demo-host.ts b/frontend-v2/src/features/chat/workflow-run/demo-host.ts index 9241681fe..6fd581cb7 100644 --- a/frontend-v2/src/features/chat/workflow-run/demo-host.ts +++ b/frontend-v2/src/features/chat/workflow-run/demo-host.ts @@ -3,7 +3,7 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { localizeContractError } from '@/lib/workflows/host/contract-error' import { toast } from '@/lib/workflows/host/ui' -import { useChatStore } from '../store' +import { useRunTenant } from './run-tenant' import { useWorkspaceNavigation } from '@/components/workspace-panel/navigation' import { updateRunInput } from './server-runs' @@ -27,7 +27,7 @@ export function useContractErrorToast(): (error: unknown, title?: string) => voi /** Saves the Start node's prompt and values edited in Theater's inspector. */ export function useUpdateWorkflowRunInput() { - const tenantId = useChatStore((s) => s.tenantId) ?? '' + const tenantId = useRunTenant() return useMutation({ mutationFn: ({ runId, input, variables }: { runId: string; input: string; variables?: Record }) => updateRunInput(tenantId, runId, input, variables), diff --git a/frontend-v2/src/features/chat/workflow-run/run-flow.test.tsx b/frontend-v2/src/features/chat/workflow-run/run-flow.test.tsx index 20306ab5d..cc2d065f5 100644 --- a/frontend-v2/src/features/chat/workflow-run/run-flow.test.tsx +++ b/frontend-v2/src/features/chat/workflow-run/run-flow.test.tsx @@ -1,6 +1,6 @@ import '@/lib/workflows/test/dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -11,6 +11,7 @@ import Page from '../page' import { newChatKey, resetChatStore, useChatStore } from '../store' import { chatBackend, FakeEventSource, line, tenantId } from '../test-backend' import { sampleWorkflowBackend, serveWorkflows, type WorkflowServer } from '@/lib/workflows/test/workflow-server' +import RunPane from './run-pane' import { runRefreshMs } from './server-runs' import { serveRuns, type RunServer } from './test-run-server' @@ -76,6 +77,22 @@ describe('workflow runs in chat', () => { expect(screen.queryByRole('dialog', { name: '运行工作流' })).not.toBeInTheDocument() }) + it('shows a run read-only on a page outside chat, on the tenant it is given', async () => { + const user = userEvent.setup() + await renderPage() + await createRun(user, '任务页的运行') + await screen.findByRole('region', { name: '工作流运行' }) + await waitFor(() => expect(runs.runs.size).toBe(1)) + const runId = [...runs.runs.keys()][0]! + cleanup() + resetChatStore() + + render() + expect(await screen.findByRole('heading', { level: 2, name: '任务页的运行' })).toBeInTheDocument() + for (const name of ['启动', '停止', '再跑一次', '在对话中追问', '工作区']) expect(screen.queryByRole('button', { name })).not.toBeInTheDocument() + expect(screen.getByRole('group', { name: '运行视图' })).toBeInTheDocument() + }) + it('runs a published workflow on v2-server and follows it on the Theater', async () => { const user = userEvent.setup() await renderPage() diff --git a/frontend-v2/src/features/chat/workflow-run/run-node-session-chat.tsx b/frontend-v2/src/features/chat/workflow-run/run-node-session-chat.tsx index 1496fa680..872cd3e5d 100644 --- a/frontend-v2/src/features/chat/workflow-run/run-node-session-chat.tsx +++ b/frontend-v2/src/features/chat/workflow-run/run-node-session-chat.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import type { GraphWorkflowNodeStatus, WorkflowNodeConversationItem } from '@/lib/workflows/runtime' import { FirstTurnWait } from '@/components/agent-thread/preparation' import { Thread } from '@/components/agent-thread/thread' -import { useChatStore } from '../store' +import { useRunTenant } from './run-tenant' import { nodeConversationTurns } from './conversation-turns' import { useNodeSession } from './node-session' import { isNodeWorking } from './run-status-style' @@ -33,7 +33,7 @@ export function RunNodeSessionChat({ session?: { runId: string; nodeRunId: string } }) { const { t } = useTranslation() - const tenantId = useChatStore((s) => s.tenantId) ?? '' + const tenantId = useRunTenant() const { conversation: followed, refused } = useNodeSession(tenantId, session?.runId, session?.nodeRunId) const recorded = useMemo(() => nodeConversationTurns(conversation, status, errorMessage), [conversation, status, errorMessage]) const thread = followed !== null && followed.turns.length > 0 ? followed : recorded diff --git a/frontend-v2/src/features/chat/workflow-run/run-pane.tsx b/frontend-v2/src/features/chat/workflow-run/run-pane.tsx index a65bd53fe..3330d4c20 100644 --- a/frontend-v2/src/features/chat/workflow-run/run-pane.tsx +++ b/frontend-v2/src/features/chat/workflow-run/run-pane.tsx @@ -1,7 +1,7 @@ import { RunForm } from './run-form' import { QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query' import { MapIcon, MessageSquareIcon, PlayIcon, RotateCcwIcon, SquareIcon, TheaterIcon, WorkflowIcon } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { I18nextProvider, useTranslation } from 'react-i18next' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -29,6 +29,7 @@ import { isLoopHistoryNode } from './loop-round-state' import { resolveStageFocusNodeId, resolveTheaterFocus, shouldReleaseLivePinToFollow, shouldStealFocusForArtifactReveal, type TheaterFocusStatusSample } from './run-focus' import { RunOverviewCanvas } from './run-overview-canvas' import { runStatusLabel, runStatusTone } from './run-status-style' +import { RunTenantContext } from './run-tenant' import { RunTheater } from './run-theater' import { workflowRunTranslations } from './translations' import { WorkflowRunStartDialog } from './workflow-run-start-dialog' @@ -49,9 +50,12 @@ const noArtifacts: WorkflowArtifact[] = [] * the run; the page reads it again each time its events stream announces a change, polling as a * fallback, until it ends. The page brings the tenant's * workflow client and query cache, which the run and the inspector's Agent configuration read. + * A page outside chat passes its tenant; `readOnly` shows the run without acting on it, and + * `leading` puts the page's own control at the start of the header. */ -export default function RunPane({ runId }: { runId: string }) { - const tenantId = useChatStore((s) => s.tenantId) ?? '' +export default function RunPane({ runId, tenantId: hostedTenant, readOnly = false, leading }: { runId: string; tenantId?: string; readOnly?: boolean; leading?: ReactNode }) { + const connectedTenant = useChatStore((s) => s.tenantId) + const tenantId = hostedTenant ?? connectedTenant ?? '' const client = useMemo(() => createContractsClient(createHttpWorkflowContracts(tenantId), tenantId), [tenantId]) const [queryClient] = useState(createWorkflowQueryClient) // The sidebar's project run list lives in the page's cache, not the run page's own; a run this @@ -65,7 +69,9 @@ export default function RunPane({ runId }: { runId: string }) { - + + + @@ -82,7 +88,7 @@ function RunToaster() { * Reads the run's frozen graph once, then the run itself until it ends: again whenever its events * stream says it changed, with polling as the fallback. */ -function LoadedRun({ tenantId, runId, refreshProjectRuns }: { tenantId: string; runId: string; refreshProjectRuns: (projectId: string) => void }) { +function LoadedRun({ tenantId, runId, readOnly, leading, refreshProjectRuns }: { tenantId: string; runId: string; readOnly: boolean; leading: ReactNode; refreshProjectRuns: (projectId: string) => void }) { const { t } = useTranslation() const queryClient = useQueryClient() const snapshot = useQuery({ ...runSnapshotQuery(tenantId, runId), enabled: tenantId !== '' }) @@ -97,7 +103,7 @@ function LoadedRun({ tenantId, runId, refreshProjectRuns }: { tenantId: string; connected: setFollowed, }) const error = snapshot.error ?? run.error - if (run.data) return + if (run.data) return return (

{error === null ? '正在读取运行…' : isMissing(error) ? '这次运行已经不在了。' : localizeContractError(error, t)}

@@ -112,7 +118,7 @@ function runConversations(run: GraphWorkflowRun): WorkflowNodeConversationItem[] return Object.values(run.nodeStates).flatMap((state) => state.conversation ?? []) } -function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string; run: GraphWorkflowRun; refreshProjectRuns: (projectId: string) => void }) { +function RunWorkspace({ tenantId, run, readOnly, leading, refreshProjectRuns }: { tenantId: string; run: GraphWorkflowRun; readOnly: boolean; leading: ReactNode; refreshProjectRuns: (projectId: string) => void }) { const { t } = useTranslation() const queryClient = useQueryClient() const conversations = useMemo(() => runConversations(run), [run]) @@ -266,7 +272,7 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string; return (
- + {leading ?? (!readOnly && )}

@@ -296,19 +302,19 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string; {t('workflowRun.viewMode.overview')}

- {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 && ( )} - workspace.setOpen(!workspace.open)} /> + {!readOnly && workspace.setOpen(!workspace.open)} />}
- {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 ?? '' +} From 49378441fb77d0cba7fd32d7f6258cab5ae406b4 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Thu, 5 Nov 2026 10:03:45 +0000 Subject: [PATCH 2/8] feat(v2): show a task's workflow runs in a tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A split only said it was running, and nothing listed what a task had run. The task page now has a 工作流 tab between Agent 运行 and 讨论: a list of the runs, each opening the chat page's run view, read-only because a run started there would not belong to the task. Starting a split opens its run; an open run folds the subtasks and hides the composer to give the graph the room. The demo video's shot opens the folded subtasks. --- .../src/features/collaboration/README.md | 5 +- .../features/collaboration/collaboration.css | 7 ++ .../collaboration/task-decomposition.tsx | 23 ++++-- .../features/collaboration/task-page.test.tsx | 73 +++++++++++++++++++ .../src/features/collaboration/task-page.tsx | 27 +++++-- .../features/collaboration/task-workflows.tsx | 61 ++++++++++++++++ .../src/features/collaboration/workspace.ts | 2 +- 7 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 frontend-v2/src/features/collaboration/task-workflows.tsx diff --git a/frontend-v2/src/features/collaboration/README.md b/frontend-v2/src/features/collaboration/README.md index 62846741b..e0e6b7f46 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,7 +15,8 @@ ## 已经有的 - 任务列表显示状态、标题、项目、指派对象,以及谁在干活(某个 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 里安装,这段时间算在「安装插件」里。 - **派给人的任务**:负责人本人可以在右侧「用 Agent 处理」:从自己能用的全部 Agent 与模型里选一个,用自己的身份开一次运行,之后和 Agent 任务一样(跑完进入待验收、可停止、只有发起人能追问)。其他人看不到这组控件,输入框只发评论;负责人不用 Agent 时照旧手动推进。 diff --git a/frontend-v2/src/features/collaboration/collaboration.css b/frontend-v2/src/features/collaboration/collaboration.css index cdd0ec900..e4ba35704 100644 --- a/frontend-v2/src/features/collaboration/collaboration.css +++ b/frontend-v2/src/features/collaboration/collaboration.css @@ -97,6 +97,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-page.test.tsx b/frontend-v2/src/features/collaboration/task-page.test.tsx index 6fac4ee70..3433c878a 100644 --- a/frontend-v2/src/features/collaboration/task-page.test.tsx +++ b/frontend-v2/src/features/collaboration/task-page.test.tsx @@ -1,6 +1,7 @@ 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 { UserContext } from '@/app/auth/user' @@ -31,6 +32,10 @@ vi.mock('./tasks-api/generated', () => ({ listTasks: vi.fn(), listTaskDecompositions: vi.fn(), startTaskDecomposition: vi.fn(), })) vi.mock('@/lib/workflows/api/generated', () => ({ listWorkflows: 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 +254,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 () => { diff --git a/frontend-v2/src/features/collaboration/task-page.tsx b/frontend-v2/src/features/collaboration/task-page.tsx index 5a011bece..4324f1ec1 100644 --- a/frontend-v2/src/features/collaboration/task-page.tsx +++ b/frontend-v2/src/features/collaboration/task-page.tsx @@ -13,6 +13,7 @@ import { DecomposeDialog, TaskFamily } from './task-decomposition' import { TaskDetailView } from './task-detail' import { TaskDiscussion } from './task-discussion' import { TaskForm, type TaskDraft } from './task-form' +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,7 +36,8 @@ 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('') @@ -78,6 +80,8 @@ export default function TaskPage() { 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' ? decompositions.items.find(d => d.workflowRunId && d.workflowRunId === params.get('workflow')) : undefined const refresh = async () => { await Promise.all([ client.invalidateQueries({ queryKey: ['task', tenantId, taskId] }), @@ -88,12 +92,20 @@ 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 }) + } const settle = (updated: Task) => { client.setQueryData(['task', tenantId, updated.id], updated) @@ -159,20 +171,23 @@ export default function TaskPage() {
      {task.description &&

      {task.description}

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

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

      )} - setDecomposing(true)} /> + setDecomposing(true)} />
      + {(decompositions.items.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={() => setDecomposing(false)} />} + {decomposing && setDecomposing(false)} onStarted={created => { setDecomposing(false); showWorkflows(created.status === 'running' ? created.workflowRunId : undefined) }} />} {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-workflows.tsx b/frontend-v2/src/features/collaboration/task-workflows.tsx new file mode 100644 index 000000000..e70c774a6 --- /dev/null +++ b/frontend-v2/src/features/collaboration/task-workflows.tsx @@ -0,0 +1,61 @@ +import { lazy, Suspense } from 'react' +import { ChevronLeft, Workflow } from 'lucide-react' +import { formatTime } from './task-model' +import type { TaskDecomposition } from './tasks-api/generated' + +const WorkflowRunView = lazy(() => import('@/features/chat/workflow-run/run-pane')) + +const STATUS = { + running: { label: '运行中', tone: 'running' }, + succeeded: { label: '已完成', tone: 'done' }, + failed: { label: '失败', tone: 'cancelled' }, +} + +/** What the run left on the task, in the words the page uses. */ +function outcome(d: TaskDecomposition): string { + if (d.status === 'succeeded') return `拆出 ${d.createdCount} 个子任务` + if (d.status === 'failed') return d.failure || '工作流没有给出子任务。' + return '正在拆分' +} + +function WorkflowRow({ decomposition: d }: { decomposition: TaskDecomposition }) { + const status = STATUS[d.status] + return <> + {status.label} + + {d.workflowName} + 拆分 · {d.startedByName} 发起 · {formatTime(d.createdAt)} + + {outcome(d)} + +} + +/** + * The workflows run for this task, newest first, and one run opened in the same view the chat + * page has. The view is read-only here: a run started from it would not belong to the task. A run + * the workflow host refused has nothing to open, so its row only says why. + */ +export function TaskWorkflows({ tenantId, decompositions, selected, onSelect }: { + tenantId: string + decompositions: TaskDecomposition[] + /** The run opened; undefined shows the list. */ + selected: TaskDecomposition | undefined + onSelect: (workflowRunId?: string) => void +}) { + if (selected) { + const back = + return
+ 正在读取运行…

}> + +
+
+ } + if (decompositions.length === 0) { + return
+ } + return
    {decompositions.map(d =>
  • + {d.workflowRunId + ? + :
    } +
  • )}
+} diff --git a/frontend-v2/src/features/collaboration/workspace.ts b/frontend-v2/src/features/collaboration/workspace.ts index fc080e0eb..83dd3b28c 100644 --- a/frontend-v2/src/features/collaboration/workspace.ts +++ b/frontend-v2/src/features/collaboration/workspace.ts @@ -89,5 +89,5 @@ export function useDecompositions(tenantId: string, taskId: string) { useEffect(() => { if (settled) void client.invalidateQueries({ queryKey: ['tasks', tenantId] }) }, [settled, client, tenantId]) - return { latest, isError: query.isError } + return { items: query.data?.items ?? [], latest, isError: query.isError } } From 6f6bfad524a23eb61be294ec357e73f5615d1bc3 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Fri, 6 Nov 2026 07:18:53 +0000 Subject: [PATCH 3/8] feat(v2): read the workspace of a task run An Agent run on a task has its own checkout, but only conversations and workflow runs exposed theirs. Anyone who can read the task can read its runs, so the same members can read what those runs changed. --- .../src/lib/workspace-api/generated.ts | 473 ++++++++++++++++++ v2-server/internal/server/workspace.go | 27 +- v2-server/internal/server/workspace_test.go | 61 +++ v2-server/internal/store/workspaces.go | 11 + v2-server/internal/taskrun/taskrun.go | 8 + v2-server/workspace.openapi.json | 445 ++++++++++++++++ 6 files changed, 1024 insertions(+), 1 deletion(-) diff --git a/frontend-v2/src/lib/workspace-api/generated.ts b/frontend-v2/src/lib/workspace-api/generated.ts index b1dd8b5bf..db1cdd919 100644 --- a/frontend-v2/src/lib/workspace-api/generated.ts +++ b/frontend-v2/src/lib/workspace-api/generated.ts @@ -128,6 +128,27 @@ export type GetWorkflowRunWorkspaceDiffParams = { path: string; }; +export type GetTaskRunWorkspaceEntriesParams = { +/** + * Path relative to the checkout, with forward slashes; empty or omitted is the root + */ +path?: string; +}; + +export type GetTaskRunWorkspaceFileParams = { +/** + * Path relative to the checkout, with forward slashes + */ +path: string; +}; + +export type GetTaskRunWorkspaceDiffParams = { +/** + * Path relative to the checkout, with forward slashes + */ +path: string; +}; + type SecondParameter unknown> = Parameters[1]; @@ -988,3 +1009,455 @@ export function useGetWorkflowRunWorkspaceDiff,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/api/v2/tenants/${tid}/tasks/${taskid}/runs/${rid}/workspace/entries`, method: 'GET', + params, signal + }, + options); + } + + + + +export const getGetTaskRunWorkspaceEntriesQueryKey = (tid: string, + taskid: string, + rid: string, + params?: GetTaskRunWorkspaceEntriesParams,) => { + return [ + `/api/v2/tenants/${tid}/tasks/${taskid}/runs/${rid}/workspace/entries`, ...(params ? [params] : []) + ] as const; + } + + +export const getGetTaskRunWorkspaceEntriesQueryOptions = >, TError = ErrorType>(tid: string, + taskid: string, + rid: string, + params?: GetTaskRunWorkspaceEntriesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetTaskRunWorkspaceEntriesQueryKey(tid,taskid,rid,params); + + + + const queryFn: QueryFunction>> = ({ signal }) => getTaskRunWorkspaceEntries(tid,taskid,rid,params, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: tid !== null && tid !== undefined && taskid !== null && taskid !== undefined && rid !== null && rid !== undefined, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetTaskRunWorkspaceEntriesQueryResult = NonNullable>> +export type GetTaskRunWorkspaceEntriesQueryError = ErrorType + + +export function useGetTaskRunWorkspaceEntries>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, + params: undefined | GetTaskRunWorkspaceEntriesParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetTaskRunWorkspaceEntries>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, + params?: GetTaskRunWorkspaceEntriesParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetTaskRunWorkspaceEntries>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, + params?: GetTaskRunWorkspaceEntriesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary List one directory of the checkout (the checkout of this run) + */ + +export function useGetTaskRunWorkspaceEntries>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, + params?: GetTaskRunWorkspaceEntriesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetTaskRunWorkspaceEntriesQueryOptions(tid,taskid,rid,params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + + + + + + + +/** + * @summary Read one text file of the checkout (the checkout of this run) + */ +export const getTaskRunWorkspaceFile = ( + tid: string, + taskid: string, + rid: string, + params: GetTaskRunWorkspaceFileParams, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/api/v2/tenants/${tid}/tasks/${taskid}/runs/${rid}/workspace/file`, method: 'GET', + params, signal + }, + options); + } + + + + +export const getGetTaskRunWorkspaceFileQueryKey = (tid: string, + taskid: string, + rid: string, + params?: GetTaskRunWorkspaceFileParams,) => { + return [ + `/api/v2/tenants/${tid}/tasks/${taskid}/runs/${rid}/workspace/file`, ...(params ? [params] : []) + ] as const; + } + + +export const getGetTaskRunWorkspaceFileQueryOptions = >, TError = ErrorType>(tid: string, + taskid: string, + rid: string, + params: GetTaskRunWorkspaceFileParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetTaskRunWorkspaceFileQueryKey(tid,taskid,rid,params); + + + + const queryFn: QueryFunction>> = ({ signal }) => getTaskRunWorkspaceFile(tid,taskid,rid,params, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: tid !== null && tid !== undefined && taskid !== null && taskid !== undefined && rid !== null && rid !== undefined, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetTaskRunWorkspaceFileQueryResult = NonNullable>> +export type GetTaskRunWorkspaceFileQueryError = ErrorType + + +export function useGetTaskRunWorkspaceFile>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, + params: GetTaskRunWorkspaceFileParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetTaskRunWorkspaceFile>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, + params: GetTaskRunWorkspaceFileParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetTaskRunWorkspaceFile>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, + params: GetTaskRunWorkspaceFileParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Read one text file of the checkout (the checkout of this run) + */ + +export function useGetTaskRunWorkspaceFile>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, + params: GetTaskRunWorkspaceFileParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetTaskRunWorkspaceFileQueryOptions(tid,taskid,rid,params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + + + + + + + +/** + * @summary Files that differ from the commit the checkout started at, untracked included (the checkout of this run) + */ +export const getTaskRunWorkspaceChanges = ( + tid: string, + taskid: string, + rid: string, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/api/v2/tenants/${tid}/tasks/${taskid}/runs/${rid}/workspace/changes`, method: 'GET', signal + }, + options); + } + + + + +export const getGetTaskRunWorkspaceChangesQueryKey = (tid: string, + taskid: string, + rid: string,) => { + return [ + `/api/v2/tenants/${tid}/tasks/${taskid}/runs/${rid}/workspace/changes` + ] as const; + } + + +export const getGetTaskRunWorkspaceChangesQueryOptions = >, TError = ErrorType>(tid: string, + taskid: string, + rid: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetTaskRunWorkspaceChangesQueryKey(tid,taskid,rid); + + + + const queryFn: QueryFunction>> = ({ signal }) => getTaskRunWorkspaceChanges(tid,taskid,rid, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: tid !== null && tid !== undefined && taskid !== null && taskid !== undefined && rid !== null && rid !== undefined, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetTaskRunWorkspaceChangesQueryResult = NonNullable>> +export type GetTaskRunWorkspaceChangesQueryError = ErrorType + + +export function useGetTaskRunWorkspaceChanges>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetTaskRunWorkspaceChanges>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetTaskRunWorkspaceChanges>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Files that differ from the commit the checkout started at, untracked included (the checkout of this run) + */ + +export function useGetTaskRunWorkspaceChanges>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetTaskRunWorkspaceChangesQueryOptions(tid,taskid,rid,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + + + + + + + +/** + * @summary One file's unified diff against that commit (the checkout of this run) + */ +export const getTaskRunWorkspaceDiff = ( + tid: string, + taskid: string, + rid: string, + params: GetTaskRunWorkspaceDiffParams, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/api/v2/tenants/${tid}/tasks/${taskid}/runs/${rid}/workspace/diff`, method: 'GET', + params, signal + }, + options); + } + + + + +export const getGetTaskRunWorkspaceDiffQueryKey = (tid: string, + taskid: string, + rid: string, + params?: GetTaskRunWorkspaceDiffParams,) => { + return [ + `/api/v2/tenants/${tid}/tasks/${taskid}/runs/${rid}/workspace/diff`, ...(params ? [params] : []) + ] as const; + } + + +export const getGetTaskRunWorkspaceDiffQueryOptions = >, TError = ErrorType>(tid: string, + taskid: string, + rid: string, + params: GetTaskRunWorkspaceDiffParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetTaskRunWorkspaceDiffQueryKey(tid,taskid,rid,params); + + + + const queryFn: QueryFunction>> = ({ signal }) => getTaskRunWorkspaceDiff(tid,taskid,rid,params, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: tid !== null && tid !== undefined && taskid !== null && taskid !== undefined && rid !== null && rid !== undefined, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetTaskRunWorkspaceDiffQueryResult = NonNullable>> +export type GetTaskRunWorkspaceDiffQueryError = ErrorType + + +export function useGetTaskRunWorkspaceDiff>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, + params: GetTaskRunWorkspaceDiffParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetTaskRunWorkspaceDiff>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, + params: GetTaskRunWorkspaceDiffParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetTaskRunWorkspaceDiff>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, + params: GetTaskRunWorkspaceDiffParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary One file's unified diff against that commit (the checkout of this run) + */ + +export function useGetTaskRunWorkspaceDiff>, TError = ErrorType>( + tid: string, + taskid: string, + rid: string, + params: GetTaskRunWorkspaceDiffParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetTaskRunWorkspaceDiffQueryOptions(tid,taskid,rid,params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} diff --git a/v2-server/internal/server/workspace.go b/v2-server/internal/server/workspace.go index 5e3f17847..af13f631b 100644 --- a/v2-server/internal/server/workspace.go +++ b/v2-server/internal/server/workspace.go @@ -10,11 +10,13 @@ import ( ) // The workspace routes are outside api/openapi.json; workspace.openapi.json describes them. They -// read the checkout behind a conversation or a workflow run, read-only, for the side panel. +// read the checkout behind a conversation, a workflow run or a task run, read-only, for the side +// panel. type workspaceStore interface { ChatConversation(ctx context.Context, tenant, user, id string) (store.ChatConversation, error) ChatWorkspace(ctx context.Context, tenant, user, conversationID string) (store.WorkspaceLocation, error) WorkflowRunWorkspace(ctx context.Context, tenant, runID string) (store.WorkspaceLocation, error) + TaskRunWorkspace(ctx context.Context, tenant, taskID, runID string) (store.WorkspaceLocation, error) } // locate finds the checkout a request names, after its own authorization checks. It writes the @@ -28,6 +30,7 @@ func (h *handler) workspaceRoutes(mux *http.ServeMux) { }{ {chatBase + "/conversations/{cid}/workspace", h.chatWorkspace}, {"/api/v2/tenants/{tid}/workflow-runs/{rid}/workspace", h.workflowRunWorkspace}, + {"/api/v2/tenants/{tid}/tasks/{taskid}/runs/{rid}/workspace", h.taskRunWorkspace}, } { mux.HandleFunc("GET "+r.base+"/entries", workspaceRead(r.find, func(_ context.Context, w workspacefs.Workspace, p string) (any, error) { return w.List(p) })) mux.HandleFunc("GET "+r.base+"/file", workspaceRead(r.find, func(_ context.Context, w workspacefs.Workspace, p string) (any, error) { return w.Read(p) })) @@ -94,6 +97,28 @@ func (h *handler) workflowRunWorkspace(w http.ResponseWriter, r *http.Request) ( return ws, found } +func (h *handler) taskRunWorkspace(w http.ResponseWriter, r *http.Request) (ws workspacefs.Workspace, found bool) { + h.authed(func(w http.ResponseWriter, r *http.Request, u store.User) { + host, scope, id, ok := h.taskRunScope(w, r, u) + if !ok { + return + } + s, ok := h.store.(workspaceStore) + if !ok { + fault(w, http.StatusServiceUnavailable, "workspace_unavailable") + return + } + runID := r.PathValue("rid") + if _, e := host.Run(r.Context(), scope, id, runID); e != nil { + taskRunFault(w, "task run workspace", e) + return + } + at, e := s.TaskRunWorkspace(r.Context(), scope.TenantID, id, runID) + ws, found = workspaceAt(w, at, e) + })(w, r) + return ws, found +} + // workspaceAt turns a recorded location into a Workspace, or answers that there is none yet: the // first turn has not cloned its checkout, or no Agent node of the run has started. func workspaceAt(w http.ResponseWriter, at store.WorkspaceLocation, e error) (workspacefs.Workspace, bool) { diff --git a/v2-server/internal/server/workspace_test.go b/v2-server/internal/server/workspace_test.go index e52a00fc7..d6cc41d16 100644 --- a/v2-server/internal/server/workspace_test.go +++ b/v2-server/internal/server/workspace_test.go @@ -195,3 +195,64 @@ func TestWorkflowRunWorkspaceBeforeAnyAgentNode(t *testing.T) { t.Fatalf("unknown run = %d %v", status, body) } } + +func TestTaskRunWorkspaceReadsTheRunCheckout(t *testing.T) { + ctx := context.Background() + f := newSquadServer(t) + f.taskRunServer(t) + tid := f.tenant(t) + f.project(t) + c := f.taskClient() + c.login() + _, targets := c.do("GET", "/api/v1/tenants/"+tid+"/collaboration/targets", nil) + var agentID string + for _, item := range targets["items"].([]any) { + if target := item.(map[string]any); target["description"] == "official/ora-space.opencode" { + agentID = target["id"].(string) + } + } + base := "/api/v2/tenants/" + tid + "/tasks" + newTask := func(title string) string { + status, created := c.do("POST", base, map[string]any{"title": title, "projectId": taskProjectID, "assignee": map[string]any{"type": "agent", "id": agentID}}) + if status != 201 { + t.Fatalf("create = %d %v", status, created) + } + return created["id"].(string) + } + task, other := newTask("修复登录页"), newTask("别的任务") + status, run := c.doHeaders("POST", base+"/"+task+"/runs", map[string]any{}, map[string]string{"Idempotency-Key": "start-1"}) + if status != 201 { + t.Fatalf("start = %d %v", status, run) + } + runID := run["id"].(string) + get := func(path string) (int, map[string]any) { + return workspaceGet(t, c.http, c.base, base+"/"+task+"/runs/"+runID+"/workspace"+path) + } + + if status, body := get("/changes"); status != 404 || body["code"] != "workspace_not_ready" { + t.Fatalf("before the checkout = %d %v", status, body) + } + _, me := c.do("GET", "/api/v1/me", nil) + scope := execution.Scope{TenantID: tid, UserID: me["id"].(string)} + root, commit := agentCheckout(t) + turn := execution.Turn{Ref: execution.Ref{SessionID: "session-1", TurnID: "turn-1"}, Caller: execution.Caller{Kind: execution.CallerTask, ID: runID, AttemptID: "start-1"}, Prompt: execution.TextPrompt("修复"), StartedAt: time.Now()} + if e := f.store.CreateExecutionSession(ctx, scope, execution.Session{ID: "session-1", WorkspaceID: "w1", Model: execution.Model{ID: "fixture"}}, turn); e != nil { + t.Fatal(e) + } + if e := f.store.RecordWorkspace(ctx, "w1", store.WorkspaceLocation{Path: root, BaseCommit: commit}); e != nil { + t.Fatal(e) + } + + status, changes := get("/changes") + if files, _ := changes["files"].([]any); status != 200 || changes["baseCommit"] != commit || len(files) != 2 { + t.Fatalf("changes = %d %v", status, changes) + } + if status, diff := get("/diff?path=README.md"); status != 200 || !strings.Contains(diff["patch"].(string), "+agent") { + t.Fatalf("diff = %d %v", status, diff) + } + // A run belongs to its task: it cannot be read through another one. + wrong := base + "/" + other + "/runs/" + runID + "/workspace/changes" + if status, body := workspaceGet(t, c.http, c.base, wrong); status != 404 || body["code"] != "not_found" { + t.Fatalf("run of another task = %d %v", status, body) + } +} diff --git a/v2-server/internal/store/workspaces.go b/v2-server/internal/store/workspaces.go index 3b23499c7..05e7f7ed3 100644 --- a/v2-server/internal/store/workspaces.go +++ b/v2-server/internal/store/workspaces.go @@ -54,6 +54,17 @@ func (s *Store) WorkflowRunWorkspace(ctx context.Context, tenant, runID string) LIMIT 1`, tenant, runID) } +// TaskRunWorkspace returns the checkout of one run of a task; each run has its own. Runs are +// readable by every tenant member, so this is scoped by tenant only, like the run itself. +func (s *Store) TaskRunWorkspace(ctx context.Context, tenant, taskID, runID string) (WorkspaceLocation, error) { + return s.workspace(ctx, ` + SELECT w.path, w.base_commit + FROM v2_task_runs r + JOIN v2_execution_sessions e ON e.id = r.session_id AND e.tenant_id = r.tenant_id + JOIN v2_workspaces w ON w.id = e.data->>'WorkspaceID' + WHERE r.tenant_id = $1 AND r.task_id = $2 AND r.id = $3`, tenant, taskID, runID) +} + func (s *Store) workspace(ctx context.Context, query string, args ...any) (WorkspaceLocation, error) { var at WorkspaceLocation e := s.pool.QueryRow(ctx, query, args...).Scan(&at.Path, &at.BaseCommit) diff --git a/v2-server/internal/taskrun/taskrun.go b/v2-server/internal/taskrun/taskrun.go index 19ef38711..a02e48a1f 100644 --- a/v2-server/internal/taskrun/taskrun.go +++ b/v2-server/internal/taskrun/taskrun.go @@ -621,6 +621,14 @@ func (h *Host) Events(ctx context.Context, scope execution.Scope, taskID, runID return stream, run, e } +// Run reads one run of a task the caller can see. +func (h *Host) Run(ctx context.Context, scope execution.Scope, taskID, runID string) (store.TaskRun, error) { + if _, e := h.store.Task(ctx, scope.TenantID, scope.UserID, taskID); e != nil { + return store.TaskRun{}, e + } + return h.store.TaskRun(ctx, scope.TenantID, taskID, runID) +} + // Owner is the scope a run's session belongs to. func Owner(run store.TaskRun) execution.Scope { return execution.Scope{TenantID: run.TenantID, UserID: run.StartedBy} diff --git a/v2-server/workspace.openapi.json b/v2-server/workspace.openapi.json index ca8d84146..450dd97d9 100644 --- a/v2-server/workspace.openapi.json +++ b/v2-server/workspace.openapi.json @@ -831,6 +831,451 @@ } } } + }, + "/api/v2/tenants/{tid}/tasks/{taskid}/runs/{rid}/workspace/entries": { + "get": { + "operationId": "getTaskRunWorkspaceEntries", + "tags": [ + "workspace" + ], + "summary": "List one directory of the checkout (the checkout of this run)", + "parameters": [ + { + "name": "tid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "taskid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "rid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Path relative to the checkout, with forward slashes; empty or omitted is the root" + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceDirectory" + } + } + } + }, + "400": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "401": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "404": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "410": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "503": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + } + } + } + }, + "/api/v2/tenants/{tid}/tasks/{taskid}/runs/{rid}/workspace/file": { + "get": { + "operationId": "getTaskRunWorkspaceFile", + "tags": [ + "workspace" + ], + "summary": "Read one text file of the checkout (the checkout of this run)", + "parameters": [ + { + "name": "tid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "taskid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "rid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Path relative to the checkout, with forward slashes" + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceFile" + } + } + } + }, + "400": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "401": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "404": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "410": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "413": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "422": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "503": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + } + } + } + }, + "/api/v2/tenants/{tid}/tasks/{taskid}/runs/{rid}/workspace/changes": { + "get": { + "operationId": "getTaskRunWorkspaceChanges", + "tags": [ + "workspace" + ], + "summary": "Files that differ from the commit the checkout started at, untracked included (the checkout of this run)", + "parameters": [ + { + "name": "tid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "taskid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "rid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceChanges" + } + } + } + }, + "401": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "404": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "410": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "413": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "503": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + } + } + } + }, + "/api/v2/tenants/{tid}/tasks/{taskid}/runs/{rid}/workspace/diff": { + "get": { + "operationId": "getTaskRunWorkspaceDiff", + "tags": [ + "workspace" + ], + "summary": "One file's unified diff against that commit (the checkout of this run)", + "parameters": [ + { + "name": "tid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "taskid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "rid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Path relative to the checkout, with forward slashes" + } + ], + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceDiff" + } + } + } + }, + "400": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "401": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "404": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "410": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "413": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + }, + "503": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceError" + } + } + } + } + } + } } }, "components": { From f1e0c32fd970f4d37074a02f6115e134a2a3e10a Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Fri, 6 Nov 2026 07:19:47 +0000 Subject: [PATCH 4/8] feat(v2): open a run's workspace from the task page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task page could show a run but not what it changed, so reviewing a task meant leaving it. The header's 工作区 opens the checkout of the run on screen in place of the side column, and names that run, since every run has its own checkout. The run view hands its panel to the page hosting it. --- .../src/components/workspace-panel/source.ts | 17 ++++++- .../components/workspace-panel/use-panel.ts | 3 ++ .../workspace-panel/workspace-panel.tsx | 4 ++ .../chat/workflow-run/run-flow.test.tsx | 4 +- .../features/chat/workflow-run/run-pane.tsx | 23 +++++----- .../src/features/collaboration/README.md | 3 +- .../features/collaboration/collaboration.css | 3 +- .../features/collaboration/task-page.test.tsx | 45 +++++++++++++++++++ .../src/features/collaboration/task-page.tsx | 45 ++++++++++++++++--- .../features/collaboration/task-workflows.tsx | 7 ++- 10 files changed, 131 insertions(+), 23 deletions(-) diff --git a/frontend-v2/src/components/workspace-panel/source.ts b/frontend-v2/src/components/workspace-panel/source.ts index 513ef4008..c5b1d3e5d 100644 --- a/frontend-v2/src/components/workspace-panel/source.ts +++ b/frontend-v2/src/components/workspace-panel/source.ts @@ -3,6 +3,10 @@ import { getChatWorkspaceDiff, getChatWorkspaceEntries, getChatWorkspaceFile, + getTaskRunWorkspaceChanges, + getTaskRunWorkspaceDiff, + getTaskRunWorkspaceEntries, + getTaskRunWorkspaceFile, getWorkflowRunWorkspaceChanges, getWorkflowRunWorkspaceDiff, getWorkflowRunWorkspaceEntries, @@ -14,10 +18,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 { @@ -40,6 +45,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 return { key: ['workspace', 'run', t, r], 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 0df263985..b4dd420bb 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}}
)} - {!readOnly && workspace.setOpen(!workspace.open)} />} + {!hostedWorkspace && workspace.setOpen(!workspace.open)} />}
workspace.setOpen(false)} /> + !hostedWorkspace && workspace.open && workspace.setOpen(false)} /> } >
diff --git a/frontend-v2/src/features/collaboration/README.md b/frontend-v2/src/features/collaboration/README.md index e0e6b7f46..6923c6861 100644 --- a/frontend-v2/src/features/collaboration/README.md +++ b/frontend-v2/src/features/collaboration/README.md @@ -16,9 +16,10 @@ - 任务列表显示状态、标题、项目、指派对象,以及谁在干活(某个 Agent 执行中、等待追问)。看板按「待处理 / 进行中 / 待验收 / 已完成」分列,「已取消」默认隐藏。把卡片拖到另一列,等同于点任务里对应的推进按钮:拖动时只有合法的下一步亮起,状态逐级推进,不能跳过待验收;Agent 正在执行的卡片拿不起来;指派给 Agent 的任务只能在任务里「开始处理」进入进行中,不能拖进去。松手后卡片立即移动,服务端拒绝(例如 `409 run_active`)时退回原列,并弹出提示说明原因,几秒后自动消失。拖动只支持指针,键盘用任务页的按钮。窄屏下看板变成单列。 - **拆分**:大需求在任务页点「拆分」,选一个已发布的工作流(默认「需求拆分」)运行,任务的标题和描述是输入。跑完后输出的 `tasks` 变成这个任务的子任务:待处理、未指派、同一个项目,任务页列出它们,看板上子任务标着「拆分自 …」,大需求标着「N 个子任务」,Tech Lead 再把它们分给人和 Agent。拆分中按钮变成「拆分中…」,失败时任务页写明原因并可「重新拆分」。子任务不能再拆。工作流运行也在工作流页的运行历史里。 -- **工作流页签**:任务运行过工作流时出现,列出每次运行的工作流、状态、发起人、时间和结果,点一行在页签里打开,和对话页同一个运行视图(`?tab=workflows&workflow=<运行 id>`,链接发给队友看到同一次运行)。点「开始拆分」直接打开这次运行。这里只读:不能启动、停止或再跑一次,从这里开的运行不属于这个任务;打开运行时输入框收起,子任务也默认折叠(可以展开),把高度让给画布;运行标题左边的返回按钮回到列表。工作流启动被拒绝的记录没有运行可打开,只写明原因。 +- **工作流页签**:任务运行过工作流时出现,列出每次运行的工作流、状态、发起人、时间和结果,点一行在页签里打开,和对话页同一个运行视图(`?tab=workflows&workflow=<运行 id>`,链接发给队友看到同一次运行)。点「开始拆分」直接打开这次运行。这里只读:不能启动、停止或再跑一次,从这里开的运行不属于这个任务,页头的「工作区」看运行改了哪些文件;打开运行时输入框收起,子任务也默认折叠(可以展开),把高度让给画布;运行标题左边的返回按钮回到列表。工作流启动被拒绝的记录没有运行可打开,只写明原因。 - 任务指派给人、可执行的预装 Agent 或未归档的小队;指派给小队不挑具体的人:每个队员的「我的任务」都有它,队员还没评论或开始处理时它也在「待我处理」,卡片标出是哪个小队派来的。 - **执行**:指派给 Agent 的任务在右侧选模型后「开始处理」,用你的身份运行,任务进入进行中,一轮结束自动进入待验收;停止或服务重启后回到待处理。Agent 执行期间不能手动推进状态。已经有运行时按钮是「重新执行」,会结束空闲的旧会话。运行开始后和对话页一样显示准备阶段:列出全部阶段、当前第几步和已用时,首次输出后收起为总用时。项目第一次用某个 Agent 要先在 Node 里安装,这段时间算在「安装插件」里。 +- **工作区**:页头的「工作区」打开当前运行的文件和 diff,占掉右侧属性栏的位置,可以拖宽,关掉后属性栏恢复。每次 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 e4ba35704..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; } diff --git a/frontend-v2/src/features/collaboration/task-page.test.tsx b/frontend-v2/src/features/collaboration/task-page.test.tsx index 3433c878a..e3d5f434b 100644 --- a/frontend-v2/src/features/collaboration/task-page.test.tsx +++ b/frontend-v2/src/features/collaboration/task-page.test.tsx @@ -8,6 +8,7 @@ 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' @@ -32,6 +33,7 @@ vi.mock('./tasks-api/generated', () => ({ listTasks: vi.fn(), listTaskDecompositions: vi.fn(), startTaskDecomposition: vi.fn(), })) vi.mock('@/lib/workflows/api/generated', () => ({ listWorkflows: 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 ? '只读' : '可写'}

, @@ -390,3 +392,46 @@ 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() +}) diff --git a/frontend-v2/src/features/collaboration/task-page.tsx b/frontend-v2/src/features/collaboration/task-page.tsx index 4324f1ec1..03e376254 100644 --- a/frontend-v2/src/features/collaboration/task-page.tsx +++ b/frontend-v2/src/features/collaboration/task-page.tsx @@ -1,12 +1,17 @@ -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 { 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' @@ -44,6 +49,7 @@ export default function TaskPage() { const [editing, setEditing] = useState(false) const [decomposing, setDecomposing] = 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, @@ -82,6 +88,16 @@ export default function TaskPage() { 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' ? decompositions.items.find(d => d.workflowRunId && d.workflowRunId === params.get('workflow')) : undefined + const newestRun = decompositions.items.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] }), @@ -107,6 +123,15 @@ export default function TaskPage() { 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) setError('') @@ -165,9 +190,15 @@ 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()}` : ''}

)} @@ -179,7 +210,7 @@ export default function TaskPage() {
{tab === 'workflows' - ? + ? : timeline.isError ?

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

: tab === 'runs' @@ -189,7 +220,7 @@ export default function TaskPage() { {!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) }} />} diff --git a/frontend-v2/src/features/collaboration/task-workflows.tsx b/frontend-v2/src/features/collaboration/task-workflows.tsx index e70c774a6..17a357259 100644 --- a/frontend-v2/src/features/collaboration/task-workflows.tsx +++ b/frontend-v2/src/features/collaboration/task-workflows.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense } from 'react' import { ChevronLeft, Workflow } from 'lucide-react' +import type { WorkspacePanelState } from '@/components/workspace-panel/use-panel' import { formatTime } from './task-model' import type { TaskDecomposition } from './tasks-api/generated' @@ -35,18 +36,20 @@ function WorkflowRow({ decomposition: d }: { decomposition: TaskDecomposition }) * page has. The view is read-only here: a run started from it would not belong to the task. A run * the workflow host refused has nothing to open, so its row only says why. */ -export function TaskWorkflows({ tenantId, decompositions, selected, onSelect }: { +export function TaskWorkflows({ tenantId, decompositions, selected, workspace, onSelect }: { tenantId: string decompositions: TaskDecomposition[] /** The run opened; undefined shows the list. */ selected: TaskDecomposition | undefined + /** The page's workspace panel, which the run's changed files open. */ + workspace: WorkspacePanelState onSelect: (workflowRunId?: string) => void }) { if (selected) { const back = return
正在读取运行…

}> - +
} From 8fb1311a4f3d038062c43a45b194abaf9e1a296d Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Fri, 6 Nov 2026 07:19:47 +0000 Subject: [PATCH 5/8] feat(v2): mock handling a task with a workflow for the demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server cannot tie a workflow run to a task yet, so with VITE_DEMO_MOCK=1 the assignee gets 用工作流处理: pick a workflow, edit the requirement and start a real run, which the task lists as its own. The link between run and task is mocked and registered in lib/demo. --- .../src/features/collaboration/README.md | 1 + .../features/collaboration/task-detail.tsx | 9 +- .../features/collaboration/task-page.test.tsx | 62 +++++++++++++- .../src/features/collaboration/task-page.tsx | 21 ++++- .../collaboration/task-workflow-dialog.tsx | 61 ++++++++++++++ .../features/collaboration/task-workflows.tsx | 27 +++--- frontend-v2/src/lib/demo/registry.ts | 6 ++ frontend-v2/src/lib/demo/task-workflows.ts | 84 +++++++++++++++++++ 8 files changed, 253 insertions(+), 18 deletions(-) create mode 100644 frontend-v2/src/features/collaboration/task-workflow-dialog.tsx create mode 100644 frontend-v2/src/lib/demo/task-workflows.ts diff --git a/frontend-v2/src/features/collaboration/README.md b/frontend-v2/src/features/collaboration/README.md index 6923c6861..385118381 100644 --- a/frontend-v2/src/features/collaboration/README.md +++ b/frontend-v2/src/features/collaboration/README.md @@ -21,6 +21,7 @@ - **执行**:指派给 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/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 e3d5f434b..79947703e 100644 --- a/frontend-v2/src/features/collaboration/task-page.test.tsx +++ b/frontend-v2/src/features/collaboration/task-page.test.tsx @@ -3,7 +3,7 @@ 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' @@ -32,7 +32,9 @@ 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', () => ({ @@ -435,3 +437,59 @@ it('offers no workspace before anything has run', async () => { 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 03e376254..78fa75963 100644 --- a/frontend-v2/src/features/collaboration/task-page.tsx +++ b/frontend-v2/src/features/collaboration/task-page.tsx @@ -5,6 +5,8 @@ import { ChevronLeft, ClipboardList, ListTree, PanelRight, Pencil } from 'lucide 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' @@ -18,6 +20,7 @@ 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 { @@ -48,6 +51,7 @@ export default function TaskPage() { 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 @@ -83,12 +87,14 @@ 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' ? decompositions.items.find(d => d.workflowRunId && d.workflowRunId === params.get('workflow')) : undefined - const newestRun = decompositions.items.find(d => d.workflowRunId) + 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 @@ -205,12 +211,12 @@ export default function TaskPage() { setDecomposing(true)} />
- {(decompositions.items.length > 0 || tab === 'workflows') && } + {(workflowRuns.length > 0 || tab === 'workflows') && }
{tab === 'workflows' - ? + ? : timeline.isError ?

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

: tab === 'runs' @@ -229,6 +235,7 @@ export default function TaskPage() { onStart: () => { setError(''); start.mutate() }, onStop: () => { if (task.activeRun) { setError(''); stop.mutate(task.activeRun.id) } }, }} + onWorkflow={demoMockEnabled() ? () => setHandling(true) : undefined} onStatus={status => { setError(''); move.mutate({ status, target: task }) }} onAssign={next => { setError(''); assign.mutate({ assignee: next, target: task }) }} /> } @@ -238,6 +245,12 @@ export default function TaskPage() { {decomposing && setDecomposing(false)} onStarted={created => { setDecomposing(false); showWorkflows(created.status === 'running' ? created.workflowRunId : undefined) }} />} + {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}))} + + + + 需求 +