Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion frontend-v2/src/components/workspace-panel/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import {
getChatWorkspaceDiff,
getChatWorkspaceEntries,
getChatWorkspaceFile,
getTaskRunWorkspaceChanges,
getTaskRunWorkspaceDiff,
getTaskRunWorkspaceEntries,
getTaskRunWorkspaceFile,
getWorkflowRunWorkspaceChanges,
getWorkflowRunWorkspaceDiff,
getWorkflowRunWorkspaceEntries,
Expand All @@ -15,10 +19,11 @@ import {
type WorkspaceFile,
} from '@/lib/workspace-api/generated'

/** Whose checkout the panel shows: a conversation's, or the one a workflow run's Agents share. */
/** Whose checkout the panel shows: a conversation's, a task run's, or the one a workflow run's Agents share. */
export type WorkspaceSource =
| { kind: 'chat'; tenantId: string; conversationId: string }
| { kind: 'run'; tenantId: string; runId: string }
| { kind: 'task'; tenantId: string; taskId: string; runId: string }

/** The four read-only views of one checkout, and the query-key prefix every one of them shares. */
export interface WorkspaceReader {
Expand All @@ -41,6 +46,16 @@ export function workspaceReader(source: WorkspaceSource): WorkspaceReader {
diff: (path) => getChatWorkspaceDiff(t, c, { path }),
}
}
if (source.kind === 'task') {
const { tenantId: t, taskId: k, runId: r } = source
return {
key: ['workspace', 'task', t, k, r],
entries: (path) => getTaskRunWorkspaceEntries(t, k, r, path ? { path } : undefined),
file: (path) => getTaskRunWorkspaceFile(t, k, r, { path }),
changes: () => getTaskRunWorkspaceChanges(t, k, r),
diff: (path) => getTaskRunWorkspaceDiff(t, k, r, { path }),
}
}
const { tenantId: t, runId: r } = source
const demo = cdaseWorkspaceReader(t, r)
if (demo) return demo
Expand Down
3 changes: 3 additions & 0 deletions frontend-v2/src/components/workspace-panel/use-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useWorkspacePanel>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,6 +91,7 @@ export function WorkspacePanel({
</Button>
))}
</div>
{caption && <span className="min-w-0 truncate text-xs text-muted-foreground" title={caption}>{caption}</span>}
<div className="ml-auto flex items-center gap-0.5">
<Button size="icon-sm" variant="ghost" aria-label="刷新" onClick={() => void queryClient.invalidateQueries({ queryKey: reader.key })}>
<RefreshCwIcon />
Expand Down
4 changes: 2 additions & 2 deletions frontend-v2/src/features/chat/workflow-run/demo-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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<string, unknown> }) =>
updateRunInput(tenantId, runId, input, variables),
Expand Down
21 changes: 20 additions & 1 deletion frontend-v2/src/features/chat/workflow-run/run-flow.test.tsx
Original file line number Diff line number Diff line change
@@ -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, vi } from 'vitest'
Expand All @@ -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'

Expand Down Expand Up @@ -101,6 +102,24 @@ 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()

const workspace = { open: false, setOpen: () => {}, request: undefined, navigation: { openDiff: () => {} } }
render(<MemoryRouter><QueryClientProvider client={new QueryClient()}><TooltipProvider><RunPane runId={runId} tenantId={tenantId} readOnly workspace={workspace} /></TooltipProvider></QueryClientProvider></MemoryRouter>)
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()
})

