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 && (
}>
+
+
+
+ }
+ if (decompositions.length === 0) {
+ return
+ }
+ return {decompositions.map(d => -
+ {d.workflowRunId
+ ? onSelect(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}}
void queryClient.invalidateQueries({ queryKey: reader.key })}>
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 cc2d065f5..14d23dc6e 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
@@ -87,8 +87,10 @@ describe('workflow runs in chat', () => {
cleanup()
resetChatStore()
- render()
+ const workspace = { open: false, setOpen: () => {}, request: undefined, navigation: { openDiff: () => {} } }
+ render()
expect(await screen.findByRole('heading', { level: 2, name: '任务页的运行' })).toBeInTheDocument()
+ // The host keeps the workspace toggle, so the view has none of its own.
for (const name of ['启动', '停止', '再跑一次', '在对话中追问', '工作区']) expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
expect(screen.getByRole('group', { name: '运行视图' })).toBeInTheDocument()
})
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 3330d4c20..37a9d8ee6 100644
--- a/frontend-v2/src/features/chat/workflow-run/run-pane.tsx
+++ b/frontend-v2/src/features/chat/workflow-run/run-pane.tsx
@@ -17,7 +17,7 @@ import { ContractError } from '@/lib/workflows/contracts'
import { localizeContractError } from '@/lib/workflows/host/contract-error'
import { isTerminalRunStatus, type GraphWorkflowRun, type WorkflowArtifact, type WorkflowNodeConversationItem } from '@/lib/workflows/runtime'
import { WorkspaceNavigationProvider } from '@/components/workspace-panel/navigation'
-import { useWorkspacePanel } from '@/components/workspace-panel/use-panel'
+import { useWorkspacePanel, type WorkspacePanelState } from '@/components/workspace-panel/use-panel'
import { WorkspacePanel, WorkspaceToggle } from '@/components/workspace-panel/workspace-panel'
import { WorkspaceSplit } from '@/components/workspace-panel/workspace-split'
import { FadeText } from '../components/fade-text'
@@ -50,10 +50,10 @@ 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.
+ * A page outside chat passes its tenant; `readOnly` shows the run without acting on it, `workspace`
+ * hands the workspace panel to that page, and `leading` puts the page's own control at the start of the header.
*/
-export default function RunPane({ runId, tenantId: hostedTenant, readOnly = false, leading }: { runId: string; tenantId?: string; readOnly?: boolean; leading?: ReactNode }) {
+export default function RunPane({ runId, tenantId: hostedTenant, readOnly = false, workspace, leading }: { runId: string; tenantId?: string; readOnly?: boolean; workspace?: WorkspacePanelState; leading?: ReactNode }) {
const connectedTenant = useChatStore((s) => s.tenantId)
const tenantId = hostedTenant ?? connectedTenant ?? ''
const client = useMemo(() => createContractsClient(createHttpWorkflowContracts(tenantId), tenantId), [tenantId])
@@ -70,7 +70,7 @@ export default function RunPane({ runId, tenantId: hostedTenant, readOnly = fals
-
+
@@ -88,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, readOnly, leading, refreshProjectRuns }: { tenantId: string; runId: string; readOnly: boolean; leading: ReactNode; refreshProjectRuns: (projectId: string) => void }) {
+function LoadedRun({ tenantId, runId, readOnly, workspace, leading, refreshProjectRuns }: { tenantId: string; runId: string; readOnly: boolean; workspace: WorkspacePanelState | undefined; leading: ReactNode; refreshProjectRuns: (projectId: string) => void }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const snapshot = useQuery({ ...runSnapshotQuery(tenantId, runId), enabled: tenantId !== '' })
@@ -103,7 +103,7 @@ function LoadedRun({ tenantId, runId, readOnly, leading, refreshProjectRuns }: {
connected: setFollowed,
})
const error = snapshot.error ?? run.error
- if (run.data) return
+ if (run.data) return
return (
{error === null ? '正在读取运行…' : isMissing(error) ? '这次运行已经不在了。' : localizeContractError(error, t)}
@@ -118,7 +118,7 @@ function runConversations(run: GraphWorkflowRun): WorkflowNodeConversationItem[]
return Object.values(run.nodeStates).flatMap((state) => state.conversation ?? [])
}
-function RunWorkspace({ tenantId, run, readOnly, leading, refreshProjectRuns }: { tenantId: string; run: GraphWorkflowRun; readOnly: boolean; leading: ReactNode; refreshProjectRuns: (projectId: string) => void }) {
+function RunWorkspace({ tenantId, run, readOnly, hostedWorkspace, leading, refreshProjectRuns }: { tenantId: string; run: GraphWorkflowRun; readOnly: boolean; hostedWorkspace: WorkspacePanelState | undefined; leading: ReactNode; refreshProjectRuns: (projectId: string) => void }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const conversations = useMemo(() => runConversations(run), [run])
@@ -138,7 +138,8 @@ function RunWorkspace({ tenantId, run, readOnly, leading, refreshProjectRuns }:
const focusStatusSampleRef = useRef
(null)
const conversationByNodeId = useConversationByNode(conversations)
// The checkout every Agent node of the run shares; read again whenever a node or the run moves on.
- const workspace = useWorkspacePanel()
+ const ownWorkspace = useWorkspacePanel()
+ const workspace = hostedWorkspace ?? ownWorkspace
const workspaceSource = useMemo(() => ({ kind: 'run' as const, tenantId, runId: run.id }), [tenantId, run.id])
const workspaceRevision = `${run.status}:${Object.values(run.nodeStates)
.map((state) => state.status)
@@ -346,13 +347,13 @@ function RunWorkspace({ tenantId, run, readOnly, leading, refreshProjectRuns }:
在对话中追问
)}
- {!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) && setDecomposing(true)} disabled={splitting}
title={splitting ? '正在拆分,跑完后才能再拆' : '用工作流把这个任务拆成子任务'}>{splitting ? '拆分中…' : '拆分'}}
+ {(newestRun || selected) && 工作区}
{ setError(''); setEditing(true) }} disabled={save.isPending}>编辑
-
+
+ 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 = onSelect()}>
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 &&
{running ? '停止' : '结束会话'}
}
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)} />
showTab('runs')}>Agent 运行 {runs.length}
- {(decompositions.items.length > 0 || tab === 'workflows') && showWorkflows()}>工作流 {decompositions.items.length}}
+ {(workflowRuns.length > 0 || tab === 'workflows') && showWorkflows()}>工作流 {workflowRuns.length}}
showTab('discussion')}>讨论 {comments.length}
{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}>
+
+
+}
diff --git a/frontend-v2/src/features/collaboration/task-workflows.tsx b/frontend-v2/src/features/collaboration/task-workflows.tsx
index 17a357259..00b6e1db8 100644
--- a/frontend-v2/src/features/collaboration/task-workflows.tsx
+++ b/frontend-v2/src/features/collaboration/task-workflows.tsx
@@ -12,20 +12,27 @@ const STATUS = {
failed: { label: '失败', tone: 'cancelled' },
}
+/** One workflow run on the task: a split, or the demo's run that handles it. */
+export type TaskWorkflowRun = Pick & {
+ status: TaskDecomposition['status']
+ handling?: boolean
+}
+
/** What the run left on the task, in the words the page uses. */
-function outcome(d: TaskDecomposition): string {
+function outcome(d: TaskWorkflowRun): string {
+ if (d.handling) return d.status === 'succeeded' ? '处理完成' : d.status === 'failed' ? '运行失败' : '正在处理'
if (d.status === 'succeeded') return `拆出 ${d.createdCount} 个子任务`
if (d.status === 'failed') return d.failure || '工作流没有给出子任务。'
return '正在拆分'
}
-function WorkflowRow({ decomposition: d }: { decomposition: TaskDecomposition }) {
+function WorkflowRow({ run: d }: { run: TaskWorkflowRun }) {
const status = STATUS[d.status]
return <>
{status.label}
{d.workflowName}
- 拆分 · {d.startedByName} 发起 · {formatTime(d.createdAt)}
+ {d.handling ? '处理' : '拆分'} · {d.startedByName} 发起 · {formatTime(d.createdAt)}
{outcome(d)}
>
@@ -36,11 +43,11 @@ 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, workspace, onSelect }: {
+export function TaskWorkflows({ tenantId, runs, selected, workspace, onSelect }: {
tenantId: string
- decompositions: TaskDecomposition[]
+ runs: TaskWorkflowRun[]
/** The run opened; undefined shows the list. */
- selected: TaskDecomposition | undefined
+ selected: TaskWorkflowRun | undefined
/** The page's workspace panel, which the run's changed files open. */
workspace: WorkspacePanelState
onSelect: (workflowRunId?: string) => void
@@ -53,12 +60,12 @@ export function TaskWorkflows({ tenantId, decompositions, selected, workspace, o
}
- if (decompositions.length === 0) {
+ if (runs.length === 0) {
return
}
- return {decompositions.map(d => -
+ return
{runs.map(d => -
{d.workflowRunId
- ? onSelect(d.workflowRunId)}>
- :
}
+ ? onSelect(d.workflowRunId)}>
+ :
}
)}
}
diff --git a/frontend-v2/src/lib/demo/registry.ts b/frontend-v2/src/lib/demo/registry.ts
index ebf216360..19a30f518 100644
--- a/frontend-v2/src/lib/demo/registry.ts
+++ b/frontend-v2/src/lib/demo/registry.ts
@@ -26,4 +26,10 @@ export const demoMocks: readonly DemoMock[] = [
covers: 'Dashboard team progress and Agent output',
replacedBy: 'A task aggregation endpoint: task status, Agent runs and output per squad',
},
+ {
+ module: 'task-workflows.ts',
+ scene: 'lead',
+ covers: 'Handling a task with a workflow: the entry, the requirement, and the run belonging to the task',
+ replacedBy: 'Task-owned workflow runs: start one on a task with a requirement, list them, and move the task when they end',
+ },
]
diff --git a/frontend-v2/src/lib/demo/task-workflows.ts b/frontend-v2/src/lib/demo/task-workflows.ts
new file mode 100644
index 000000000..e82268bdf
--- /dev/null
+++ b/frontend-v2/src/lib/demo/task-workflows.ts
@@ -0,0 +1,84 @@
+/**
+ * Handling a task with a workflow, used only when `VITE_DEMO_MOCK=1` (see `flags.ts`).
+ *
+ * The run itself is real: it goes through the workflow API with the requirement as its input. What is
+ * faked is that it belongs to a task, which the server cannot record yet: the run carries the task's
+ * title as its name in the task's project, and only the task's assignee can start one, so that
+ * person is its starter. Nothing moves the task when the run ends; the assignee submits it by hand.
+ */
+import { useQuery } from '@tanstack/react-query'
+import { createWorkflowRun, listWorkflowRuns, listWorkflows, startWorkflowRun, updateWorkflowRunInput } from '@/lib/workflows/api/generated'
+import { demoMockEnabled } from './flags'
+
+/** The workflow the dialog picks when the workspace has it. */
+export const DEMO_HANDLING_WORKFLOW = '需求开发'
+
+/** A task the demo can hand to a workflow. */
+export interface DemoHandledTask {
+ id: string
+ title: string
+ description: string
+ projectId: string
+ assignee?: { displayName: string } | null
+}
+
+/** One workflow run that handles a task, in the shape the task page lists its workflow runs. */
+export interface DemoTaskWorkflowRun {
+ id: string
+ workflowName: string
+ workflowRunId: string
+ status: 'running' | 'succeeded' | 'failed'
+ failure: string
+ createdCount: number
+ startedByName: string
+ createdAt: string
+ handling: true
+}
+
+const statusOf = (status: string): DemoTaskWorkflowRun['status'] =>
+ status === 'succeeded' ? 'succeeded' : status === 'failed' || status === 'cancelled' ? 'failed' : 'running'
+
+export const demoTaskRunsKey = (tenantId: string, taskId: string) => ['demo-task-workflow-runs', tenantId, taskId]
+
+/** The workflow runs handling this task, newest first; read again while one still executes. */
+export function useDemoTaskRuns(tenantId: string, task: DemoHandledTask | undefined) {
+ const query = useQuery({
+ queryKey: demoTaskRunsKey(tenantId, task?.id ?? ''),
+ enabled: demoMockEnabled() && !!tenantId && !!task,
+ queryFn: async ({ signal }): Promise => {
+ const [{ runs }, { workflows }] = await Promise.all([
+ listWorkflowRuns(tenantId, { projectId: task!.projectId }, undefined, signal),
+ listWorkflows(tenantId, undefined, signal),
+ ])
+ return runs
+ .filter(run => run.name === task!.title)
+ .sort((a, b) => b.createdAt - a.createdAt)
+ .map(run => ({
+ id: run.id,
+ workflowName: workflows.find(w => w.id === run.workflowId)?.name ?? '工作流',
+ workflowRunId: run.id,
+ status: statusOf(run.status),
+ failure: '',
+ createdCount: 0,
+ startedByName: task!.assignee?.displayName ?? '',
+ createdAt: new Date(run.createdAt).toISOString(),
+ handling: true,
+ }))
+ },
+ refetchInterval: q => (q.state.data?.some(run => run.status === 'running') ? 3000 : false),
+ })
+ return query.data ?? []
+}
+
+/** The requirement the dialog opens with: the task as written, and room for the assignee's own judgment. */
+export function demoPromptFor(task: DemoHandledTask): string {
+ return [`# ${task.title}`, task.description, '## 约束\n- '].filter(Boolean).join('\n\n')
+}
+
+/** Starts a published workflow on the task with `prompt` as its input, and returns the run's id. */
+export async function startDemoTaskWorkflow(tenantId: string, task: DemoHandledTask, workflowId: string, prompt: string): Promise {
+ const { run } = await createWorkflowRun(tenantId, workflowId, { projectId: task.projectId, name: task.title })
+ await updateWorkflowRunInput(tenantId, run.id, { input: prompt })
+ await startWorkflowRun(tenantId, run.id)
+ return run.id
+}
From e753753af2b219fc5ecb81c3d35e3355a95d3701 Mon Sep 17 00:00:00 2001
From: "Ruihao (Ray) Zhang"
Date: Fri, 6 Nov 2026 07:19:47 +0000
Subject: [PATCH 6/8] fix(v2): stop pointing a finished run at the rerun button
A read-only run view has no such button, and the result card shows there
too.
---
frontend-v2/src/features/chat/workflow-run/translations.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend-v2/src/features/chat/workflow-run/translations.ts b/frontend-v2/src/features/chat/workflow-run/translations.ts
index 9d414e446..a94e62654 100644
--- a/frontend-v2/src/features/chat/workflow-run/translations.ts
+++ b/frontend-v2/src/features/chat/workflow-run/translations.ts
@@ -181,7 +181,7 @@ export const workflowRunTranslations = {
"workflowRun.result.title.failed": "运行失败",
"workflowRun.result.title.cancelled": "已取消",
"workflowRun.result.body.succeeded":
- "本轮工作流已成功结束。可在路径中回顾各步骤,或从顶栏再跑一次。",
+ "本轮工作流已成功结束。可在路径中回顾各步骤。",
"workflowRun.result.body.failed":
"运行在某个步骤失败后结束。打开路径查看失败节点与详情。",
"workflowRun.result.body.cancelled":
@@ -189,7 +189,7 @@ export const workflowRunTranslations = {
"workflowRun.result.showOverview": "查看全图",
"workflowRun.result.openArtifacts": "打开最近成果",
"workflowRun.result.pathHint":
- "点路径上的「结果」或步骤可切换回顾;再跑一次请用顶栏按钮。",
+ "点路径上的「结果」或步骤可切换回顾。",
"workflowRun.result.pathChip": "结果",
"workflowRun.result.finishedToastTitle": "运行已结束",
"workflowRun.result.finishedToastDescription": "可切到舞台查看结果摘要。",
From 2cf7a564ba5064a125a895710f96018982d8d818 Mon Sep 17 00:00:00 2001
From: "Ruihao (Ray) Zhang"
Date: Fri, 6 Nov 2026 07:19:47 +0000
Subject: [PATCH 7/8] feat(video): add the team collaboration shots
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three shots on one environment: 陈默 splits a requirement and assigns it,
苏芮 handles a task with a workflow and submits it, 陈默 reviews 苏芮's
changes and accepts them.
---
frontend-v2/video/src/shots/index.ts | 8 +-
frontend-v2/video/src/shots/team-human.ts | 81 ++++++++++++++++++++
frontend-v2/video/src/shots/team-review.ts | 70 +++++++++++++++++
frontend-v2/video/src/shots/team-split.ts | 87 ++++++++++++++++++++++
4 files changed, 244 insertions(+), 2 deletions(-)
create mode 100644 frontend-v2/video/src/shots/team-human.ts
create mode 100644 frontend-v2/video/src/shots/team-review.ts
create mode 100644 frontend-v2/video/src/shots/team-split.ts
diff --git a/frontend-v2/video/src/shots/index.ts b/frontend-v2/video/src/shots/index.ts
index 9250d2ce6..3e1588557 100644
--- a/frontend-v2/video/src/shots/index.ts
+++ b/frontend-v2/video/src/shots/index.ts
@@ -1,9 +1,13 @@
import type { Shot } from '../record/shot.ts'
-import { leadDecompose } from './lead-decompose.ts'
import { oraOverview } from './ora-overview.ts'
+import { teamHuman } from './team-human.ts'
+import { teamReview } from './team-review.ts'
+import { teamSplit } from './team-split.ts'
/** Every shot the recorder knows, by name, in the order the film plays them. */
export const shots: Record = {
[oraOverview.name]: oraOverview,
- [leadDecompose.name]: leadDecompose,
+ [teamSplit.name]: teamSplit,
+ [teamHuman.name]: teamHuman,
+ [teamReview.name]: teamReview,
}
diff --git a/frontend-v2/video/src/shots/team-human.ts b/frontend-v2/video/src/shots/team-human.ts
new file mode 100644
index 000000000..ec9790a26
--- /dev/null
+++ b/frontend-v2/video/src/shots/team-human.ts
@@ -0,0 +1,81 @@
+import type { Shot } from '../record/shot.ts'
+
+/**
+ * 苏芮 takes the subtask 陈默 assigned and chooses how to do it, this time with a workflow. Recorded after
+ * `team-split` in the same environment, while the Agent's subtask is still running.
+ */
+export const teamHuman: Shot = {
+ name: 'team-human',
+ scene: 'lead',
+ account: 'su.rui@demo.ora-space.dev',
+ start: '/collaboration?view=mine',
+ async ready(page) {
+ await page.getByRole('listitem', { name: '待处理列' }).getByRole('button', { name: /^查看任务/ }).first().waitFor()
+ },
+ async play(d, page) {
+ d.slide({
+ eyebrow: '苏芮',
+ title: '灵活的任务执行方式',
+ narration: '执行方式由成员选择:',
+ items: [
+ { title: '人工开发', body: '直接修改代码,把控实现细节', narration: '人工开发,' },
+ { title: 'Agent 协作', body: '调整提示词,由 Agent 执行', narration: '与 Agent 协作,' },
+ { title: '工作流执行', body: '复用团队沉淀的标准流程', narration: '或运行工作流。' },
+ ],
+ })
+
+ d.say({ narration: '本次,苏芮选择开发工作流。' })
+ await d.focus('page', 1.3)
+ await d.click(page.getByRole('listitem', { name: '待处理列' }).getByRole('button', { name: /^查看任务/ }).first())
+ await d.hold(500)
+ await d.click(page.getByRole('button', { name: '用工作流处理' }))
+ const dialog = page.getByRole('dialog')
+ const prompt = dialog.getByLabel('需求')
+ await prompt.waitFor()
+ await d.hold(600)
+
+ d.say({ narration: '补充需求细节与约束条件,明确验收标准。' })
+ await d.focus(prompt, 1.6)
+ await d.click(prompt)
+ await page.keyboard.press('Control+End')
+ await page.keyboard.type('沿用现有技术栈,不新增依赖;改动要带测试。', { delay: 45 })
+ await d.hold(1200)
+ await d.focus('page', 1.3)
+ await d.click(dialog.getByRole('button', { name: '启动工作流' }))
+
+ const run = page.getByRole('region', { name: '工作流运行' })
+ // The window freezes for the explanation, so it has to show the run, not its loading line.
+ await run.getByText('执行路径').waitFor()
+ await page.waitForTimeout(600) // The screencast lags the page, and the frozen frame is the last one it sent.
+ await d.explain({
+ title: '开发工作流',
+ narration: '工作流按需求自动执行:',
+ items: [
+ { title: '制定计划', body: '分析代码库,明确实现方案与验收清单', narration: '制定计划,' },
+ { title: '实现需求', body: '完成开发并补充测试', narration: '实现需求,' },
+ { title: '验证交付', body: '运行测试并生成交付说明', narration: '验证交付。' },
+ ],
+ })
+
+ await d.wait(run.getByText('成功').first(), { rate: 20 })
+ await d.hold(600)
+
+ d.say({ narration: '执行完成后,苏芮在工作区审查变更,提交验收。' })
+ await d.click(run.getByRole('group', { name: '运行视图' }).getByRole('button', { name: '全图' }))
+ await d.hold(200)
+ await d.click(page.getByRole('button', { name: '工作区', exact: true }))
+ const panel = page.getByRole('complementary', { name: '工作区' })
+ const file = panel.getByRole('button').filter({ hasText: /\.mjs/ }).first()
+ await file.waitFor()
+ await d.focus(panel, 1.5)
+ await d.hold(300)
+ await d.click(file)
+ await d.hold(1000)
+ await d.focus('page', 1.3)
+ await d.click(page.getByRole('button', { name: '工作区', exact: true }))
+ await d.hold(200)
+ await d.click(page.getByRole('button', { name: '提交验收' }))
+ await page.locator('.mc-task-bar .mc-status', { hasText: '待验收' }).waitFor()
+ await d.hold(400)
+ },
+}
diff --git a/frontend-v2/video/src/shots/team-review.ts b/frontend-v2/video/src/shots/team-review.ts
new file mode 100644
index 000000000..17858646a
--- /dev/null
+++ b/frontend-v2/video/src/shots/team-review.ts
@@ -0,0 +1,70 @@
+import type { Shot } from '../record/shot.ts'
+
+/**
+ * 陈默 comes back to the board and accepts 苏芮's work, which closes the third part. Recording
+ * starts only once both subtasks of `team-split` are waiting for review.
+ */
+export const teamReview: Shot = {
+ name: 'team-review',
+ scene: 'lead',
+ account: 'chen.mo@demo.ora-space.dev',
+ start: '/collaboration?view=all',
+ async ready(page) {
+ const review = page.getByRole('listitem', { name: '待验收列' })
+ await review.getByText('拆分自').nth(1).waitFor({ timeout: 300_000 })
+ },
+ async play(d, page) {
+ const review = page.getByRole('listitem', { name: '待验收列' })
+
+ d.slide({
+ eyebrow: '陈默',
+ title: '成果验收',
+ narration: '成果统一进入「待验收」,由负责人审查。',
+ })
+
+ await d.focus('page', 1)
+ await d.hold(400)
+ await d.mark(review, { label: '待验收' })
+
+ d.say({ narration: '陈默审查苏芮提交的变更,确认通过。' })
+ await d.focus('page', 1.3)
+ const filter = page.getByLabel('指派对象')
+ const assignee = await filter.locator('option', { hasText: '苏芮' }).first().textContent()
+ await d.select(filter, assignee!)
+ await d.hold(400)
+ await d.click(review.getByRole('button', { name: /^查看任务/ }).filter({ hasText: '拆分自' }).first())
+ const workspace = page.getByRole('button', { name: '工作区', exact: true })
+ await workspace.waitFor()
+ await d.hold(300)
+ await d.click(workspace)
+ const panel = page.getByRole('complementary', { name: '工作区' })
+ const file = panel.getByRole('button').filter({ hasText: /\.mjs/ }).first()
+ await file.waitFor()
+ await d.focus(panel, 1.5)
+ await d.hold(300)
+ await d.click(file)
+ await d.hold(1000)
+ await d.focus('page', 1.3)
+ await d.click(workspace)
+ await d.hold(200)
+ await d.click(page.getByRole('button', { name: '确认完成' }))
+ const done = page.locator('.mc-task-bar .mc-status', { hasText: '已完成' })
+ await done.waitFor()
+ await d.hold(300)
+
+ await d.mark(done, { label: '已完成', narration: '验收通过,任务完成。' })
+ await d.hold(400)
+
+ d.slide({
+ eyebrow: '协作闭环',
+ title: '团队协同,人机共创',
+ narration: '人把控方向,人机协同交付:',
+ items: [
+ { title: '陈默 · 拆分需求', body: '工作流生成子任务并完成分配', narration: '拆分需求,' },
+ { title: 'OpenCode · 自主执行', body: 'Agent 独立完成开发任务', narration: '自主执行,' },
+ { title: '苏芮 · 人机协作', body: '选用工作流,完成开发并提交', narration: '人机协作,' },
+ { title: '陈默 · 审查验收', body: '审查变更,确认交付', narration: '审查验收。' },
+ ],
+ })
+ },
+}
diff --git a/frontend-v2/video/src/shots/team-split.ts b/frontend-v2/video/src/shots/team-split.ts
new file mode 100644
index 000000000..50ddfc824
--- /dev/null
+++ b/frontend-v2/video/src/shots/team-split.ts
@@ -0,0 +1,87 @@
+import type { Shot } from '../record/shot.ts'
+
+const title = '看板支持按负责人筛选和批量指派'
+const description = [
+ '小队人多以后,看板上很难找到自己的任务,Tech Lead 分派也要一张张点。',
+ '验收:看板顶部可以按负责人筛选;可以多选卡片一次指派给同一个人或 Agent;筛选条件刷新后保留。',
+].join('\n')
+
+/**
+ * The third part of the film opens here: 陈默 splits a big requirement with the 需求拆分 workflow,
+ * starts an Agent on one subtask and hands another to 苏芮. Her shot and the review pick up from
+ * this one in the same environment. Subtasks are taken by position, since the model writes them.
+ */
+export const teamSplit: Shot = {
+ name: 'team-split',
+ scene: 'lead',
+ account: 'chen.mo@demo.ora-space.dev',
+ start: '/collaboration',
+ async ready(page) {
+ await page.getByRole('list', { name: '任务看板' }).waitFor()
+ },
+ async play(d, page) {
+ d.slide({
+ eyebrow: '团队协作',
+ title: '团队协同,人机共创',
+ subtitle: '任务拆分、分工执行、验收闭环',
+ narration: '团队协同,人机共创:复杂需求由人与 Agent 分工完成。',
+ })
+
+ d.say({ narration: '陈默创建需求,记录目标与验收标准。' })
+ await d.focus('page', 1.3)
+ await d.click(page.getByRole('button', { name: '新建任务' }).first())
+ const dialog = page.getByRole('dialog')
+ await d.type(dialog.getByLabel('任务标题'), title, { rate: 5 })
+ await d.select(dialog.getByLabel('所属项目'), '协作看板服务')
+ await d.type(dialog.getByLabel('描述(可选)'), description, { rate: 20 })
+ await d.click(dialog.getByRole('button', { name: '创建任务' }))
+
+ d.say({ narration: '通过「拆分」,调用团队的「需求拆分」工作流。' })
+ await page.getByRole('heading', { level: 2, name: title }).waitFor()
+ await d.click(page.getByRole('button', { name: '拆分', exact: true }))
+ const split = page.getByRole('dialog')
+ await d.hold(300)
+ await d.click(split.getByRole('button', { name: '开始拆分' }))
+
+ const run = page.getByRole('region', { name: '工作流运行' })
+ // The window freezes for the explanation, so it has to show the run, not its loading line.
+ await run.getByText('执行路径').waitFor()
+ await page.waitForTimeout(600) // The screencast lags the page, and the frozen frame is the last one it sent.
+ await d.explain({
+ title: '需求拆分流程',
+ narration: '工作流自动完成三个步骤:',
+ items: [
+ { title: '解析需求', body: '提取目标、范围与验收标准', narration: '解析需求,' },
+ { title: '拆分任务', body: '按可独立交付的粒度划分', narration: '拆分任务,' },
+ { title: '生成子任务', body: '自动写入任务看板', narration: '生成子任务。' },
+ ],
+ })
+
+ // Starting opens the run with the subtasks folded; they are the payoff, so the shot opens them.
+ const expand = page.getByRole('button', { name: '展开子任务' })
+ await d.wait(expand, { rate: 20, timeout: 120_000 })
+ await d.click(expand)
+ const subtasks = page.getByRole('list', { name: '子任务' })
+ await subtasks.waitFor()
+ await d.mark(subtasks, { label: '子任务', narration: '子任务已生成。' })
+ await d.hold(1400)
+
+ d.say({ narration: '可直接执行的任务,指派给 Agent,即刻启动。' })
+ await d.click(subtasks.getByRole('link').nth(0))
+ const assignee = page.getByLabel('指派给')
+ await assignee.waitFor()
+ await d.select(assignee, 'OpenCode')
+ await page.getByText('当前:OpenCode').waitFor()
+ await d.click(page.getByRole('button', { name: '开始处理', exact: true }))
+ await d.hold(600)
+
+ d.say({ narration: '需要人工把关的任务,指派给苏芮。' })
+ await d.click(page.getByRole('link', { name: title }))
+ await subtasks.waitFor()
+ await d.click(subtasks.getByRole('link').nth(1))
+ await assignee.waitFor()
+ await d.select(assignee, '苏芮')
+ await page.getByText('当前:苏芮').waitFor()
+ await d.hold(800)
+ },
+}
From db5f2bcd25aa7d9c9eaf216eb6338eb37ac1b246 Mon Sep 17 00:00:00 2001
From: "Ruihao (Ray) Zhang"
Date: Fri, 6 Nov 2026 07:30:58 +0000
Subject: [PATCH 8/8] fix(video): number the team chapter card like the other
segments
---
frontend-v2/video/src/shots/team-split.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend-v2/video/src/shots/team-split.ts b/frontend-v2/video/src/shots/team-split.ts
index 50ddfc824..90b391962 100644
--- a/frontend-v2/video/src/shots/team-split.ts
+++ b/frontend-v2/video/src/shots/team-split.ts
@@ -21,7 +21,7 @@ export const teamSplit: Shot = {
},
async play(d, page) {
d.slide({
- eyebrow: '团队协作',
+ eyebrow: '03 / 团队协作',
title: '团队协同,人机共创',
subtitle: '任务拆分、分工执行、验收闭环',
narration: '团队协同,人机共创:复杂需求由人与 Agent 分工完成。',