From 0db422f98af5fea810949b148d04db1a38f5ebd0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 14:24:13 +0000 Subject: [PATCH 1/3] Fix Workflow stage UI and keep node replies off Chief chat Align flowchart wires with the zero-width plane, drop the stage message composer, absorb ephemeral step replies in middleware, and animate the running node on the graph. Fixes #1459 Co-authored-by: Danyang Chen --- changelog.d/1459.fixed.md | 1 + core/client.js | 14 +- core/src/client/styles.js | 6 +- core/src/client/workflows.js | 6 +- docs/subsystems/workflow.md | 6 +- patches/ds-bot/0003-flybot-api-client.patch | 8 +- patches/ds-bot/0008-client-bundle.patch | 16 ++- patches/ds-bot/files/src/client/workflows.js | 6 +- plugins/workflow/client.js | 128 ++++++++----------- plugins/workflow/index.js | 12 +- plugins/workflow/src/service.js | 2 +- plugins/workflow/test/client.test.js | 47 +++++++ plugins/workflow/test/ephemeral.test.js | 9 +- 13 files changed, 149 insertions(+), 112 deletions(-) create mode 100644 changelog.d/1459.fixed.md diff --git a/changelog.d/1459.fixed.md b/changelog.d/1459.fixed.md new file mode 100644 index 000000000..f95e38106 --- /dev/null +++ b/changelog.d/1459.fixed.md @@ -0,0 +1 @@ +Workflow stage: fix flowchart wire alignment, compact sidebar thumbnails, a breathing border on the running node, no message composer on the stage, and stop ephemeral node replies from appearing in persistent Bot chats (e.g. Chief). Progress stays on the workflow row and graph. ([#1459](https://github.com/ThinkFlowLab/FlyBot/issues/1459)) diff --git a/core/client.js b/core/client.js index 2067b68a6..1482d2479 100644 --- a/core/client.js +++ b/core/client.js @@ -4242,11 +4242,13 @@ button.bt-set-row:hover{background:var(--bt-hover)} .bt-wf-prompt{display:flex;flex-direction:column;gap:4px;font-size:12px;min-width:0} .bt-wf-prompt textarea{width:100%;box-sizing:border-box;border-radius:8px;border:1px solid var(--bt-line);background:var(--bt-card);color:inherit;font:inherit;font-size:12px;padding:6px 8px;resize:vertical} .bt-wf-plane{position:relative;width:0;margin:16px auto 0} -.bt-wf-wires{position:absolute;left:0;top:0;overflow:visible;pointer-events:none} +.bt-wf-wires{position:absolute;left:calc(-1 * var(--bt-wf-wire-span,480px));top:0;width:calc(2 * var(--bt-wf-wire-span,480px));overflow:visible;pointer-events:none} .bt-wf-wires path{fill:none;stroke:var(--bt-ink-3);stroke-width:1.6} .bt-wf-wires marker path{fill:var(--bt-ink-3);stroke:none} .bt-wf-node{position:absolute;width:168px;box-sizing:border-box;border:1px solid var(--bt-line);background:var(--bt-card);border-radius:12px;padding:8px 9px;display:flex;align-items:center;gap:8px} -.bt-wf-node.running{border-color:var(--bt-accent);box-shadow:0 0 0 3px rgba(77,107,254,.12)} +@keyframes bt-wf-node-live{0%,100%{border-color:var(--bt-accent);box-shadow:0 0 0 2px rgba(77,107,254,.18),0 0 0 0 rgba(77,107,254,.2)}50%{border-color:rgba(77,107,254,.85);box-shadow:0 0 0 3px rgba(77,107,254,.35),0 0 14px 2px rgba(77,107,254,.22)}} +.bt-wf-node.running{border-color:var(--bt-accent);animation:bt-wf-node-live 1.5s ease-in-out infinite} +@media (prefers-reduced-motion:reduce){.bt-wf-node.running{animation:none;box-shadow:0 0 0 3px rgba(77,107,254,.2)}} .bt-wf-node.done{border-color:rgba(22,163,74,.35)} .bt-wf-node.failed{border-color:#e5484d} .bt-wf-node.queued{opacity:.78} @@ -14450,9 +14452,10 @@ function hitNode(event) { return (nested ?? direct)?.dataset?.node || ""; } var WIRE_SPAN = 480; +var NODE_WIRE_OUT = 56; function wirePath(from, to) { const x1 = from.x + WIRE_SPAN; - const y1 = from.y + 48; + const y1 = from.y + NODE_WIRE_OUT; const x2 = to.x + WIRE_SPAN; const y2 = to.y; const mid = (y1 + y2) / 2; @@ -14520,7 +14523,7 @@ function GraphCanvas({ graph, editing, roster, onCommit }) { ))) : null, (0, import_react34.createElement)( "div", - { className: "bt-wf-plane", "data-plane": "1", style: { height } }, + { className: "bt-wf-plane", "data-plane": "1", style: { height, "--bt-wf-wire-span": `${WIRE_SPAN}px` } }, (0, import_react34.createElement)( "svg", { @@ -14528,8 +14531,7 @@ function GraphCanvas({ graph, editing, roster, onCommit }) { "aria-hidden": true, width: WIRE_SPAN * 2, height, - viewBox: `0 0 ${WIRE_SPAN * 2} ${height}`, - style: { left: -WIRE_SPAN, width: WIRE_SPAN * 2, height } + viewBox: `0 0 ${WIRE_SPAN * 2} ${height}` }, (0, import_react34.createElement)( "defs", diff --git a/core/src/client/styles.js b/core/src/client/styles.js index 4bfe8adf3..1c1e7b4fa 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -547,11 +547,13 @@ button.bt-set-row:hover{background:var(--bt-hover)} .bt-wf-prompt{display:flex;flex-direction:column;gap:4px;font-size:12px;min-width:0} .bt-wf-prompt textarea{width:100%;box-sizing:border-box;border-radius:8px;border:1px solid var(--bt-line);background:var(--bt-card);color:inherit;font:inherit;font-size:12px;padding:6px 8px;resize:vertical} .bt-wf-plane{position:relative;width:0;margin:16px auto 0} -.bt-wf-wires{position:absolute;left:0;top:0;overflow:visible;pointer-events:none} +.bt-wf-wires{position:absolute;left:calc(-1 * var(--bt-wf-wire-span,480px));top:0;width:calc(2 * var(--bt-wf-wire-span,480px));overflow:visible;pointer-events:none} .bt-wf-wires path{fill:none;stroke:var(--bt-ink-3);stroke-width:1.6} .bt-wf-wires marker path{fill:var(--bt-ink-3);stroke:none} .bt-wf-node{position:absolute;width:168px;box-sizing:border-box;border:1px solid var(--bt-line);background:var(--bt-card);border-radius:12px;padding:8px 9px;display:flex;align-items:center;gap:8px} -.bt-wf-node.running{border-color:var(--bt-accent);box-shadow:0 0 0 3px rgba(77,107,254,.12)} +@keyframes bt-wf-node-live{0%,100%{border-color:var(--bt-accent);box-shadow:0 0 0 2px rgba(77,107,254,.18),0 0 0 0 rgba(77,107,254,.2)}50%{border-color:rgba(77,107,254,.85);box-shadow:0 0 0 3px rgba(77,107,254,.35),0 0 14px 2px rgba(77,107,254,.22)}} +.bt-wf-node.running{border-color:var(--bt-accent);animation:bt-wf-node-live 1.5s ease-in-out infinite} +@media (prefers-reduced-motion:reduce){.bt-wf-node.running{animation:none;box-shadow:0 0 0 3px rgba(77,107,254,.2)}} .bt-wf-node.done{border-color:rgba(22,163,74,.35)} .bt-wf-node.failed{border-color:#e5484d} .bt-wf-node.queued{opacity:.78} diff --git a/core/src/client/workflows.js b/core/src/client/workflows.js index ac40b8718..42c257ebd 100644 --- a/core/src/client/workflows.js +++ b/core/src/client/workflows.js @@ -118,10 +118,11 @@ function hitNode(event) { // be negative. The wire box is shifted by SPAN so those points stay inside the // SVG viewport; overflow:visible is not reliable for a stroke on the edge. const WIRE_SPAN = 480 +const NODE_WIRE_OUT = 56 function wirePath(from, to) { const x1 = from.x + WIRE_SPAN - const y1 = from.y + 48 + const y1 = from.y + NODE_WIRE_OUT const x2 = to.x + WIRE_SPAN const y2 = to.y const mid = (y1 + y2) / 2 @@ -177,11 +178,10 @@ function GraphCanvas({ graph, editing, roster, onCommit }) { onCommit?.({ nodes: nodes.map((item) => (item.id === node.id ? { ...item, prompt } : item)), edges }) }, })))) : null, - h('div', { className: 'bt-wf-plane', 'data-plane': '1', style: { height } }, + h('div', { className: 'bt-wf-plane', 'data-plane': '1', style: { height, '--bt-wf-wire-span': `${WIRE_SPAN}px` } }, h('svg', { className: 'bt-wf-wires', 'aria-hidden': true, width: WIRE_SPAN * 2, height, viewBox: `0 0 ${WIRE_SPAN * 2} ${height}`, - style: { left: -WIRE_SPAN, width: WIRE_SPAN * 2, height }, }, h('defs', null, h('marker', { diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 815fcf25e..72391b21d 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -26,14 +26,14 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, | File | Role | | --- | --- | -| `plugins/workflow/index.js` | Plugin entry (`flybot-workflow`). Endpoints, `run_workflow`, `define_workflow`, the board listener, and the reply middleware that reports a step result. | +| `plugins/workflow/index.js` | Plugin entry (`flybot-workflow`). Endpoints, `run_workflow`, `define_workflow`, the board listener, and the reply middleware that ingests a step result and denies delivery to persistent Bot chats. | | `plugins/workflow/src/graph.js` | Nodes, edges, positions, and named inputs and outputs. A node starts when its forward predecessors are settled and a taken edge reaches it. Same-rank nodes run together. A forward cycle is refused. A loop edge is the only cycle. A `when` edge chooses a branch; the other is skipped. A required input with no upstream provider is refused before a run starts. | | `plugins/workflow/src/service.js` | The store, lanes (active runs, FIFO queue, terminal history), `maxConcurrent`, `rename`, `delete`, `saveGraph`, `define`, `runNamed`, `ingest`, `stepResult`. `rename` updates a workflow's name and optional description; `delete` removes the workflow, releases ephemeral Bots, and cleans logs/runs/routes. Factory template ids (e.g. `delivery`) are normal saved rows and may be deleted. A spawn hook makes one run Bot per node. Disposing the plugin releases every run Bot first and drops those ids from the saved record only once the release has finished, handing the release back as a promise the host awaits, so a shutdown that is cut short cannot leave hidden Bots that no state names (#1387). A released run Bot also gives its runner name back to the allocator instead of keeping it for the lifetime of the process. A dispatch that does not land is not marked handed. | | `plugins/workflow/src/ephemeral.js` | Builds each node's run Bot from its prompt, role, model and tools, and releases the ids the host returns. `permissionFor` picks `danger-full-access` when the node names a shell, otherwise `workspace-write`. | | `plugins/workflow/src/match.js` | Which open card matches a Workflow or a local Bot. | | `plugins/workflow/src/template.js` | 发布交付流水线 (a parallel branch) and the weekly report, each node with the inputs and outputs that edge carries. | | `plugins/workflow/src/narrate.js` | Progress lines for a step. A live run quotes the Bot reply when present; otherwise delivery-template nodes may use canned copy. A join line is only for a rank of more than one node. | -| `plugins/workflow/client.js` | Sidebar section (with mini-flowcharts), chat stage (normal conversation UI), assign dialog (scrollable with sticky footer), flowchart drawer (collapsed by default, per-node inspector), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest, composer). The stage shows the queue and refreshes state while one is active. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as `delivery`) support Delete with confirm. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. | +| `plugins/workflow/client.js` | Sidebar section (with mini-flowcharts), workflow stage (view/control: progress strip and flowchart drawer — no message composer), assign dialog (scrollable with sticky footer), flowchart drawer (collapsed by default, per-node inspector), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest). The stage shows the queue and refreshes state while one is active; a running node uses a breathing border animation. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as `delivery`) support Delete with confirm. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. | ## Entry points @@ -46,7 +46,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, 1. Friends' Bots are not nodes. A `remote` Bot is refused by create, define and the matcher. 2. The canvas and the run share one graph: nodes, edges and positions. A drag commits `workflow/graph`. Status highlighting reads that same graph. Replacing edges does not recompute who is already running. -3. **A live run advances only when its ephemeral Bot replies, following the graph's edges in order.** That reply is the chat line. Nodes start only when their forward predecessors finish and edges reach them. There is no scripted or seeded run path: `workflow/state` exposes only `live === true` runs. A run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a live run (`assign`, `run_workflow`, `ingest`) spawns one ephemeral Bot per node from that node's prompt, role, model and tools, sends it the step from the Main Bot, and removes every instance when the run finishes, fails for good, or is reset. A `failOnce` retry keeps them. They are hidden and left out of the sidebar. Loading the plugin does not spawn Bots for the default workflows. Loading is also the one moment a run the host left behind is noticed: a run still marked `running` is abandoned, so loading releases its instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). A dispatch that does not land is not marked handed. The chat names the code, and the next hand-off tries that node again. It is not sent twice in the same hand-off. A reply that says `could not answer your message` or `model call failed` fails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not. +3. **A live run advances only when its ephemeral Bot replies, following the graph's edges in order.** That reply advances the run and may appear on the workflow stage log; it is not delivered into a persistent sidebar Bot conversation (for example Chief). The `workflow-step` send middleware denies those replies after `stepResult` runs. Nodes start only when their forward predecessors finish and edges reach them. There is no scripted or seeded run path: `workflow/state` exposes only `live === true` runs. A run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a live run (`assign`, `run_workflow`, `ingest`) spawns one ephemeral Bot per node from that node's prompt, role, model and tools, sends it the step from the Main Bot, and removes every instance when the run finishes, fails for good, or is reset. A `failOnce` retry keeps them. They are hidden and left out of the sidebar. Loading the plugin does not spawn Bots for the default workflows. Loading is also the one moment a run the host left behind is noticed: a run still marked `running` is abandoned, so loading releases its instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). A dispatch that does not land is not marked handed. The chat names the code, and the next hand-off tries that node again. It is not sent twice in the same hand-off. A reply that says `could not answer your message` or `model call failed` fails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not. 4. Each workflow keeps a FIFO queue and up to `maxConcurrent` live runs (default 2, an integer from 1 to 8 on the record or via `workflow/concurrency`). The host runs at most 6 live runs at once. A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first, until the host cap. A new assign on a workflow that already has a queue waits behind it. A task does not replace a running run or release that run's Bots. Migrating an old store keeps only runs that were still `running` with ephemeral Bot ids; other legacy scripted rows are dropped from active state (finished tasks may remain in lane history). History records a finished or failed live run. Lowering the cap does not stop a run that has already started. Assigning `{ botId }` claims the card for that local Bot and does not enqueue. A remote Bot is refused. 5. A node declares up to eight inputs and eight outputs. A required input is an output of the same name on an ancestor, or `task`, `title`, or `brief` on a root. A start with no provider is refused, and Bots spawned for that attempt are released. On a Bot-backed run a successor stays queued until its required inputs are present. If every forward predecessor is done, failed or skipped and an input is still missing, that node fails and the run stops. The dispatch text carries `Input : `. The Bot reports `OUTPUT : `; a single declared output may be the reply body. Nodes start in edge order. 6. Each node is a real ephemeral Bot for that run, then it is removed. A permanent sidebar Bot (`botId`) is optional and is not required for the step to run. `tools` on the node is what that Bot may use: `github` grants `mcp__github__*` (pull requests, reviews, comments); `bash` or `shell` sets the permission to `danger-full-access` and allows a full-access shell; any other step gets `workspace-write`. A hidden run Bot cannot show an approval card, so a granted tool is allowed and a connector it did not name is refused. The host stores this on the run Bot as `stepTools` and `permission`. diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index a623161e2..e11c3b0fa 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -2754,7 +2754,7 @@ diff --git a/src/client/styles.js b/src/client/styles.js /* Usage while another page is open: out of the flow at Usage's own width, so it keeps its layout, and not rendered. */ .bt-settings-usage[data-off]{position:absolute;top:0;left:198px;width:calc(min(1100px,calc(100vw - 48px)) - 198px);height:100%;box-sizing:border-box;content-visibility:hidden;pointer-events:none} -@@ -470,13 +470,58 @@ button.bt-set-row:hover{background:var(--bt-hover)} +@@ -470,13 +470,60 @@ button.bt-set-row:hover{background:var(--bt-hover)} .bt-set-card>.bt-set-row+.bt-set-row{border-top:.5px solid var(--bt-line)} .bt-set-row:first-child{border-top-left-radius:12px;border-top-right-radius:12px} .bt-set-row:last-child{border-bottom-left-radius:12px;border-bottom-right-radius:12px} @@ -2788,11 +2788,13 @@ diff --git a/src/client/styles.js b/src/client/styles.js +.bt-wf-prompt{display:flex;flex-direction:column;gap:4px;font-size:12px;min-width:0} +.bt-wf-prompt textarea{width:100%;box-sizing:border-box;border-radius:8px;border:1px solid var(--bt-line);background:var(--bt-card);color:inherit;font:inherit;font-size:12px;padding:6px 8px;resize:vertical} +.bt-wf-plane{position:relative;width:0;margin:16px auto 0} -+.bt-wf-wires{position:absolute;left:0;top:0;overflow:visible;pointer-events:none} ++.bt-wf-wires{position:absolute;left:calc(-1 * var(--bt-wf-wire-span,480px));top:0;width:calc(2 * var(--bt-wf-wire-span,480px));overflow:visible;pointer-events:none} +.bt-wf-wires path{fill:none;stroke:var(--bt-ink-3);stroke-width:1.6} +.bt-wf-wires marker path{fill:var(--bt-ink-3);stroke:none} +.bt-wf-node{position:absolute;width:168px;box-sizing:border-box;border:1px solid var(--bt-line);background:var(--bt-card);border-radius:12px;padding:8px 9px;display:flex;align-items:center;gap:8px} -+.bt-wf-node.running{border-color:var(--bt-accent);box-shadow:0 0 0 3px rgba(77,107,254,.12)} ++@keyframes bt-wf-node-live{0%,100%{border-color:var(--bt-accent);box-shadow:0 0 0 2px rgba(77,107,254,.18),0 0 0 0 rgba(77,107,254,.2)}50%{border-color:rgba(77,107,254,.85);box-shadow:0 0 0 3px rgba(77,107,254,.35),0 0 14px 2px rgba(77,107,254,.22)}} ++.bt-wf-node.running{border-color:var(--bt-accent);animation:bt-wf-node-live 1.5s ease-in-out infinite} ++@media (prefers-reduced-motion:reduce){.bt-wf-node.running{animation:none;box-shadow:0 0 0 3px rgba(77,107,254,.2)}} +.bt-wf-node.done{border-color:rgba(22,163,74,.35)} +.bt-wf-node.failed{border-color:#e5484d} +.bt-wf-node.queued{opacity:.78} diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index b545563fb..8b969be5c 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1767,7 +1767,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,773 +1624,1847 @@ function usageOf(list, ids) { +@@ -1624,773 +1624,1849 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -4220,11 +4220,13 @@ diff --git a/client.js b/client.js +.bt-wf-prompt{display:flex;flex-direction:column;gap:4px;font-size:12px;min-width:0} +.bt-wf-prompt textarea{width:100%;box-sizing:border-box;border-radius:8px;border:1px solid var(--bt-line);background:var(--bt-card);color:inherit;font:inherit;font-size:12px;padding:6px 8px;resize:vertical} +.bt-wf-plane{position:relative;width:0;margin:16px auto 0} -+.bt-wf-wires{position:absolute;left:0;top:0;overflow:visible;pointer-events:none} ++.bt-wf-wires{position:absolute;left:calc(-1 * var(--bt-wf-wire-span,480px));top:0;width:calc(2 * var(--bt-wf-wire-span,480px));overflow:visible;pointer-events:none} +.bt-wf-wires path{fill:none;stroke:var(--bt-ink-3);stroke-width:1.6} +.bt-wf-wires marker path{fill:var(--bt-ink-3);stroke:none} +.bt-wf-node{position:absolute;width:168px;box-sizing:border-box;border:1px solid var(--bt-line);background:var(--bt-card);border-radius:12px;padding:8px 9px;display:flex;align-items:center;gap:8px} -+.bt-wf-node.running{border-color:var(--bt-accent);box-shadow:0 0 0 3px rgba(77,107,254,.12)} ++@keyframes bt-wf-node-live{0%,100%{border-color:var(--bt-accent);box-shadow:0 0 0 2px rgba(77,107,254,.18),0 0 0 0 rgba(77,107,254,.2)}50%{border-color:rgba(77,107,254,.85);box-shadow:0 0 0 3px rgba(77,107,254,.35),0 0 14px 2px rgba(77,107,254,.22)}} ++.bt-wf-node.running{border-color:var(--bt-accent);animation:bt-wf-node-live 1.5s ease-in-out infinite} ++@media (prefers-reduced-motion:reduce){.bt-wf-node.running{animation:none;box-shadow:0 0 0 3px rgba(77,107,254,.2)}} +.bt-wf-node.done{border-color:rgba(22,163,74,.35)} +.bt-wf-node.failed{border-color:#e5484d} +.bt-wf-node.queued{opacity:.78} @@ -16414,9 +16416,10 @@ diff --git a/client.js b/client.js + return (nested ?? direct)?.dataset?.node || ""; +} +var WIRE_SPAN = 480; ++var NODE_WIRE_OUT = 56; +function wirePath(from, to) { + const x1 = from.x + WIRE_SPAN; -+ const y1 = from.y + 48; ++ const y1 = from.y + NODE_WIRE_OUT; + const x2 = to.x + WIRE_SPAN; + const y2 = to.y; + const mid = (y1 + y2) / 2; @@ -16548,7 +16551,7 @@ diff --git a/client.js b/client.js + ))) : null, + (0, import_react34.createElement)( + "div", -+ { className: "bt-wf-plane", "data-plane": "1", style: { height } }, ++ { className: "bt-wf-plane", "data-plane": "1", style: { height, "--bt-wf-wire-span": `${WIRE_SPAN}px` } }, + (0, import_react34.createElement)( + "svg", + { @@ -16556,8 +16559,7 @@ diff --git a/client.js b/client.js + "aria-hidden": true, + width: WIRE_SPAN * 2, + height, -+ viewBox: `0 0 ${WIRE_SPAN * 2} ${height}`, -+ style: { left: -WIRE_SPAN, width: WIRE_SPAN * 2, height } ++ viewBox: `0 0 ${WIRE_SPAN * 2} ${height}` + }, + (0, import_react34.createElement)( + "defs", diff --git a/patches/ds-bot/files/src/client/workflows.js b/patches/ds-bot/files/src/client/workflows.js index ac40b8718..42c257ebd 100644 --- a/patches/ds-bot/files/src/client/workflows.js +++ b/patches/ds-bot/files/src/client/workflows.js @@ -118,10 +118,11 @@ function hitNode(event) { // be negative. The wire box is shifted by SPAN so those points stay inside the // SVG viewport; overflow:visible is not reliable for a stroke on the edge. const WIRE_SPAN = 480 +const NODE_WIRE_OUT = 56 function wirePath(from, to) { const x1 = from.x + WIRE_SPAN - const y1 = from.y + 48 + const y1 = from.y + NODE_WIRE_OUT const x2 = to.x + WIRE_SPAN const y2 = to.y const mid = (y1 + y2) / 2 @@ -177,11 +178,10 @@ function GraphCanvas({ graph, editing, roster, onCommit }) { onCommit?.({ nodes: nodes.map((item) => (item.id === node.id ? { ...item, prompt } : item)), edges }) }, })))) : null, - h('div', { className: 'bt-wf-plane', 'data-plane': '1', style: { height } }, + h('div', { className: 'bt-wf-plane', 'data-plane': '1', style: { height, '--bt-wf-wire-span': `${WIRE_SPAN}px` } }, h('svg', { className: 'bt-wf-wires', 'aria-hidden': true, width: WIRE_SPAN * 2, height, viewBox: `0 0 ${WIRE_SPAN * 2} ${height}`, - style: { left: -WIRE_SPAN, width: WIRE_SPAN * 2, height }, }, h('defs', null, h('marker', { diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 171b68d89..7e9763ca1 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -128,6 +128,7 @@ window.__ModuleLoader__.load({ const COLORS = { Planner: '#8b5cf6', Writer: '#f0509a', Coder: '#1fbf6a', Reviewer: '#f5a524', Tester: '#14b8a6', Deployer: '#2f7cf6', '研究员': '#a78bfa', '数据分析师': '#ec4899' } // Zero-width plane center line: shift wire coordinates so strokes stay inside the SVG box. const WIRE_SPAN = 480 + const NODE_WIRE_OUT = 56 function workflowNodeIds(workflow) { return new Set((workflow?.nodes ?? []).map(node => node.id)) @@ -223,20 +224,31 @@ window.__ModuleLoader__.load({ return workflow?.nodes?.find(node => node.id === id) } - function MiniFlowchart({ workflow }) { + function MiniFlowchart({ workflow, compact = false }) { const nodes = workflow?.nodes ?? [] const edges = workflow?.edges ?? [] if (nodes.length === 0) return null const layers = workflow?.layers ?? [[nodes[0]?.id]] - const width = 160 - const nodeW = 32 - const nodeH = 32 - const spacing = 40 - const layerSpacing = 48 - const height = layers.length * layerSpacing + nodeH - return h('svg', { width, height, viewBox: `0 0 ${width} ${height}`, className: 'wf-mini-flow', 'aria-label': workflow.name }, + const width = compact ? 28 : 160 + const nodeW = compact ? 10 : 32 + const nodeH = compact ? 10 : 32 + const spacing = compact ? 14 : 40 + const layerSpacing = compact ? 14 : 48 + const height = compact ? 28 : layers.length * layerSpacing + nodeH + const viewW = compact ? 28 : width + const viewH = compact ? 28 : height + const markerId = compact ? 'wf-mini-arrow-c' : 'wf-mini-arrow' + return h('svg', { + width: compact ? '100%' : width, + height: compact ? '100%' : height, + viewBox: `0 0 ${viewW} ${viewH}`, + preserveAspectRatio: 'xMidYMid meet', + className: compact ? 'wf-mini-flow wf-mini-flow-compact' : 'wf-mini-flow', + 'aria-hidden': compact ? true : undefined, + 'aria-label': compact ? undefined : workflow.name, + }, h('defs', null, - h('marker', { id: 'wf-mini-arrow', viewBox: '0 0 10 10', refX: '8', refY: '5', markerWidth: '4', markerHeight: '4', orient: 'auto' }, + h('marker', { id: markerId, viewBox: '0 0 10 10', refX: '8', refY: '5', markerWidth: compact ? '3' : '4', markerHeight: compact ? '3' : '4', orient: 'auto' }, h('path', { d: 'M0 0 10 5 0 10z', fill: 'var(--bt-ink-3, #9a9a9a)' }))), edges.map(edge => { const fromIdx = layers.findIndex(layer => layer.includes(edge.from)) @@ -246,47 +258,48 @@ window.__ModuleLoader__.load({ const toLayer = layers[toIdx] const fromPos = fromLayer.indexOf(edge.from) const toPos = toLayer.indexOf(edge.to) - const x1 = width / 2 + (fromPos - (fromLayer.length - 1) / 2) * spacing + const midX = viewW / 2 + const x1 = midX + (fromPos - (fromLayer.length - 1) / 2) * spacing const y1 = fromIdx * layerSpacing + nodeH - const x2 = width / 2 + (toPos - (toLayer.length - 1) / 2) * spacing + const x2 = midX + (toPos - (toLayer.length - 1) / 2) * spacing const y2 = toIdx * layerSpacing return h('path', { key: `${edge.from}>${edge.to}`, d: `M ${x1} ${y1} L ${x2} ${y2}`, stroke: 'var(--bt-ink-3, #9a9a9a)', - strokeWidth: 1, + strokeWidth: compact ? 1.2 : 1, fill: 'none', - markerEnd: 'url(#wf-mini-arrow)' + markerEnd: `url(#${markerId})`, }) }), layers.map((layer, layerIdx) => layer.map((nodeId, pos) => { const node = nodeOf(workflow, nodeId) if (!node) return null - const x = width / 2 + (pos - (layer.length - 1) / 2) * spacing - nodeW / 2 + const x = viewW / 2 + (pos - (layer.length - 1) / 2) * spacing - nodeW / 2 const y = layerIdx * layerSpacing const color = COLORS[node.name] ?? '#4d6bfe' return h('g', { key: nodeId }, h('rect', { - x, y, width: nodeW, height: nodeH, rx: 6, + x, y, width: nodeW, height: nodeH, rx: compact ? 2 : 6, fill: color, opacity: 0.15, stroke: color, - strokeWidth: 1 + strokeWidth: 1, }), - h('text', { + compact ? null : h('text', { x: x + nodeW / 2, y: y + nodeH / 2 + 1, textAnchor: 'middle', dominantBaseline: 'middle', fill: color, fontSize: 12, - fontWeight: 600 + fontWeight: 600, }, (node.name ?? '?').slice(0, 1))) }))) } - // Stage: reuse normal FlyBot conversation UI instead of custom card style - function Stage({ workflow, roster, stageLeft, drawer, tab, draft, onDraft, onCloseDrawer, onToggleDrawer, onTab, onAssign, onSend, onTip, onReset, editing, onToggleEdit, onCommit, onPause, onResume }) { + // Stage: view/control only — progress and flowchart; no message composer (#1459). + function Stage({ workflow, roster, stageLeft, drawer, tab, onCloseDrawer, onToggleDrawer, onTab, onAssign, onTip, onReset, editing, onToggleEdit, onCommit, onPause, onResume }) { const chatRef = useRef(null) const run = workflow.run?.live === true ? workflow.run : null useEffect(() => { @@ -294,38 +307,12 @@ window.__ModuleLoader__.load({ if (el) el.scrollTop = el.scrollHeight }, [workflow.messages.length]) const running = (run?.running ?? []).map(id => nodeOf(workflow, id)).filter(Boolean) - const Conversation = globalThis.__flybot__?.Conversation - const ConversationHeader = globalThis.__flybot__?.ConversationHeader - - // Use normal conversation components if available - if (typeof Conversation === 'function' && typeof ConversationHeader === 'function') { - const messages = stageMessages(workflow, workflow.messages, roster).map(msg => ({ - id: msg.id, - role: msg.kind === 'user' ? 'user' : msg.kind === 'system' ? 'system' : 'assistant', - sender: msg.botId ? { id: msg.botId, name: nodeOf(workflow, msg.botId)?.name ?? roster?.byId?.[msg.botId]?.name } : { id: workflow.id, name: workflow.name }, - text: msg.text, - timestamp: msg.timestamp - })) - - return h('div', { className: 'wf-stage', style: { left: stageLeft } }, - h(ConversationHeader, { - title: workflow.name, - subtitle: run ? taskSummary(run.task, run) : tr('Waiting for a task'), - onAction: onToggleDrawer - }), - h(Conversation, { - messages: messages.map(msg => ({ ...msg, text: chatPreview(msg.text) })), - onSend: (text) => { onDraft(text); onSend() } - })) - } - - // Fallback to simplified conversation-style UI const visible = stageMessages(workflow, workflow.messages, roster) return h('div', { className: 'wf-stage', style: { left: stageLeft } }, h('div', { className: 'wf-main' }, h('div', { className: 'wf-top' }, h('button', { type: 'button', className: 'wf-pill', onClick: onToggleDrawer }, - h(MiniFlowchart, { workflow }), + h('span', { className: 'wf-pill-thumb' }, h(MiniFlowchart, { workflow, compact: true })), h('span', { className: 'wf-pill-name' }, workflow.name), h('span', { className: 'wf-chev', 'aria-hidden': true }, '▾')), h('div', { className: 'wf-actions' }, @@ -347,17 +334,9 @@ window.__ModuleLoader__.load({ : h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: onPause }, tr('Pause'))) : null, h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => { onTab('runs'); onToggleDrawer() } }, tr('Progress')), h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => onTab('flow') }, tr('Flow'))) : null, - h('div', { className: 'wf-chat', ref: chatRef }, + visible.length > 0 ? h('div', { className: 'wf-chat', ref: chatRef }, h('div', { className: 'wf-chat-in' }, - visible.map(message => h(Bubble, { key: message.id, message, workflow, roster })))), - h('div', { className: 'wf-composer-wrap' }, - h('form', { className: 'wf-composer', onSubmit: (event) => { event.preventDefault(); onSend() } }, - h('input', { - value: draft, placeholder: `${tr('Message the workflow')} ${workflow.name}`, - onChange: (event) => onDraft(event.target.value), - }), - h('button', { type: 'submit', className: 'wf-mic', 'aria-label': tr('Assign') }, '↑')), - h('div', { className: 'wf-under' }, run ? `运行 ${run.steps}/${run.stepBudget} 步 · 已完成 ${run.doneCount} 个节点` : null))), + visible.map(message => h(Bubble, { key: message.id, message, workflow, roster })))) : null), drawer ? h('aside', { className: 'wf-drawer' }, h('div', { className: 'wf-dr-head' }, h('button', { type: 'button', className: 'wf-icon', 'aria-label': tr('Close'), onClick: onCloseDrawer }, '×')), @@ -411,7 +390,7 @@ window.__ModuleLoader__.load({ function wirePath(from, to) { const x1 = (from.x ?? 0) + WIRE_SPAN - const y1 = (from.y ?? 0) + 48 + const y1 = (from.y ?? 0) + NODE_WIRE_OUT const x2 = (to.x ?? 0) + WIRE_SPAN const y2 = to.y ?? 0 const mid = (y1 + y2) / 2 @@ -423,7 +402,6 @@ window.__ModuleLoader__.load({ width: WIRE_SPAN * 2, height, viewBox: `0 0 ${WIRE_SPAN * 2} ${height}`, - style: { left: -WIRE_SPAN, width: WIRE_SPAN * 2, height }, } } @@ -917,7 +895,6 @@ window.__ModuleLoader__.load({ const [tab, setTab] = useState('flow') const [assignOpen, setAssignOpen] = useState(false) const [creating, setCreating] = useState(false) - const [draft, setDraft] = useState('') const [tasks, setTasks] = useState([]) const [tip, setTip] = useState(null) const [error, setError] = useState('') @@ -1107,7 +1084,7 @@ window.__ModuleLoader__.load({ setRowMenu({ x: event.clientX, y: event.clientY, workflow: item }) }, }, - h('span', { className: 'wf-ico' }, h(MiniFlowchart, { workflow: item, roster })), + h('span', { className: 'wf-ico' }, h(MiniFlowchart, { workflow: item, compact: true })), h('span', { className: 'wf-txt' }, h('span', { className: 'wf-t1' }, item.name, h('span', { className: 'wf-badge' }, 'Workflow')), h('span', { className: 'wf-t2' }, sidebarPreview(item))), @@ -1117,7 +1094,7 @@ window.__ModuleLoader__.load({ h('span', { className: 'wf-txt' }, h('span', { className: 'wf-t1' }, tr('Hand a task to a Workflow')))), error ? h('div', { className: 'wf-err', role: 'alert' }, error) : null, open ? portal(h(Stage, { - workflow: open, roster, stageLeft, drawer, tab, draft, onDraft: setDraft, + workflow: open, roster, stageLeft, drawer, tab, editing, onToggleEdit: () => { setEditing(value => !value); setDrawer(true); setTab('flow') }, onCommit: (graph) => { @@ -1131,12 +1108,6 @@ window.__ModuleLoader__.load({ onTab: (key) => { setTab(key); setDrawer(true) }, onReset: () => { void call('workflow/reset', { workflowId: open.id }).then(value => { if (value?.workflows) setPack(value) }) }, onAssign: () => { setAssignOpen(true); void loadTasks() }, - onSend: () => { - const text = draft.trim() - if (!text) return - setDraft('') - void call('workflow/say', { workflowId: open.id, text }).then(value => { if (value?.workflows) setPack(value) }) - }, onTip: setTip, onPause: () => { void call('workflow/pause', { workflowId: open.id }).then(value => { if (value?.workflows) setPack(value) }) @@ -1196,15 +1167,17 @@ window.__ModuleLoader__.load({ .wf-t2,.wf-chain{display:block;font-size:12px;color:var(--bt-ink-2,rgba(20,20,20,.6));white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .wf-badge{font-size:9.5px;padding:0 5px;border-radius:4px;background:rgba(77,107,254,.12);color:var(--bt-accent,#4d6bfe);line-height:15px} .wf-live{width:7px;height:7px;border-radius:50%;background:var(--bt-accent,#4d6bfe);flex:none} -.wf-ico{width:32px;height:32px;border-radius:9px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));display:grid;place-items:center;color:var(--bt-accent,#4d6bfe);flex:none;overflow:hidden} +.wf-ico{width:32px;height:32px;border-radius:9px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));display:grid;place-items:center;color:var(--bt-accent,#4d6bfe);flex:none;overflow:hidden;padding:2px;box-sizing:border-box} .wf-mini-flow{display:block;max-width:100%;max-height:100%} +.wf-mini-flow-compact{width:100%;height:100%} +.wf-pill-thumb{width:28px;height:28px;flex:none;display:grid;place-items:center;overflow:hidden;border-radius:6px;background:var(--bt-main,#fcfcfc)} .wf-ico-lg{width:42px;height:42px;margin:0 auto} .wf-av{border-radius:50%;display:inline-grid;place-items:center;color:#fff;font-weight:650;flex:none} .wf-stage{position:fixed;top:0;right:0;bottom:0;z-index:30;display:flex;overflow:hidden;background:var(--bt-main,#fcfcfc);color:var(--bt-ink,#141414)} .wf-main{flex:1;min-width:0;display:flex;flex-direction:column} .wf-top{height:56px;display:flex;align-items:center;justify-content:center;position:relative;flex:none} -.wf-pill{display:flex;align-items:center;gap:8px;height:34px;padding:0 12px 0 8px;border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:10px;background:var(--bt-card,#fff);cursor:pointer;color:inherit;font:inherit} -.wf-pill-name{font-weight:600}.wf-sub{font-size:11px;color:var(--bt-ink-3,#9a9a9a)}.wf-chev{color:var(--bt-ink-3,#9a9a9a)} +.wf-pill{display:flex;align-items:center;gap:10px;height:36px;padding:0 12px 0 6px;border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:10px;background:var(--bt-card,#fff);cursor:pointer;color:inherit;font:inherit;max-width:min(420px,calc(100% - 120px))} +.wf-pill-name{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wf-sub{font-size:11px;color:var(--bt-ink-3,#9a9a9a)}.wf-chev{color:var(--bt-ink-3,#9a9a9a)} .wf-actions{position:absolute;right:14px;top:12px;display:flex;gap:6px} .wf-btn{height:32px;padding:0 12px;border-radius:16px;border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);color:inherit;font:inherit;font-size:12.5px;cursor:pointer} .wf-btn.wf-pri{background:var(--bt-accent,#4d6bfe);border-color:var(--bt-accent,#4d6bfe);color:#fff} @@ -1254,19 +1227,21 @@ window.__ModuleLoader__.load({ .wf-term.on{color:#16a34a;background:rgba(22,163,74,.1);border-color:transparent} .wf-canvas{display:flex;flex-direction:column;align-items:center;gap:8px;min-height:440px;position:relative;overflow:auto} .wf-palette{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;width:100%} -.wf-plane{position:relative;width:0;margin:8px auto 0} +.wf-plane{position:relative;width:0;margin:8px auto 0;--wf-wire-span:${WIRE_SPAN}px} .wf-node-inspector{position:absolute;bottom:8px;right:8px;width:320px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:12px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,.1);z-index:10} .wf-inspector-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;font-size:12px;font-weight:600} .wf-close-inspector{background:none;border:0;cursor:pointer;color:var(--bt-ink-3,#9a9a9a);font-size:18px;line-height:1;padding:0;width:20px;height:20px} .wf-node-inspector textarea{width:100%;box-sizing:border-box;border-radius:8px;border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);color:inherit;font:inherit;padding:8px;resize:vertical;font-size:12px} -.wf-wires{position:absolute;left:0;top:0;overflow:visible;pointer-events:none} +.wf-wires{position:absolute;left:calc(-1 * var(--wf-wire-span, ${WIRE_SPAN}px));top:0;width:calc(2 * var(--wf-wire-span, ${WIRE_SPAN}px));overflow:visible;pointer-events:none} .wf-wires path{fill:none;stroke:var(--bt-line,rgba(20,20,20,.35));stroke-width:1.6} .wf-wires .wf-end{pointer-events:auto;fill:var(--bt-card,#fff);stroke:var(--bt-accent,#4d6bfe);cursor:crosshair} .wf-abs{position:absolute} .wf-port{position:absolute;left:50%;bottom:-6px;width:12px;height:12px;margin-left:-6px;border-radius:50%;border:2px solid var(--bt-accent,#4d6bfe);background:var(--bt-card,#fff);padding:0;cursor:crosshair} .wf-mini{position:absolute;top:-8px;right:-8px;width:18px;height:18px;border-radius:50%;border:1px solid var(--bt-line,rgba(20,20,20,.16));background:var(--bt-card,#fff);cursor:pointer;line-height:16px;padding:0} .wf-node{width:168px;border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);border-radius:12px;padding:8px 9px;display:flex;align-items:center;gap:8px;position:relative} -.wf-node.running{border-color:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 3px rgba(77,107,254,.12)} +@keyframes wf-node-live{0%,100%{border-color:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 2px rgba(77,107,254,.18),0 0 0 0 rgba(77,107,254,.2)}50%{border-color:rgba(77,107,254,.85);box-shadow:0 0 0 3px rgba(77,107,254,.35),0 0 14px 2px rgba(77,107,254,.22)}} +.wf-node.running{border-color:var(--bt-accent,#4d6bfe);animation:wf-node-live 1.5s ease-in-out infinite} +@media (prefers-reduced-motion:reduce){.wf-node.running{animation:none;box-shadow:0 0 0 3px rgba(77,107,254,.2)}} .wf-node.done{border-color:rgba(22,163,74,.35)}.wf-node.failed{border-color:#e5484d} .wf-node.selected{border-color:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 2px rgba(77,107,254,.2)} .wf-nt{min-width:0;flex:1;display:flex;flex-direction:column} @@ -1337,6 +1312,9 @@ window.__ModuleLoader__.load({ return off } - return { name: 'flybot-workflow', inject: ['flybot'], apply, Section, WorkflowSettings, tr, columnOf, shortId, stageMessages, taskSummary, chatPreview } + return { + name: 'flybot-workflow', inject: ['flybot'], apply, Section, WorkflowSettings, + tr, columnOf, shortId, stageMessages, taskSummary, chatPreview, wirePath, WIRE_SPAN, NODE_WIRE_OUT, + } }, }) diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index eba515df9..195046a43 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -134,16 +134,18 @@ export function apply(ctx, config = {}) { const off = fb.bot.send.use({ name: 'workflow-step', priority: fb.bot.send.priority?.ROUTING, - handle(msg) { + async handle(msg) { const from = typeof msg.from === 'string' ? msg.from : '' if (msg.role !== 'reply' || !from || service.ownsBot(from) !== true) return undefined const text = String(msg.text ?? '') const failed = /could not answer your message|model call failed/i.test(text) - // The reply is the step result. Let it deliver, and move the node from it. - void Promise.resolve(service.stepResult({ botId: from, text, ok: !failed })).catch(error => { + // The reply advances the run only. It must not land in a persistent Bot chat (e.g. Chief). + try { + await Promise.resolve(service.stepResult({ botId: from, text, ok: !failed })) + } catch (error) { fb.warn?.('flybot-workflow: step result failed: %s', error) - }) - return undefined + } + return { action: 'deny', reason: 'workflow step result stays on the run surface' } }, }) if (typeof off === 'function') offs.push(off) diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index e2e30c181..a66a27133 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -132,7 +132,7 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no } function readyWorkflow(item) { - if (Array.isArray(item.edges) && Array.isArray(item.layers) && item.nodes.every(node => Number.isFinite(node.x))) return item + if (Array.isArray(item.edges) && Array.isArray(item.layers) && item.nodes.every(node => Number.isFinite(node.x) && Number.isFinite(node.y))) return item const next = normalizeGraph(item) item.nodes = next.nodes item.edges = next.edges diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index ce86fa662..926b23afe 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -560,6 +560,53 @@ test('a sidebar workflow row can be deleted from the context menu', async () => view.unmount() }) +test('wire paths for a linear three-node graph connect each step vertically', () => { + const nodes = [ + { id: 'a', x: 0, y: 0 }, + { id: 'b', x: 0, y: 108 }, + { id: 'c', x: 0, y: 216 }, + ] + const span = client.WIRE_SPAN + const out = client.NODE_WIRE_OUT + const ab = client.wirePath(nodes[0], nodes[1]) + const bc = client.wirePath(nodes[1], nodes[2]) + assert.match(ab, new RegExp(`^M ${span} ${out} C ${span} ${(out + 108) / 2}, ${span} ${(out + 108) / 2}, ${span} 108$`)) + const y1b = 108 + out + assert.match(bc, new RegExp(`^M ${span} ${y1b} C ${span} ${(y1b + 216) / 2}, ${span} ${(y1b + 216) / 2}, ${span} 216$`)) +}) + +test('the workflow stage mounts no message composer', async () => { + const graph = delivery() + const prevFlybot = globalThis.__flybot__ + globalThis.__flybot__ = { + ...prevFlybot, + Conversation: () => null, + ConversationHeader: () => null, + } + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + await settle() + assert.equal(findAll(view.tree, node => node.props?.className === 'wf-composer').length, 0) + assert.equal(findAll(view.tree, node => node.props?.className === 'wf-composer-wrap').length, 0) + assert.equal(findAll(view.tree, node => node.type === 'input').length, 0) + globalThis.__flybot__ = prevFlybot + view.unmount() +}) + +test('running flow nodes use a breathing border animation only while running', () => { + const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + assert.match(source, /@keyframes wf-node-live/) + assert.match(source, /\.wf-node\.running\{[^}]*animation:wf-node-live/) + assert.match(source, /\.wf-node\.done\{/) + assert.doesNotMatch(source, /\.wf-node\.done\{[^}]*animation/) + assert.doesNotMatch(source, /\.wf-node\.queued\{[^}]*animation/) +}) + test('Chinese labels follow the host Tasks translation', () => { const prev = globalThis.__flybot__ globalThis.__flybot__ = { t: key => (key === 'Tasks' ? '任务' : key) } diff --git a/plugins/workflow/test/ephemeral.test.js b/plugins/workflow/test/ephemeral.test.js index 730a1bcd1..43b164d32 100644 --- a/plugins/workflow/test/ephemeral.test.js +++ b/plugins/workflow/test/ephemeral.test.js @@ -231,14 +231,15 @@ test('a Bot reply advances a live node; a 429 in the reply does not fail it', as const run = opened.workflows.find(item => item.id === made.id).run assert.equal(typeof handle, 'function') assert.equal(run.nodeStatus.plan, 'running') - handle({ from: run.ephemeral.plan, role: 'request', text: 'not a reply' }) - handle({ from: run.ephemeral.plan, role: 'reply', text: 'Noted a rate limit (429) in the log.\nOUTPUT plan: two lines' }) + assert.equal(await handle({ from: run.ephemeral.plan, role: 'request', text: 'not a reply' }), undefined) + const planReply = await handle({ from: run.ephemeral.plan, role: 'reply', text: 'Noted a rate limit (429) in the log.\nOUTPUT plan: two lines' }) + assert.equal(planReply?.action, 'deny') const mid = await waitFor(() => { const row = started.service.state().workflows.find(item => item.id === made.id) return row.run?.nodeStatus?.code === 'running' ? row : null }) assert.equal(mid.run.nodeStatus.plan, 'done') - handle({ from: run.ephemeral.code, role: 'reply', text: 'could not answer your message' }) + await handle({ from: run.ephemeral.code, role: 'reply', text: 'could not answer your message' }) const stopped = await waitFor(() => { const row = started.service.state().workflows.find(item => item.id === made.id) if (row.run?.status !== 'failed' || removed.length !== created.length) return null @@ -251,7 +252,7 @@ test('a Bot reply advances a live node; a 429 in the reply does not fail it', as const again = await started.service.runNamed({ workflow: 'Lane', brief: 'Ship another', task: '#1402b' }) const second = again.workflows.find(item => item.id === made.id).run assert.equal(second.status, 'running') - handle({ from: second.ephemeral.plan, role: 'reply', text: 'The model call failed.' }) + await handle({ from: second.ephemeral.plan, role: 'reply', text: 'The model call failed.' }) const failed = await waitFor(() => { const row = started.service.state().workflows.find(item => item.id === made.id) return row.run?.task?.id === '#1402b' && row.run?.status === 'failed' ? row : null From 2ede9d832bbff6cc20e7be3c08d9debd0dd38ea5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 14:27:26 +0000 Subject: [PATCH 2/3] Polish workflow palette Bot chips for drag-to-canvas Replace flat name pills with gripped roster chips (avatar, role or id hint for duplicate names), hover states, and a fixed drag ghost. Fixes #1459 Co-authored-by: Danyang Chen --- plugins/workflow/client.js | 90 +++++++++++++++++++++++++--- plugins/workflow/test/client.test.js | 27 +++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 7e9763ca1..390bd63e0 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -220,6 +220,47 @@ window.__ModuleLoader__.load({ return h('span', { className: 'wf-av', style: { background: color, width: size, height: size, fontSize: Math.max(11, size * 0.42) } }, (node?.name ?? '?').slice(0, 1)) } + const BOT_TINTS = ['#4d6bfe', '#8b5cf6', '#14b8a6', '#f5a524', '#ec4899', '#1fbf6a', '#2f7cf6'] + function botTint(bot) { + let hash = 0 + for (const ch of String(bot?.id ?? bot?.name ?? '')) hash = (hash * 31 + ch.charCodeAt(0)) | 0 + return BOT_TINTS[Math.abs(hash) % BOT_TINTS.length] + } + + function botPaletteSubtitle(bot, peers) { + const role = String(bot?.role ?? '').replace(/\s+/g, ' ').trim() + const dupes = (peers ?? []).filter(item => item.name === bot.name).length > 1 + if (role) return role.length > 28 ? `${role.slice(0, 27)}…` : role + if (dupes && bot?.id) return `…${String(bot.id).slice(-6)}` + return '' + } + + function PaletteBotFace({ bot, size = 26 }) { + const Avatar = globalThis.__flybot__?.BotAvatar + if (typeof Avatar === 'function') return h(Avatar, { bot, size, badge: false, live: false }) + const color = botTint(bot) + return h('span', { + className: 'wf-palette-av', + style: { background: color, width: size, height: size, fontSize: Math.max(11, size * 0.42) }, + }, (bot?.name ?? '?').slice(0, 1)) + } + + function PaletteBotChip({ bot, peers, onPointerDown, dragging }) { + const sub = botPaletteSubtitle(bot, peers) + return h('button', { + type: 'button', + className: `wf-palette-bot${dragging ? ' dragging' : ''}`, + 'data-bot': bot.id, + 'aria-label': sub ? `${bot.name} · ${sub}` : bot.name, + onPointerDown, + }, + h('span', { className: 'wf-palette-grip', 'aria-hidden': true }), + h(PaletteBotFace, { bot, size: 26 }), + h('span', { className: 'wf-palette-copy' }, + h('span', { className: 'wf-palette-name' }, bot.name), + sub ? h('span', { className: 'wf-palette-sub' }, sub) : null)) + } + function nodeOf(workflow, id) { return workflow?.nodes?.find(node => node.id === id) } @@ -408,6 +449,7 @@ window.__ModuleLoader__.load({ function Flow({ workflow, run, roster, onTip, editing, onCommit }) { const [gesture, setGesture] = useState(null) const [ghost, setGhost] = useState(null) + const [dragPreview, setDragPreview] = useState(null) const [selectedNode, setSelectedNode] = useState(null) const status = run?.nodeStatus ?? {} const nodes = workflow.nodes.map(node => ghost?.[node.id] ? { ...node, ...ghost[node.id] } : node) @@ -417,11 +459,17 @@ window.__ModuleLoader__.load({ const selected = nodes.find(node => node.id === selectedNode) const commit = (nextNodes, nextEdges) => onCommit?.({ nodes: nextNodes, edges: nextEdges }) const movePointer = (event) => { - if (!gesture || gesture.kind !== 'move') return + if (!gesture) return + if (gesture.kind === 'add') { + setDragPreview({ x: event.clientX, y: event.clientY }) + return + } + if (gesture.kind !== 'move') return setGhost({ [gesture.id]: { x: Math.round(gesture.x + event.clientX - gesture.ox), y: Math.round(gesture.y + event.clientY - gesture.oy) } }) } const endPointer = (event) => { if (!gesture) return + setDragPreview(null) const hit = hitNode(event) if (gesture.kind === 'move' && ghost?.[gesture.id]) { commit(nodes.map(node => node.id === gesture.id ? { ...node, ...ghost[gesture.id] } : node), edges) @@ -443,13 +491,26 @@ window.__ModuleLoader__.load({ const updateNode = (nodeId, updates) => { commit(nodes.map(item => item.id === nodeId ? { ...item, ...updates } : item), edges) } - return h('div', { className: 'wf-canvas', onPointerMove: movePointer, onPointerUp: endPointer }, + return h('div', { className: 'wf-canvas', onPointerMove: movePointer, onPointerUp: endPointer, onPointerCancel: () => { setGesture(null); setGhost(null); setDragPreview(null) } }, editing ? h('div', { className: 'wf-palette' }, - h('div', { className: 'wf-muted' }, tr('Drag a Bot here')), - locals.map(bot => h('button', { - key: bot.id, type: 'button', className: 'wf-chip', 'data-bot': bot.id, - onPointerDown: (event) => { event.preventDefault?.(); setGesture({ kind: 'add', bot }) }, - }, bot.name))) : null, + h('p', { className: 'wf-palette-hint' }, tr('Drag a Bot here')), + h('div', { className: 'wf-palette-row' }, + locals.map(bot => h(PaletteBotChip, { + key: bot.id, + bot, + peers: locals, + dragging: gesture?.kind === 'add' && gesture.bot?.id === bot.id, + onPointerDown: (event) => { + event.preventDefault?.() + setGesture({ kind: 'add', bot }) + setDragPreview({ x: event.clientX, y: event.clientY }) + }, + })))) : null, + gesture?.kind === 'add' && dragPreview ? h('div', { + className: 'wf-drag-ghost', + style: { left: dragPreview.x + 10, top: dragPreview.y + 10 }, + 'aria-hidden': true, + }, h(PaletteBotFace, { bot: gesture.bot, size: 24 }), h('span', { className: 'wf-drag-ghost-name' }, gesture.bot?.name ?? '')) : null, run ? h('div', { className: 'wf-term', title: run.task?.title ?? '' }, taskSummary(run.task, run)) : null, h('div', { className: 'wf-plane', 'data-plane': '1', style: { height } }, h('svg', { className: 'wf-wires', 'aria-hidden': true, ...wireSvgProps(height) }, @@ -1226,7 +1287,19 @@ window.__ModuleLoader__.load({ .wf-chip-inline{margin-left:6px;font-size:9.5px;vertical-align:middle;font-weight:500} .wf-term.on{color:#16a34a;background:rgba(22,163,74,.1);border-color:transparent} .wf-canvas{display:flex;flex-direction:column;align-items:center;gap:8px;min-height:440px;position:relative;overflow:auto} -.wf-palette{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;width:100%} +.wf-palette{display:flex;flex-direction:column;align-items:stretch;gap:10px;width:min(100%,360px);margin:0 auto 4px;padding:10px 12px;border:1px dashed var(--bt-line,rgba(20,20,20,.14));border-radius:12px;background:var(--bt-card,#fff)} +.wf-palette-hint{margin:0;font-size:11.5px;color:var(--bt-ink-2,rgba(20,20,20,.6));text-align:center;line-height:1.4} +.wf-palette-row{display:flex;flex-wrap:wrap;gap:8px;justify-content:center} +.wf-palette-bot{display:inline-flex;align-items:center;gap:8px;min-width:0;max-width:100%;padding:6px 10px 6px 6px;border:1px solid var(--bt-line,rgba(20,20,20,.1));border-radius:12px;background:var(--bt-main,#fcfcfc);color:inherit;font:inherit;cursor:grab;box-shadow:0 1px 0 rgba(20,20,20,.04);transition:border-color .15s,box-shadow .15s,transform .12s} +.wf-palette-bot:hover{border-color:rgba(77,107,254,.35);box-shadow:0 2px 8px rgba(77,107,254,.12)} +.wf-palette-bot:active,.wf-palette-bot.dragging{cursor:grabbing;border-color:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 3px rgba(77,107,254,.14);transform:scale(.98)} +.wf-palette-grip{width:6px;height:18px;flex:none;border-radius:2px;background:repeating-linear-gradient(180deg,var(--bt-ink-3,#9a9a9a) 0 2px,transparent 2px 4px);opacity:.45} +.wf-palette-av{border-radius:50%;display:inline-grid;place-items:center;color:#fff;font-weight:650;flex:none} +.wf-palette-copy{min-width:0;display:flex;flex-direction:column;align-items:flex-start;gap:1px;text-align:left} +.wf-palette-name{font-size:12.5px;font-weight:600;line-height:1.2;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.wf-palette-sub{font-size:10.5px;color:var(--bt-ink-3,#9a9a9a);line-height:1.2;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.wf-drag-ghost{position:fixed;z-index:100;pointer-events:none;display:inline-flex;align-items:center;gap:6px;padding:5px 10px 5px 5px;border-radius:10px;border:1px solid var(--bt-accent,#4d6bfe);background:var(--bt-card,#fff);box-shadow:0 8px 20px rgba(20,20,20,.12);opacity:.92;transform:translate(-4px,-4px)} +.wf-drag-ghost-name{font-size:12px;font-weight:600;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .wf-plane{position:relative;width:0;margin:8px auto 0;--wf-wire-span:${WIRE_SPAN}px} .wf-node-inspector{position:absolute;bottom:8px;right:8px;width:320px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:12px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,.1);z-index:10} .wf-inspector-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;font-size:12px;font-weight:600} @@ -1315,6 +1388,7 @@ window.__ModuleLoader__.load({ return { name: 'flybot-workflow', inject: ['flybot'], apply, Section, WorkflowSettings, tr, columnOf, shortId, stageMessages, taskSummary, chatPreview, wirePath, WIRE_SPAN, NODE_WIRE_OUT, + botPaletteSubtitle, botTint, } }, }) diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 926b23afe..4cfd1c65e 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -598,10 +598,37 @@ test('the workflow stage mounts no message composer', async () => { view.unmount() }) +test('palette chips distinguish duplicate Bot names and expose drag affordance', async () => { + const graph = delivery() + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const roster = { + bots: [ + { id: 'chief-a', name: 'Chief', role: 'Triage', remote: false }, + { id: 'chief-b', name: 'Chief', role: 'Verify', remote: false }, + { id: 'chief-c', name: 'Chief', role: '', remote: false }, + ], + } + const view = mount(client.Section, { roster, call, actions: {} }) + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + button(view.tree, 'Flow').props.onClick() + button(view.tree, 'Edit').props.onClick() + await settle() + assert.ok(findAll(view.tree, node => node.props?.className?.includes('wf-palette-bot')).length >= 3) + assert.equal(client.botPaletteSubtitle({ id: 'chief-a', name: 'Chief', role: 'Triage' }, roster.bots), 'Triage') + assert.ok(client.botPaletteSubtitle({ id: 'chief-c', name: 'Chief', role: '' }, roster.bots).includes('hief-c')) + view.unmount() +}) + test('running flow nodes use a breathing border animation only while running', () => { const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8') assert.match(source, /@keyframes wf-node-live/) assert.match(source, /\.wf-node\.running\{[^}]*animation:wf-node-live/) + assert.match(source, /\.wf-palette-bot\{/) + assert.match(source, /\.wf-drag-ghost\{/) assert.match(source, /\.wf-node\.done\{/) assert.doesNotMatch(source, /\.wf-node\.done\{[^}]*animation/) assert.doesNotMatch(source, /\.wf-node\.queued\{[^}]*animation/) From d1b10546106cd19480ce7e0269c49100fcf0261f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 14:31:52 +0000 Subject: [PATCH 3/3] Document workflow wire-plane anchoring in core-client arch-docs: Settings and plugin flowcharts share WIRE_SPAN / CSS var anchoring for edge SVGs (#1459). Fixes #1459 Co-authored-by: Danyang Chen --- docs/subsystems/core-client.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 1cba4f1d4..f692f8b63 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -181,6 +181,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### New-chat menu - The + menu is a popover anchored next to the sidebar **+** (#195, #173): its `.bt-pane` carries `data-menu` and stays see-through, the menu is a 280px card at the top left with no title row, and a press on the pane outside the card (or Escape) closes it. New chat, New group chat, New Bot and plugin rows open their full pane as before. +- Settings → **Workflows** flowchart wire plane (#1459): `GraphCanvas` in `workflows.js` places nodes on a zero-width center line (`.bt-wf-plane`). Connector paths are drawn in an SVG shifted by `WIRE_SPAN` (480): the plane sets `--bt-wf-wire-span` on its inline style and `.bt-wf-wires` in `styles.js` uses `left:calc(-1 * var(--bt-wf-wire-span))` and `width:calc(2 * var(--bt-wf-wire-span))`, so coordinates `node.x + WIRE_SPAN` line up with each card's center instead of fanning to a corner. The `@flybot/workflow` stage drawer uses the same anchoring (`--wf-wire-span` on `.wf-plane`, matching rules on `.wf-wires`). `NODE_WIRE_OUT` is the bottom port offset for the cubic segment. Tests: `plugins/workflow/test/client.test.js` (`wirePath` regression); Settings: `core/test/workflows-page.test.js`. - Settings → **Workflows** (`src/client/workflows.js`, #1364) sits right after **Scheduled tasks**, beside the other automation page, so the page list's existing order and the plugin-slot rule (a `flybot.settings.page` entry is listed after Connectors) are unchanged. It is not a second row in the account menu: the menu keeps its one name per destination. The page reads `@flybot/workflow` through `call('workflow/state')`: **Factory presets** are the template catalog (a drawable graph, so a pipeline can be opened before it is copied) and **Yours** are the workflows saved on this machine whose ids are not those templates. Each user-owned workflow row shows rename (✎) and delete (×) action buttons; factory presets show no actions and refuse both operations (#1434). Rename opens a modal with a name field and calls `workflow/rename`; delete confirms ("Are you sure you want to delete '{name}'? This cannot be undone.") and calls `workflow/delete`, which removes the workflow from state, releases ephemeral Bots if a run exists, and cleans logs/runs/routes. Opening one draws the flowchart; a saved workflow can be dragged (move a node, draw an edge, drop a local Bot, remove a node) and each node's **system prompt** can be typed in a field beside the canvas — that prompt is what the node's run Bot receives — and the picture is saved with `workflow/graph` (#1393). **Use this** copies a factory template with `workflow/create`. A read that rejects or never returns settles as an error with **Retry**; an empty store says there are no workflows. When that read is `bot/not-found`, or the host says `Unknown FlyBot endpoint workflow/…`, the page says the Workflow plugin is not installed or not enabled (a default install includes `@flybot/workflow`) in the interface language, and **Retry** stays (#1381). Any other failure still shows its own reason. The page does not teach `workflows/.json`. The definition engine that still stores those files stays on the host and is not this page. Tests: `core/test/workflows-page.test.js`. - The sidebar footer button (`ConnectApps` in `sidebar.js`, label **Connect apps** / 连接应用) opens Settings > Connectors through `actions.openConnectors()`, the same call and the same words as the account menu's **Connect apps** row. The shell's plugin panel stays reachable in the Bot surface through the account menu's **Plugins** row (`actions.selectPanel('plugins')`, 插件), without switching to classic Agent mode. - One name per destination in the account menu and command palette (#392, #393, #394): **Settings** opens the Settings dialog (General), since **Bot settings** / **Edit Bot** open one Bot; **Scheduled tasks** opens Settings → Scheduled tasks, the name Settings and a Bot's details use (DSH's own bundle stays "Automation tasks" in the Plugins hints); and **Switch to classic Agent mode** is the only way to classic Agent mode, so Settings → General is Appearance only.