it('runs a published workflow on v2-server and follows it on the Theater', async () => {
const user = userEvent.setup()
await renderPage()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
41 changes: 24 additions & 17 deletions frontend-v2/src/features/chat/workflow-run/run-pane.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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'
Expand All @@ -16,7 +16,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'
Expand All @@ -28,6 +28,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'
Expand All @@ -48,9 +49,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, `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 }: { runId: string }) {
const tenantId = useChatStore((s) => s.tenantId) ?? ''
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])
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
Expand All @@ -64,7 +68,9 @@ export default function RunPane({ runId }: { runId: string }) {
<I18nextProvider i18n={runI18n}>
<QueryClientProvider client={queryClient}>
<ContractsClientProvider client={client}>
<LoadedRun key={runId} tenantId={tenantId} runId={runId} refreshProjectRuns={refreshProjectRuns} />
<RunTenantContext.Provider value={tenantId}>
<LoadedRun key={runId} tenantId={tenantId} runId={runId} readOnly={readOnly} workspace={workspace} leading={leading} refreshProjectRuns={refreshProjectRuns} />
</RunTenantContext.Provider>
<RunToaster />
</ContractsClientProvider>
</QueryClientProvider>
Expand All @@ -81,7 +87,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, 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 !== '' })
Expand All @@ -96,7 +102,7 @@ function LoadedRun({ tenantId, runId, refreshProjectRuns }: { tenantId: string;
connected: setFollowed,
})
const error = snapshot.error ?? run.error
if (run.data) return <RunWorkspace tenantId={tenantId} run={run.data} refreshProjectRuns={refreshProjectRuns} />
if (run.data) return <RunWorkspace tenantId={tenantId} run={run.data} readOnly={readOnly} hostedWorkspace={workspace} leading={leading} refreshProjectRuns={refreshProjectRuns} />
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
<p>{error === null ? '正在读取运行…' : isMissing(error) ? '这次运行已经不在了。' : localizeContractError(error, t)}</p>
Expand All @@ -111,7 +117,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, 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])
Expand All @@ -138,7 +144,8 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string;
const focusStatusSampleRef = useRef<TheaterFocusStatusSample | null>(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)
Expand Down Expand Up @@ -275,7 +282,7 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string;
return (
<WorkspaceNavigationProvider value={workspace.navigation}>
<header className="flex min-h-12 shrink-0 flex-wrap items-center gap-2 border-b px-3 py-2 sm:h-12 sm:flex-nowrap sm:px-4 sm:py-0">
<ShowSidebarButton />
{leading ?? (!readOnly && <ShowSidebarButton />)}
<WorkflowIcon className="size-4 shrink-0 text-muted-foreground" />
<h2 className="flex min-w-0 flex-1 text-sm font-medium">
<FadeText text={run.name} />
Expand Down Expand Up @@ -306,19 +313,19 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string;
<span className="hidden sm:inline">{t('workflowRun.viewMode.overview')}</span>
</Button>
</div>
{run.status === 'pending' && (
{!readOnly && run.status === 'pending' && (
<Button size="sm" className="h-7 px-2.5 text-xs" aria-label="启动" onClick={() => setStartOpen(true)} disabled={busy}>
<PlayIcon />
<span className="hidden sm:inline">启动</span>
</Button>
)}
{(run.status === 'running' || run.status === 'awaiting_input') && (
{!readOnly && (run.status === 'running' || run.status === 'awaiting_input') && (
<Button variant="outline" size="sm" className="h-7 px-2.5 text-xs" aria-label="停止" onClick={() => act(() => cancelRun(tenantId, run.id))} disabled={busy}>
<SquareIcon />
<span className="hidden sm:inline">停止</span>
</Button>
)}
{finished && (
{!readOnly && finished && (
<Button
variant="outline"
size="sm"
Expand All @@ -337,7 +344,7 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string;
<span className="hidden sm:inline">再跑一次</span>
</Button>
)}
{run.status === 'succeeded' && run.finalOutput && (
{!readOnly && run.status === 'succeeded' && run.finalOutput && (
<Button variant="outline" size="sm" className="h-7 px-2.5 text-xs" aria-label="在对话中追问" onClick={() => {
const store = useChatStore.getState()
const draft = store.drafts[newChatKey] ?? emptyDraft
Expand All @@ -351,17 +358,17 @@ function RunWorkspace({ tenantId, run, refreshProjectRuns }: { tenantId: string;
<span className="hidden sm:inline">在对话中追问</span>
</Button>
)}
<WorkspaceToggle open={workspace.open} toggle={() => workspace.setOpen(!workspace.open)} />
{!hostedWorkspace && <WorkspaceToggle open={workspace.open} toggle={() => workspace.setOpen(!workspace.open)} />}
</div>
</header>
<WorkspaceSplit
id="run"
panel={
workspace.open && <WorkspacePanel source={workspaceSource} revision={workspaceRevision} request={workspace.request} onClose={() => workspace.setOpen(false)} />
!hostedWorkspace && workspace.open && <WorkspacePanel source={workspaceSource} revision={workspaceRevision} request={workspace.request} onClose={() => workspace.setOpen(false)} />
}
>
<div ref={stageAreaRef} aria-label={viewMode === 'theater' ? '运行舞台' : undefined} className="relative flex min-h-0 min-w-0 flex-1 flex-col">
{run.status === 'pending' && viewMode === 'overview' && (
{!readOnly && run.status === 'pending' && viewMode === 'overview' && (
<p className="absolute inset-x-0 top-3 z-10 mx-auto w-fit rounded-md border bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm">
还没开始:点右上角「启动」,填写输入后运行
</p>
Expand Down
12 changes: 12 additions & 0 deletions frontend-v2/src/features/chat/workflow-run/run-tenant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react'
import { useChatStore } from '../store'

/** The tenant of a run view that a page outside chat hosts; chat itself leaves it unset. */
export const RunTenantContext = createContext<string | undefined>(undefined)

/** The tenant a run view reads: its hosting page's, otherwise the one chat is connected to. */
export function useRunTenant(): string {
const hosted = useContext(RunTenantContext)
const connected = useChatStore((s) => s.tenantId)
return hosted ?? connected ?? ''
}
4 changes: 2 additions & 2 deletions frontend-v2/src/features/chat/workflow-run/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,15 @@ export const workflowRunTranslations = {
"workflowRun.result.title.failed": "运行失败",
"workflowRun.result.title.cancelled": "已取消",
"workflowRun.result.body.succeeded":
"本轮工作流已成功结束。可在路径中回顾各步骤,或从顶栏再跑一次。",
"本轮工作流已成功结束。可在路径中回顾各步骤。",
"workflowRun.result.body.failed":
"运行在某个步骤失败后结束。打开路径查看失败节点与详情。",
"workflowRun.result.body.cancelled":
"运行已停止。已执行的步骤仍可在路径中回顾。",
"workflowRun.result.showOverview": "查看全图",
"workflowRun.result.openArtifacts": "打开最近成果",
"workflowRun.result.pathHint":
"点路径上的「结果」或步骤可切换回顾;再跑一次请用顶栏按钮。",
"点路径上的「结果」或步骤可切换回顾。",
"workflowRun.result.pathChip": "结果",
"workflowRun.result.finishedToastTitle": "运行已结束",
"workflowRun.result.finishedToastDescription": "可切到舞台查看结果摘要。",
Expand Down
Loading
Loading