mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Feature: prompter gold upgrade (#84)
* feat(prompter): make the assistant a RoboCo insider and fully wire launch
The Prompter's intelligence lived in two thin static prompts, so it asked
generic checklist questions and produced a flat task. The launch path was
also only half-wired: the panel called the generic task-create endpoint with
no project, bypassing the Prompter's own confirm flow.
Interview brain
- Rewrite the chat system prompt with RoboCo's org model, the task-spec
standard, a dimensions playbook, and a reflect-back, 1-2-questions-per-turn,
auto-stop discipline.
- Inject the live projects/products list each turn so the assistant grounds
questions in real surfaces and resolves the target itself.
- Replace the brittle phrase-match readiness with a parsed roboco-meta control
block (parse_readiness); the block is stripped from the visible reply and the
turn now returns draft_ready + scale.
Structured GOLD draft
- Add first-class draft fields (objective, what_this_builds, the_work, notes)
carried in the existing draft_data JSONB — no migration.
- Compose the GOLD markdown description deterministically from those fields
(compose_description); the model never hand-formats the body.
Adaptive routing + wired launch
- Confirm now runs through the Prompter confirm endpoint with the human's
project/product choice and edited structured draft.
- Single-cell targets a project and the cell team; a multi-cell feature targets
a product and becomes a Main-PM coordination root that fans out.
Frontend
- Turn-envelope draft_ready (drop the duplicated phrase-match), structured
draft card, confirm dialog with a project/product picker and a per-cell
The Work editor, and the corrected priority labels (0 highest .. 3 lowest).
* fix(prompter): commit session writes so they survive across requests
Session create returned 201 but the row was never durably committed, so the
immediately-following /messages call could not find it and 404'd. The prompter
routes were the only write surface that never called db.commit() — every other
write route (tasks, a2a, groups, docs, product) commits explicitly rather than
rely on the request-teardown auto-commit, which is sensitive to middleware and
teardown ordering under the production server.
- Commit explicitly in all four prompter write routes (create session, send
message, get/generate draft, confirm).
- Fix _get_session's NotFoundError: it passed a full sentence as resource_type,
producing the doubled "... not found not found" message; now uses the
(resource_type, resource_id) signature.
- Panel: when a message hits a session the server no longer has, start a fresh
session and retry once instead of dead-ending on a stale id.
Add a regression test that gives each request its own non-committing session —
the real cross-request boundary the shared-session integration tests never
crossed. It reproduces the production 404 without the route commit and passes
with it.
* refactor(prompter): drop the "GOLD" jargon for plain wording
"GOLD" was informal shorthand for "a good/well-formed spec" that should never
have been baked into the LLM prompts, comments, and docstrings as if it were a
defined term. Replace it everywhere with plain language ("a well-formed task",
"a complete task spec", "the markdown description", "structured spec fields").
No behaviour change.
* feat(intake): add the intake interviewer agent role (static definition)
Phase 1 of the intake-agent feature: a new first-class `prompter` role — the
intake interviewer the CEO chats with to draft a task. This commit defines the
role across every foundation layer (no runtime yet); spawning + the live
session come next.
- identity: Role.PROMPTER, RoleLevel.INTAKE (lowest authority), an AGENTS row
(intake-1) on the board team, ROLE_LEVEL entry. Deliberately NOT in
BOARD_ROLES — it interviews, it does not review.
- lifecycle: gets i_am_idle like every agent (its only verb); no
delivery-lifecycle intents.
- journaling: ReadTier.OWN — isolated, reads only its own journal.
- role_config: human-only manifest — note + evidence only, no say/dm/notify/
channels; allows_subagent=True (research), allows_write=False.
- agents_config derives it automatically and correctly excludes it from
TASK_CREATOR_ROLES (it drafts, it never creates tasks).
- seed presentation ("Intake"); regenerated lifecycle artifacts.
- role system prompt: read the code first, single-CEO awareness, propose
rather than interrogate — written against the failures we saw.
- docs: roster count 19 -> 20, org charts, verb-surface table, usage roster.
All foundation drift checks pass; role/manifest/permission tests green.
* feat(intake): migrate agentrole enum to add 'prompter'
ALTER TYPE agentrole ADD VALUE IF NOT EXISTS 'prompter' so the intake agent
row seeds/spawns against a migrated production DB. Forward-only (postgres
can't drop enum values), guarded for offline mode — matches migration 012.
* style(intake): ruff format the role additions
* fix(intake): unguard the agentrole migration so it renders offline
The enum-migration-parity test renders 'alembic upgrade head --sql' (offline)
and greps for ALTER TYPE ... ADD VALUE. The is_offline_mode() guard skipped
emitting it, so the parity check couldn't see 'prompter'. Drop the guard —
PG16 permits ADD VALUE in a transaction, same as migration 020's backfill.
* feat(intake): the live-session driver (Claude Agent SDK loop)
Phase 2 begins. The intake agent isn't a one-shot `claude -p`; it's a live
Claude Code session the human chats with. This driver is the container's loop:
open one long-lived claude-agent-sdk ClaudeSDKClient, then per human message
run a turn (query + receive_response) and stream its events out, keeping
conversation context in-process — verified against the real SDK (v0.2.94).
- StreamChunk + normalize(): map SDK messages (StreamEvent text deltas,
AssistantMessage text/thinking/tool_use blocks, ResultMessage→session_id) to
panel-facing chunks. Duck-typed, so it works on real SDK objects and on test
fakes alike — SDK-free, fully unit-tested.
- IntakeDriver.run(): the loop, with injected session/source/sink seams; a turn
failure surfaces as an error chunk without killing the session.
- SdkIntakeSession + build_intake_options: the only SDK-coupled code (lazy
import; needs the live claude binary, so excluded from coverage).
- Add claude-agent-sdk dependency + mypy ignore-missing-stubs.
Relay, panel SSE, and the persistent on-demand spawn are the next steps.
* Updated uv.lock
* feat(intake): the panel<->agent live bridge (registry, routes, entrypoint, image)
Wires the live intake chat end to end (Phase 2 integration layer):
- prompter_live.py: the orchestrator-side per-session registry — open/close,
push (agent->panel), stream (SSE drain), deliver (panel->container). In-process
(the orchestrator is single-process). 7 unit tests.
- routes/prompter_live.py: GET /live/{id}/stream (SSE), POST /live/{id}/messages
(deliver), POST /live/{id}/events (relay in); registered under /api/prompter.
5 integration tests.
- agent_sdk/intake_main.py: the container entrypoint — a POST /turn receiver
(the driver's MessageSource) + a relay-poster EventSink + the ClaudeSDKClient
session, run concurrently. 4 unit tests on the wiring helpers.
- docker/agent-prompter.Dockerfile: FROM base, ENTRYPOINT = the driver (not the
one-shot `claude` the other agents use).
Remaining for Phase 2: the orchestrator persistent-spawn path (scope->workspace
clone, CMD = driver, registry.open on spawn, reap-on-confirm) — the deploy-side
piece, best finalized against a buildable image.
* feat(intake): orchestrator persistent spawn + start/stop for the live chat
Add the task-free spawn path for the intake (prompter) agent: one fixed
intake-1 container running the Agent-SDK driver (image ENTRYPOINT, not
claude -p), one live session at a time.
- spawn_intake_session clones the scope's repo(s) via WorkspaceService
(project -> one; product -> each distinct project, primary first),
composes the intake-1 prompt, resolves the model, and builds docker run
via _build_intake_run_cmd: no settings/hook mount (driver owns 9000),
no MCP config, no -w; registers the live relay and best-effort delivers
the opening message once the receiver is up.
- reap_intake_session closes the relay and stops the container.
- Routes: POST /live/start (project XOR product) and POST /live/{id}/stop.
- ROLE_MODEL_MAP[prompter]=opus; intake-1 -> roboco-agent-prompter image map.
- Replace the budget-sweep try/except/continue with _fetch_budget_status,
which logs the swallow at debug instead of silently dropping it.
25 new tests; docker + the clone are mocked. End-to-end container spawn is
pending a built image and the stack.
* feat(intake): wire /prompter to the live agent — scope form + SSE chat
Replace the Ollama chat loop on /prompter with the spawned-agent flow.
- IntakeForm: pick scope (project XOR product) + opening message + Start
before the chat; the agent clones that scope and reads the real code.
- use-prompter rewritten as the live brain (lib/api/prompter-live.ts): Start
spawns via POST /live/start, then an EventSource on /live/{id}/stream
streams the agent working — token deltas fill the assistant bubble,
tool_use/thinking drive a live activity line, a draft event renders the
existing DraftProposalCard. Messages go via POST /live/{id}/messages.
- Chat UX unchanged (Keep Chatting / Review & Confirm / ConfirmDialog reused);
reap-on-confirm and reap-on-leave call POST /live/{id}/stop.
- Drop the dead Ollama prompterApi client; trim prompter.ts to shared types.
Frontend gate green (tsc --noEmit, lint, build). The draft event + the
/live/{id}/confirm endpoint are the Phase 4 backend seam.
* feat(intake): confirm draft -> backlog task + agent draft emission
Complete the live intake vertical: the agent proposes a structured draft and
Review & Confirm turns it into a task.
- Draft emission: the prompter prompt instructs the agent to emit a fenced
roboco-draft JSON block when the spec is ready; the driver parses it into a
'draft' event over the existing relay -> the panel's DraftProposalCard. The
panel strips the raw block from the chat bubble.
- Fix a double-text bug: with include_partial_messages the reply arrives as
both StreamEvent deltas and the final AssistantMessage; the driver now takes
text from deltas only and the AssistantMessage for thinking/tool_use/draft.
- POST /live/{id}/confirm -> confirm_live_draft, reusing a draft->task core
extracted from confirm_draft; reaps the session on success.
- Both prompter confirm paths create at BACKLOG, not pending: backlog is the
holding area a draft waits in until it's reviewed and promoted to pending
(TaskService.activate). The legacy Ollama confirm was creating at pending,
skipping that gate — fixed.
- Remove the dead 'context' bootstrap param from the Ollama session-create
chain (schema + route + method + tests), superseded by the live scope form.
- No suppressions: replace every type:ignore/noqa across the intake surface
with a real fix (ORM .id -> UUID(str(x)); fakes -> monkeypatch.setattr;
lazy imports -> pyproject per-file ignore; union-attr -> recipients[0]).
Full make quality green; frontend tsc + lint green.
* build(intake): add the agent-prompter image builder to compose
The orchestrator references roboco-agent-prompter (AGENT_IMAGES + the
_ensure_agent_image dockerfile map) and docker/agent-prompter.Dockerfile
exists, but docker-compose.yml built every other agent image up front and
left this one out — so the image wasn't pre-built for a stack bring-up.
Mirror the other specialized agent-*-image builders: build from
docker/agent-prompter.Dockerfile, tag roboco-agent-prompter, depend on
agent-base-image.
* Created docker-compose.yaml for the NAS
* fix(intake): non-blocking /live/start so spawn never times out
The start POST awaited the whole spawn — workspace clone + first-time image
build + docker run — which blew past the panel's 60s HTTP timeout ('Request
timed out. The server may be busy.') and triggered a duplicate send. Found on
the 2026-06-09 NAS smoke.
- start_intake_session opens the live relay synchronously, then spawns the
container in the background (_spawn_intake_container_guarded). The route
returns the session id immediately; the panel opens the SSE stream right away.
- A background spawn failure is pushed onto the relay as an 'error' event and
closes the session, so the panel shows it instead of hanging.
- spawn_intake_session stays as the synchronous variant for direct callers/tests.
- Panel shows a 'Preparing the agent…' indicator until the first event arrives.
18 intake-spawn tests green; tsc + lint green. E2E re-validates on next smoke.
* fix(intake): propose_draft MCP tool + lock the agent down
Smoke 2026-06-09 exposed two compounding problems: the agent never reliably
emitted the draft (it narrated the spec instead of typing the magic fence), and
it had inherited the CEO's entire Claude Code env — Write/Edit/Bash + Gmail/
Notion/Calendar/Drive MCP — because bypassPermissions ignored the allowlist and
the mounted ~/.claude leaked the host MCP config.
- propose_draft: build_intake_options now registers an in-process SDK MCP tool
(create_sdk_mcp_server + @tool). The agent calls it to submit the draft; the
driver turns that ToolUseBlock into a 'draft' event (_is_propose_draft /
_draft_from_tool_input, tolerant of nested/flat/JSON-string input). The fenced
roboco-draft block stays as a fallback.
- Lockdown: strict_mcp_config=True + setting_sources=[] (ignore host MCP +
settings); permission_mode 'dontAsk' + a can_use_tool gate enforcing a hard
allowlist (Read/Grep/Glob/Task + propose_draft) replaces bypassPermissions.
- Prompt: call propose_draft (not a fence); the draft's downstream chain is
backlog -> Board (PO + HoM) -> CEO approve -> Main PM, and the agent's job ends
at the draft (it never routes or hands off).
SDK API verified against the installed claude-agent-sdk. Driver detection unit-
tested; the SDK-construction is validated on the next NAS smoke (incl. that
setting_sources=[] doesn't break the mounted-~/.claude auth).
* fix(intake): panel UX cluster from the smoke (#3/#4/#6/#12)
- #3 message boundaries: a tool call now ends the current text bubble, so the
agent's words before and after a tool render as separate messages instead of
one merged wall (the 'two waves merged into one bubble' the CEO saw).
- #4 activity indicator: promoted from tiny grey text to a prominent primary-
tinted pill so 'watch it work' is actually visible.
- #12 End chat: a header button (any chat state) reaps the agent and resets to
the form, reusing startAnother (which already stops the session). Backend
POST /live/{id}/stop already existed.
- #6 log noise: the opening-message delivery retry logs at debug, not error —
those failures are expected until the container receiver is up.
- Also fix a latent test gap from the #1 commit: the live-route test's fake
orchestrator now exposes start_intake_session (the route's non-blocking entry).
Frontend tsc + lint green; live-route + prompter_live tests green.
* fix(intake): render markdown in the chat bubbles (#8)
The agent emits rich markdown (### headers, **bold**, tables, lists) but the
bubble rendered raw text, so it was illegible (CEO-flagged on the smoke). Render
assistant content with react-markdown + remark-gfm (GFM tables) in a prose
container. Adds react-markdown + remark-gfm to the panel.
* feat(intake): #14 — two start routes (Board review vs straight to Main PM)
Per the CEO spec, the draft confirm now starts the task at PENDING with an
explicit assignment instead of parking it at backlog:
- route="board" (Board review & Start): assigned to the Product Owner, so the
orchestrator dispatches the full Board review (PO + Head of Marketing) before
the Main PM picks it up.
- route="main_pm" (Approve & Start): assigned straight to the Main PM, who
delegates to the cells (Board review skipped).
create_task_from_draft gains status + assigned_to params (default BACKLOG, so the
legacy confirm_draft is unchanged); confirm_live_draft + the /live/{id}/confirm
request carry the route. Service tests cover both routes.
* feat(intake): #14 draft-card buttons — Board review vs Approve & Start
Three buttons on the draft card now (CEO spec): Keep chatting / Board review &
Start / Approve & Start. The two action buttons confirm directly with their
route — launchTask(route) sends route to POST /live/{id}/confirm, which starts
the task at pending assigned to the Board (PO+HoM) or straight to the Main PM.
Supersedes the ConfirmDialog review step (scope is chosen up front in the form),
so it's removed from the page flow. The ConfirmDialog component + its sub-editors
are now unused — flagged for a follow-up cleanup, left in place to avoid churn.
tsc + lint green.
* fix(intake): keep the live SSE stream bound to its relay session
The orchestrator opened the relay session twice per live chat — once on the
request path (before the start call returns) and again inside the background
container spawn. The SSE stream binds to the session's queue the moment the
panel connects, so the second open swapped in a fresh queue and stranded the
stream: the agent replied normally, but its events went to the new queue while
the panel kept reading the old one, so the chat looked frozen on "Preparing…".
The second open was always redundant (the relay is opened by the caller before
the spawn). Remove it, and make open() idempotent so a live session is never
replaced out from under a stream that is already connected to it.
* fix(intake): draft-card launch buttons silently did nothing
The launch path required a `description` field, but the prompter draft schema
intentionally has none — it sends `objective` + the structured spec and the
backend composes the description (compose_description). `editableDraft.description`
was therefore undefined, so `description.trim()` inside launch validation threw a
TypeError that propagated out of the button's onClick. Clicking "Board review &
Start" / "Approve & Start" did nothing, with no feedback — the wall blocking the
whole confirm → task → reap flow.
- Map a proposed draft's description from `objective` as a fallback.
- Make launch validation null-safe.
- Replace the silent early-return with a toast that names what's missing, so a
blocked launch is never a dead, feedback-less button again.
* fix(intake): steer the agent to ask inline, not via AskUserQuestion
The intake's job is to ask clarifying questions, so it reached for the
AskUserQuestion tool — which isn't wired to the live chat panel and isn't in its
allowlist. The bare deny left it to stumble ("let me clarify… — no worries, let
me just lay it out") and waste a visible turn.
- Prompt: spell out that it asks by writing in the chat (the human reads every
message live) and that no question/prompt tool is available to it.
- Gate: give AskUserQuestion a specific deny message that nudges it to ask inline,
so even a reflex attempt degrades gracefully.
Also refresh the now-stale "what happens after propose_draft" section: the draft
card has three choices (Keep chatting / Board review & Start / Approve & Start)
and produces a pending task — not the old two-button "backlog" description.
* feat(intake): copy buttons on agent messages and the draft card
The CEO asked for a way to save the agent's plan/spec elsewhere "just in case" —
a cheap manual backstop until refresh-durability lands.
- New CopyButton: async Clipboard API when available, plus a legacy
textarea+execCommand fallback. The fallback is load-bearing — the panel is
served over plain http on a LAN IP, where navigator.clipboard is absent
(clipboard needs a secure context), so the modern API alone would never copy.
- Copy button under each assistant message (copies its text).
- Copy button on the draft card (copies the full spec as markdown: title,
objective, what-this-builds, the-work per cell, notes, success criteria).
* feat(intake): unbuffer logs + log each turn so the container isn't a black box
Debugging the intake smoke was painful for two reasons: (a) the orchestrator
block-buffered stdout, so `docker logs` lagged minutes behind reality, and (b)
the intake container logged only "session opened" then went silent for the whole
conversation (the chat streams to the relay, not stdout).
- Set PYTHONUNBUFFERED=1 on the orchestrator and agent-base images so structured
logs reach `docker logs` in real time instead of in large delayed chunks.
- Log each intake turn: "turn received" (with char count) and "turn streamed"
(chunk count + whether a draft was emitted), so the container logs show the
conversation's shape at a glance.
* chore(intake): remove the dead ConfirmDialog draft editor
The three-button draft card (Keep chatting / Board review & Start / Approve &
Start) replaced the old review-modal confirm flow, leaving ConfirmDialog and its
sub-editors (StringListEditor, TheWorkEditor) referenced by nothing but the
barrel export. Remove the three files and the export — typecheck + lint confirm
no remaining references.
* fix(intake): coerce bad draft enums on confirm instead of hard-failing
The intake agent is an LLM and will emit off-enum values — e.g. task_type="feature",
which is not a valid TaskType (code/documentation/research/planning/design/
administrative). `_coerce_draft_enums` called `TaskType(value)` directly, which
raised, and the confirm 400'd with "Draft has invalid or missing required fields:
'feature' is not a valid TaskType". That forced the agent to discover the valid
values and self-correct in-chat — unacceptable: clicking "Approve & Start" must
never blow up on a cosmetic enum guess.
Coerce each enum to a sane default on invalid/missing (task_type→code,
nature→technical, complexity→medium); team falls back to the first valid cell in
the_work, then backend. `_lead_cell_team` now skips invalid cell names too. The
confirm/launch action no longer hard-fails on an enum the model got wrong.
* fix(intake): draft card no longer renders above the user's latest message
attachDraft fell back to "the last assistant message anywhere" when the current
turn had no streamed text yet (propose_draft called first). That last message was
often the PREVIOUS turn's — sitting above the user's "Yes, propose it" — so the
draft card rendered above the user's message. Attach only to the current turn's
streaming message; otherwise append a fresh assistant message so the card always
lands at the bottom of the thread.
* test(intake): guard draft enum coercion + invalid-cell skipping
Regression tests for the confirm-time enum coercion: an off-enum task_type
("feature") / nature / complexity coerce to code/technical/medium instead of
raising, and _lead_cell_team skips invalid cell names. Locks in that a bad enum
guess from the agent can never 400 the launch again.
* fix(intake): stop the agent fumbling through Claude Code meta-tools
In smoke it reflexively probed CC built-ins before reaching propose_draft —
plan mode + ExitPlanMode (it announced a written plan and waited instead of
emitting the draft), ToolSearch, Write — each correctly denied by the lockdown
but stumbly, and it only proposed after explicit CEO nudges.
- Gate: ExitPlanMode now gets a specific deny nudge ("you don't use plan mode;
call propose_draft"), and the generic deny names the actual toolset instead
of a bare "not available", so any probe degrades into guidance.
- Prompt: forbid plan mode/ExitPlanMode/ToolSearch explicitly and spell out
"you do not plan and wait — call propose_draft directly when the spec is
ready," plus an anti-pattern bullet.
* feat(intake): make the container logs transparent mid-turn
`docker logs` on the intake container was a black box: only turn start/end, while
the agent read the codebase and spawned 20+ subagents invisibly (the conversation
streams to the relay, not stdout), and the benign 3x ~/.claude.json warning was
the only thing visible.
- Driver logs each tool call mid-turn ("Intake tool use" with the tool name) and
the draft emission, plus a tools count in the turn-streamed summary. Text deltas
stay unlogged (they'd spam). Now the logs show the turn's real shape.
- Pre-create ~/.claude.json ({}) at container boot so the CLI's "config not found"
warning (printed 3x, self-healed anyway) stops drowning the real logs.
* fix(intake): render markdown in user messages + scope copy to code blocks
Two display fixes from the smoke:
- User messages collapsed newlines (plain {content} in a div) and rendered no
markdown — a "1.\n2.\n3." answer showed as one run-on line. Render user AND
assistant bubbles through a shared GFM markdown body that inherits the bubble's
text color, so lists / newlines / styling render correctly on both.
- Copy was blanketed on every assistant message; scope it to KEY parts — a copy
button on fenced code blocks (the draft card keeps its own). Removed the
per-message button.
* fix(intake): prevent duplicate tasks from a double-click on launch
Clicking a draft launch button twice fired two confirms and created duplicate
tasks. Add a synchronous re-entry guard (a ref — no stale-closure window) at the
top of launchTask so a second click returns immediately, and disable + spin the
draft-card buttons while a launch is in flight so it's visually clear it's working.
* docs(how-to): lead task creation with the Task Assistant flow
Rewrite "1 · It starts with you" to walk the Prompter/Task Assistant path —
scope form, the agent reading the codebase, its grounded analysis, the draft
card, and the created task — then flow into the Board review. Replaces the old
manual task-definition form shots.
Image placeholder: images/prompter_draft_card.png (the 3-button card) is
referenced but not yet captured — TODO comment marks it for the next smoke run.
A second comment flags an optional re-capture of prompter_run_2 after the
markdown-rendering fix.
* fix(intake): restore assistant message text contrast
The markdown refactor dropped `dark:prose-invert` and made text inherit the
bubble's color, but the assistant bubble had no explicit text color — so its text
rendered near-invisible (dark-on-dark on bg-muted). Give the assistant bubble an
explicit text-foreground; the user bubble already carries text-primary-foreground,
and [&_*]:!text-inherit now resolves to a readable color on both.
* fix(intake): coerce draft priority too — confirm 500'd on priority="high"
The enum-coercion fix covered task_type/nature/complexity/team, but priority is a
non-enum int field handled by `int(draft_data.get("priority", 2))`, and the agent
guesses a word ("high") as readily as a number — so int("high") raised ValueError
and the confirm 500'd. Same class of bug, one field missed.
Add _coerce_priority: map words (urgent/high/medium/low → 0/1/2/3), clamp numbers
to 0-3, default to 2 (medium) on anything else. The launch can no longer crash on
any field the LLM guessed. + regression test.
* fix(intake): draft card shows distinct cells, not one badge per work item
the_work has one entry per work item, so a cell with several items rendered its
badge repeatedly ("Board-led across Backend Backend Backend Frontend Frontend
…"). De-dupe to distinct teams so the card reads "Board-led across Backend
Frontend" — and the "Cell:" vs "Board-led across" label keys off distinct count.
* docs(how-to): hero the teaser gif + resolve the Prompter/Task Assistant thread
- Move the 12s teaser gif to the top as the hero — it was buried between the
"prefer video" link and the first screenshot.
- Name the connection: the Task Assistant IS the Prompter, so section 1 (using
the tool) and the rest (RoboCo building it) read as one story — you use the
tool the company built for itself, then watch the build.
- Re-anchor the section 1 → Board transition to follow the Prompter's own
journey, instead of implying section 1's example task is the one reviewed next.
* Included images for how-to.md
* docs(how-to): align agent count to 20 (matches README + CLAUDE.md)
The how-to said "18 agents" with UX/UI at one dev and no Intake — stale against
the authoritative count. Bump 18→20 (prose + spelled-out eighteen→twenty), give
UX/UI 2 devs, and add the Intake line to the org tree (Intake leads section 1, so
it belongs in the tree). README + CLAUDE.md already say 20.
* ci(release): publish all RoboCo images to GHCR + Docker Hub
The release published only the orchestrator to GHCR. Build and push the full set
the stack needs — agent-base, the 8 agent images, orchestrator, and panel — to
BOTH ghcr.io/rennf93/* and docker.io/renzof93/*, at :<version> and :latest, so
consumers can pull instead of compose-building.
- agent-base builds first (the agent images build FROM roboco-agent-base, a local
tag), then the rest; push only after every build succeeds.
- Image names mirror the docker-compose `image:` values 1:1.
- Free disk on the runner first (11 images is space-heavy).
- Needs a DOCKERHUB_TOKEN repo secret for the Docker Hub login.
- SECURITY.md updated to reference both registries.
* ci(release): use short SHA as the image tag on manual dispatch
A workflow_dispatch runs against a branch, and the branch name (e.g.
feature/prompter-gold-upgrade) was used verbatim as the image tag — but "/" is
illegal in a Docker tag, so the first build failed instantly with "invalid
reference format". Releases still tag from the release tag; manual dispatch now
always uses the short SHA, which is a valid tag.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Loader2, Sparkles, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePrompter } from "@/hooks/use-prompter";
|
||||
import {
|
||||
ChatMessages,
|
||||
ChatComposer,
|
||||
ConfirmDialog,
|
||||
SuccessCard,
|
||||
IntakeForm,
|
||||
} from "@/components/prompter";
|
||||
|
||||
export default function PrompterPage() {
|
||||
@@ -14,23 +15,30 @@ export default function PrompterPage() {
|
||||
state,
|
||||
messages,
|
||||
isSending,
|
||||
editableDraft,
|
||||
activity,
|
||||
createdTaskId,
|
||||
createdTaskTitle,
|
||||
createdTaskTeam,
|
||||
targetKind,
|
||||
setTargetKind,
|
||||
projectId,
|
||||
setProjectId,
|
||||
productId,
|
||||
setProductId,
|
||||
initialMessage,
|
||||
setInitialMessage,
|
||||
isFormValid,
|
||||
start,
|
||||
send,
|
||||
openReview,
|
||||
closeReview,
|
||||
keepChatting,
|
||||
updateDraft,
|
||||
isValidForLaunch,
|
||||
launchTask,
|
||||
startAnother,
|
||||
isLaunching,
|
||||
} = usePrompter();
|
||||
|
||||
const showForm = state === "form" || state === "preparing";
|
||||
const isComposerDisabled =
|
||||
state === "launching" || state === "success";
|
||||
state === "launching" || state === "success" || isSending;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -40,54 +48,82 @@ export default function PrompterPage() {
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Task Assistant</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Describe your idea and I'll help you create a structured task
|
||||
Chat with an agent that reads your code and drafts the task
|
||||
</p>
|
||||
</div>
|
||||
{/* End chat — reap the agent and return to the form (any chat state) */}
|
||||
{!showForm && state !== "success" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto text-muted-foreground"
|
||||
onClick={startAnother}
|
||||
disabled={state === "launching"}
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
End chat
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Success overlay in chat area */}
|
||||
{state === "success" &&
|
||||
{showForm ? (
|
||||
<IntakeForm
|
||||
targetKind={targetKind}
|
||||
onTargetKind={setTargetKind}
|
||||
projectId={projectId}
|
||||
onProjectId={setProjectId}
|
||||
productId={productId}
|
||||
onProductId={setProductId}
|
||||
initialMessage={initialMessage}
|
||||
onInitialMessage={setInitialMessage}
|
||||
isValid={isFormValid()}
|
||||
isPreparing={state === "preparing"}
|
||||
onStart={start}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Success overlay in chat area */}
|
||||
{state === "success" &&
|
||||
createdTaskId &&
|
||||
createdTaskTitle &&
|
||||
createdTaskTeam ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-8 py-8">
|
||||
<div className="w-full max-w-md">
|
||||
<SuccessCard
|
||||
taskId={createdTaskId}
|
||||
taskTitle={createdTaskTitle}
|
||||
team={createdTaskTeam}
|
||||
onStartAnother={startAnother}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-8 py-8">
|
||||
<div className="w-full max-w-md">
|
||||
<SuccessCard
|
||||
taskId={createdTaskId}
|
||||
taskTitle={createdTaskTitle}
|
||||
team={createdTaskTeam}
|
||||
onStartAnother={startAnother}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
onOpenReview={openReview}
|
||||
onKeepChatting={keepChatting}
|
||||
/>
|
||||
)}
|
||||
) : (
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
onStart={launchTask}
|
||||
onKeepChatting={keepChatting}
|
||||
isLaunching={isLaunching}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
<ChatComposer
|
||||
onSend={send}
|
||||
disabled={isComposerDisabled}
|
||||
isSending={isSending}
|
||||
/>
|
||||
</div>
|
||||
{/* Live activity indicator — "watch it work" (prominent) */}
|
||||
{activity && state !== "success" && (
|
||||
<div className="mx-4 mb-2 flex items-center gap-2.5 rounded-lg border border-primary/30 bg-primary/10 px-4 py-2.5 text-sm font-medium text-primary">
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
|
||||
<span>{activity}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation dialog (portal) */}
|
||||
<ConfirmDialog
|
||||
open={state === "review_modal" || state === "launching"}
|
||||
draft={editableDraft}
|
||||
onClose={closeReview}
|
||||
onUpdate={updateDraft}
|
||||
onConfirm={launchTask}
|
||||
isLaunching={isLaunching}
|
||||
isValid={isValidForLaunch()}
|
||||
/>
|
||||
{/* Composer */}
|
||||
{state !== "success" && (
|
||||
<ChatComposer
|
||||
onSend={send}
|
||||
disabled={isComposerDisabled}
|
||||
isSending={isSending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatMessage } from "@/hooks/use-prompter";
|
||||
import { CopyButton } from "@/components/ui/copy-button";
|
||||
import type { ChatMessage, StartRoute } from "@/hooks/use-prompter";
|
||||
import { DraftProposalCard } from "./draft-proposal-card";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: ChatMessage[];
|
||||
onOpenReview: () => void;
|
||||
onStart: (route: StartRoute) => void;
|
||||
onKeepChatting: () => void;
|
||||
isLaunching?: boolean;
|
||||
}
|
||||
|
||||
/** Raw text of a fenced code block — the <pre>'s <code> child's string content. */
|
||||
function codeText(children: ReactNode): string {
|
||||
const codeEl = children as ReactElement<{ children?: ReactNode }> | undefined;
|
||||
const inner = codeEl?.props?.children;
|
||||
if (typeof inner === "string") return inner;
|
||||
if (Array.isArray(inner)) {
|
||||
return inner.filter((c): c is string => typeof c === "string").join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Copy lives on KEY PARTS only: fenced code blocks here, and the draft card has
|
||||
// its own. (Not a blanket button on every whole message.)
|
||||
const markdownComponents = {
|
||||
pre(props: ComponentPropsWithoutRef<"pre">) {
|
||||
const text = codeText(props.children).replace(/\n$/, "");
|
||||
return (
|
||||
<div className="group relative">
|
||||
<pre {...props} />
|
||||
{text && (
|
||||
<CopyButton
|
||||
value={text}
|
||||
className="absolute right-1.5 top-1.5 bg-background/80 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** GFM markdown that inherits the bubble's text color, so it renders correctly on
|
||||
* both the muted assistant bubble and the primary user bubble (lists, code,
|
||||
* newlines all preserved). */
|
||||
function MarkdownBody({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none [&_*]:!text-inherit prose-p:my-1.5 prose-headings:mt-3 prose-headings:mb-1 prose-pre:my-2 prose-pre:bg-black/20">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessages({
|
||||
messages,
|
||||
onOpenReview,
|
||||
onStart,
|
||||
onKeepChatting,
|
||||
isLaunching,
|
||||
}: ChatMessagesProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -43,7 +92,7 @@ export function ChatMessages({
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-end">
|
||||
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-primary px-4 py-3 text-sm text-primary-foreground">
|
||||
{msg.content}
|
||||
<MarkdownBody content={msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -66,11 +115,11 @@ export function ChatMessages({
|
||||
<div className="flex justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[70%] rounded-2xl rounded-tl-sm bg-muted px-4 py-3 text-sm",
|
||||
"max-w-[70%] rounded-2xl rounded-tl-sm bg-muted px-4 py-3 text-sm text-foreground",
|
||||
msg.draft && "max-w-[85%]"
|
||||
)}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
<MarkdownBody content={msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +130,8 @@ export function ChatMessages({
|
||||
<DraftProposalCard
|
||||
draft={msg.draft}
|
||||
onKeepChatting={onKeepChatting}
|
||||
onOpenReview={onOpenReview}
|
||||
onStart={onStart}
|
||||
isLaunching={isLaunching}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { AcceptanceCriteriaEditor } from "@/components/tasks/acceptance-criteria-editor";
|
||||
import { MarkdownEditor } from "@/components/tasks/markdown-editor";
|
||||
import { Team, TaskType, Complexity } from "@/types";
|
||||
import type { EditableDraft } from "@/hooks/use-prompter";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
draft: EditableDraft;
|
||||
onClose: () => void;
|
||||
onUpdate: (updates: Partial<EditableDraft>) => void;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
isLaunching: boolean;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
const WARNING_BANNER_ID = "prompter-warning-banner";
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
draft,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onConfirm,
|
||||
isLaunching,
|
||||
isValid,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o && !isLaunching) onClose(); }}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review & Confirm Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Warning banner */}
|
||||
<div
|
||||
id={WARNING_BANNER_ID}
|
||||
className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
This will create a real task and notify the team. It cannot be undone from this screen.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Form fields — NOT wrapped in a <form> to prevent Enter-key submission bypass */}
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Title */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="prompter-title">
|
||||
Title <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="prompter-title"
|
||||
value={draft.title}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
placeholder="Task title"
|
||||
disabled={isLaunching}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<MarkdownEditor
|
||||
label="Description"
|
||||
value={draft.description}
|
||||
onChange={(v) => onUpdate({ description: v })}
|
||||
placeholder="Describe what needs to be done…"
|
||||
required
|
||||
minLength={20}
|
||||
/>
|
||||
|
||||
{/* Acceptance Criteria */}
|
||||
<AcceptanceCriteriaEditor
|
||||
criteria={draft.acceptance_criteria}
|
||||
onChange={(criteria) => onUpdate({ acceptance_criteria: criteria })}
|
||||
/>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Team */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
Team <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.team}
|
||||
onValueChange={(v) => onUpdate({ team: v as Team })}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select team" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(Team).map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.replace("_", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Priority</Label>
|
||||
<Select
|
||||
value={String(draft.priority)}
|
||||
onValueChange={(v) => onUpdate({ priority: Number(v) })}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">Low</SelectItem>
|
||||
<SelectItem value="1">Medium</SelectItem>
|
||||
<SelectItem value="2">High</SelectItem>
|
||||
<SelectItem value="3">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Task Type */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
value={draft.task_type || ""}
|
||||
onValueChange={(v) => onUpdate({ task_type: v as TaskType })}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(TaskType).map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Complexity */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Estimated Complexity</Label>
|
||||
<Select
|
||||
value={draft.estimated_complexity || ""}
|
||||
onValueChange={(v) => onUpdate({ estimated_complexity: v as Complexity })}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select complexity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(Complexity).map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c.charAt(0).toUpperCase() + c.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={!isValid || isLaunching}
|
||||
aria-describedby={WARNING_BANNER_ID}
|
||||
>
|
||||
{isLaunching ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
"Confirm & Launch"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircle, ClipboardCheck } from "lucide-react";
|
||||
import { MessageCircle, Users, Rocket, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CopyButton } from "@/components/ui/copy-button";
|
||||
import type { DraftProposal } from "@/lib/api/prompter";
|
||||
import type { StartRoute } from "@/hooks/use-prompter";
|
||||
|
||||
interface DraftProposalCardProps {
|
||||
draft: DraftProposal;
|
||||
onKeepChatting: () => void;
|
||||
onOpenReview: () => void;
|
||||
onStart: (route: StartRoute) => void;
|
||||
/** A launch is in flight — disable the actions so a double-click can't dupe. */
|
||||
isLaunching?: boolean;
|
||||
}
|
||||
|
||||
// 0 is the highest priority, 3 the lowest — matches the backend contract.
|
||||
const PRIORITY_LABELS: Record<number, string> = {
|
||||
0: "Low",
|
||||
1: "Medium",
|
||||
2: "High",
|
||||
3: "Urgent",
|
||||
0: "Urgent",
|
||||
1: "High",
|
||||
2: "Medium",
|
||||
3: "Low",
|
||||
};
|
||||
|
||||
const cellLabel = (team: string) =>
|
||||
team === "ux_ui" ? "UX/UI" : team.charAt(0).toUpperCase() + team.slice(1);
|
||||
|
||||
/** Render the draft as plain markdown text for the copy button, so the CEO can
|
||||
* stash the full spec elsewhere (a safety net until refresh-durability lands). */
|
||||
function draftToText(draft: DraftProposal): string {
|
||||
const lines: string[] = [`# ${draft.title}`, ""];
|
||||
if (draft.objective) lines.push("## Objective", draft.objective, "");
|
||||
if (draft.what_this_builds?.length) {
|
||||
lines.push(
|
||||
"## What This Builds",
|
||||
...draft.what_this_builds.map((b) => `- ${b}`),
|
||||
""
|
||||
);
|
||||
}
|
||||
if (draft.the_work?.length) {
|
||||
lines.push("## The Work");
|
||||
for (const cell of draft.the_work) {
|
||||
lines.push(`### ${cellLabel(cell.team)}`, cell.summary);
|
||||
if (cell.items?.length) lines.push(...cell.items.map((i) => `- ${i}`));
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
if (draft.notes?.length) {
|
||||
lines.push("## Notes", ...draft.notes.map((n) => `- ${n}`), "");
|
||||
}
|
||||
if (draft.acceptance_criteria.length) {
|
||||
lines.push(
|
||||
"## Success Criteria",
|
||||
...draft.acceptance_criteria.map((c) => `- ${c}`),
|
||||
""
|
||||
);
|
||||
}
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
export function DraftProposalCard({
|
||||
draft,
|
||||
onKeepChatting,
|
||||
onOpenReview,
|
||||
onStart,
|
||||
isLaunching = false,
|
||||
}: DraftProposalCardProps) {
|
||||
const priorityLabel = PRIORITY_LABELS[draft.priority ?? 2] ?? "High";
|
||||
const priorityLabel = PRIORITY_LABELS[draft.priority ?? 2] ?? "Medium";
|
||||
const cells = draft.the_work ?? [];
|
||||
// Distinct cells only: the_work has one entry per work item, so a cell with
|
||||
// several items would otherwise show its badge repeated (Backend Backend …).
|
||||
const distinctTeams = Array.from(new Set(cells.map((c) => c.team)));
|
||||
|
||||
return (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
@@ -33,7 +79,7 @@ export function DraftProposalCard({
|
||||
<CardTitle className="text-sm font-semibold leading-tight">
|
||||
{draft.title}
|
||||
</CardTitle>
|
||||
<div className="flex flex-wrap gap-1 shrink-0">
|
||||
<div className="flex flex-wrap items-center gap-1 shrink-0">
|
||||
{draft.team && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{draft.team}
|
||||
@@ -47,23 +93,38 @@ export function DraftProposalCard({
|
||||
{draft.task_type}
|
||||
</Badge>
|
||||
)}
|
||||
<CopyButton value={draftToText(draft)} className="ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pb-3 space-y-3">
|
||||
{/* Description excerpt */}
|
||||
{draft.description && (
|
||||
{/* Objective (falls back to a description excerpt) */}
|
||||
{(draft.objective || draft.description) && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||
{draft.description}
|
||||
{draft.objective || draft.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The Work — participating cells (distinct) */}
|
||||
{distinctTeams.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{distinctTeams.length > 1 ? "Board-led across" : "Cell:"}
|
||||
</span>
|
||||
{distinctTeams.map((team) => (
|
||||
<Badge key={team} variant="outline" className="text-xs">
|
||||
{cellLabel(team)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Acceptance criteria */}
|
||||
{draft.acceptance_criteria.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
Acceptance criteria ({draft.acceptance_criteria.length})
|
||||
Success criteria ({draft.acceptance_criteria.length})
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{draft.acceptance_criteria.slice(0, 4).map((criterion, i) => (
|
||||
@@ -84,23 +145,38 @@ export function DraftProposalCard({
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="gap-2 pt-0">
|
||||
<CardFooter className="flex-wrap gap-2 pt-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={onKeepChatting}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<MessageCircle className="mr-1.5 h-3.5 w-3.5" />
|
||||
Keep Chatting
|
||||
Keep chatting
|
||||
</Button>
|
||||
{/* Board review & Start → PENDING, assigned to PO + HoM for review */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={onOpenReview}
|
||||
onClick={() => onStart("board")}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<ClipboardCheck className="mr-1.5 h-3.5 w-3.5" />
|
||||
Review & Confirm
|
||||
{isLaunching ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Users className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Board review & Start
|
||||
</Button>
|
||||
{/* Approve & Start → PENDING, straight to Main PM (skip the board) */}
|
||||
<Button size="sm" onClick={() => onStart("main_pm")} disabled={isLaunching}>
|
||||
{isLaunching ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Rocket className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Approve & Start
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { ChatMessages } from "./chat-messages";
|
||||
export { ChatComposer } from "./chat-composer";
|
||||
export { DraftProposalCard } from "./draft-proposal-card";
|
||||
export { ConfirmDialog } from "./confirm-dialog";
|
||||
export { SuccessCard } from "./success-card";
|
||||
export { IntakeForm } from "./intake-form";
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { useProducts } from "@/hooks/use-products";
|
||||
import type { TargetKind } from "@/hooks/use-prompter";
|
||||
|
||||
interface IntakeFormProps {
|
||||
targetKind: TargetKind;
|
||||
onTargetKind: (k: TargetKind) => void;
|
||||
projectId: string;
|
||||
onProjectId: (id: string) => void;
|
||||
productId: string;
|
||||
onProductId: (id: string) => void;
|
||||
initialMessage: string;
|
||||
onInitialMessage: (v: string) => void;
|
||||
isValid: boolean;
|
||||
isPreparing: boolean;
|
||||
onStart: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-time scope form shown before the chat. The agent is spawned against
|
||||
* exactly one of project / product, clones that scope's repo(s), and reads the
|
||||
* real code before answering — so the scope must be chosen up front.
|
||||
*/
|
||||
export function IntakeForm({
|
||||
targetKind,
|
||||
onTargetKind,
|
||||
projectId,
|
||||
onProjectId,
|
||||
productId,
|
||||
onProductId,
|
||||
initialMessage,
|
||||
onInitialMessage,
|
||||
isValid,
|
||||
isPreparing,
|
||||
onStart,
|
||||
}: IntakeFormProps) {
|
||||
const { data: projects = [] } = useProjects();
|
||||
const { data: products = [] } = useProducts();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-8">
|
||||
<div className="w-full max-w-lg space-y-6 rounded-xl border bg-card p-6 shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Start an intake chat</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick what you're working on. An agent reads that code, then
|
||||
interviews you and drafts the task.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope: single-cell project vs board-led product */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Scope <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Tabs
|
||||
value={targetKind}
|
||||
onValueChange={(v) => onTargetKind(v as TargetKind)}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="project" disabled={isPreparing}>
|
||||
Single cell (Project)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="product" disabled={isPreparing}>
|
||||
Board-led (Product)
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{targetKind === "project" ? (
|
||||
<Select
|
||||
value={projectId}
|
||||
onValueChange={onProjectId}
|
||||
disabled={isPreparing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a project…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
value={productId}
|
||||
onValueChange={onProductId}
|
||||
disabled={isPreparing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a product…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name} ({p.cell_count} cells)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{products.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No products exist yet. A board-led feature needs a product (a
|
||||
cell→repo map) — create one under Products, or target a single
|
||||
project instead.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Opening message */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="intake-initial-message">
|
||||
What do you want to build?{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="intake-initial-message"
|
||||
value={initialMessage}
|
||||
onChange={(e) => onInitialMessage(e.target.value)}
|
||||
placeholder="Describe the idea. The agent will read the code and ask sharp follow-ups…"
|
||||
rows={4}
|
||||
disabled={isPreparing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={onStart}
|
||||
disabled={!isValid || isPreparing}
|
||||
>
|
||||
{isPreparing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Preparing the agent…
|
||||
</>
|
||||
) : (
|
||||
"Start chatting"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Put `text` on the clipboard.
|
||||
*
|
||||
* Tries the async Clipboard API first, then falls back to a hidden
|
||||
* textarea + `execCommand("copy")`. The fallback matters: the panel is served
|
||||
* over plain http on a LAN IP, and `navigator.clipboard` only exists in a
|
||||
* secure context (https / localhost) — so on the real deployment the modern
|
||||
* API is simply absent and the legacy path is what actually works.
|
||||
*/
|
||||
async function writeClipboard(text: string): Promise<boolean> {
|
||||
if (
|
||||
typeof navigator !== "undefined" &&
|
||||
navigator.clipboard &&
|
||||
window.isSecureContext
|
||||
) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// fall through to the legacy path
|
||||
}
|
||||
}
|
||||
try {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
ta.setAttribute("readonly", "");
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface CopyButtonProps {
|
||||
/** The text placed on the clipboard. */
|
||||
value: string;
|
||||
/** Visible label next to the icon; omit for an icon-only button. */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A small copy-to-clipboard button that flips to a check for ~1.5s on success. */
|
||||
export function CopyButton({ value, label, className }: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onCopy = async () => {
|
||||
if (await writeClipboard(value)) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
aria-label={label ?? "Copy"}
|
||||
title={label ?? "Copy"}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{label ? <span>{copied ? "Copied" : label}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+403
-124
@@ -1,19 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { prompterApi, type DraftProposal } from "@/lib/api/prompter";
|
||||
import {
|
||||
prompterLiveApi,
|
||||
LIVE_EVENT_KINDS,
|
||||
type LiveEvent,
|
||||
} from "@/lib/api/prompter-live";
|
||||
import {
|
||||
type DraftProposal,
|
||||
type CellWork,
|
||||
type DraftScale,
|
||||
type ConfirmPayload,
|
||||
} from "@/lib/api/prompter";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { useCreateTask } from "@/hooks/use-tasks";
|
||||
import type { TaskCreate, Team, TaskType, Complexity } from "@/types";
|
||||
import { Team } from "@/types";
|
||||
import type { TaskType, TaskNature, Complexity } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PrompterState =
|
||||
| "empty"
|
||||
| "form" // collecting scope + opening message (no chat yet)
|
||||
| "preparing" // agent spawning / cloning the repo(s)
|
||||
| "chatting"
|
||||
| "streaming" // a reply is mid-flight over SSE
|
||||
| "draft_preview"
|
||||
| "review_modal"
|
||||
| "launching"
|
||||
@@ -29,6 +41,12 @@ export interface ChatMessage {
|
||||
draft?: DraftProposal;
|
||||
}
|
||||
|
||||
/** Which target the human picked for this chat. */
|
||||
export type TargetKind = "project" | "product";
|
||||
|
||||
/** Which start button the human pressed on the draft card. */
|
||||
export type StartRoute = "board" | "main_pm";
|
||||
|
||||
export interface EditableDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -36,7 +54,92 @@ export interface EditableDraft {
|
||||
team: Team | "";
|
||||
priority: number;
|
||||
task_type: TaskType | "";
|
||||
nature: TaskNature | "";
|
||||
estimated_complexity: Complexity | "";
|
||||
// Structured spec fields
|
||||
objective: string;
|
||||
what_this_builds: string[];
|
||||
the_work: CellWork[];
|
||||
notes: string[];
|
||||
// Targeting
|
||||
targetKind: TargetKind;
|
||||
projectId: string;
|
||||
productId: string;
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: EditableDraft = {
|
||||
title: "",
|
||||
description: "",
|
||||
acceptance_criteria: [],
|
||||
team: "",
|
||||
priority: 2,
|
||||
task_type: "",
|
||||
nature: "",
|
||||
estimated_complexity: "",
|
||||
objective: "",
|
||||
what_this_builds: [],
|
||||
the_work: [],
|
||||
notes: [],
|
||||
targetKind: "project",
|
||||
projectId: "",
|
||||
productId: "",
|
||||
};
|
||||
|
||||
function newId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
/** Remove the fenced ```roboco-draft block from displayed text — the structured
|
||||
* draft card renders it; the raw JSON shouldn't sit in the chat bubble. */
|
||||
function stripDraftFence(text: string): string {
|
||||
return text.replace(/```roboco-draft[\s\S]*?```/g, "").trimEnd();
|
||||
}
|
||||
|
||||
/** Map an agent-proposed draft (the `draft` SSE event payload) to the editable
|
||||
* form, carrying the chat's chosen scope through unchanged. */
|
||||
function toEditable(
|
||||
draft: DraftProposal,
|
||||
scale: DraftScale | null,
|
||||
scope: { targetKind: TargetKind; projectId: string; productId: string }
|
||||
): EditableDraft {
|
||||
return {
|
||||
title: draft.title,
|
||||
// The prompter's propose_draft schema has NO `description` field — it uses
|
||||
// `objective` + the structured spec. Fall back to objective so launch
|
||||
// validation never reads `undefined` (which threw on `.trim()` and silently
|
||||
// killed the button click).
|
||||
description: draft.description ?? draft.objective ?? "",
|
||||
acceptance_criteria: draft.acceptance_criteria,
|
||||
team: draft.team ?? "",
|
||||
priority: draft.priority ?? 2,
|
||||
task_type: draft.task_type ?? "",
|
||||
nature: draft.nature ?? "",
|
||||
estimated_complexity: draft.estimated_complexity ?? "",
|
||||
objective: draft.objective ?? "",
|
||||
what_this_builds: draft.what_this_builds ?? [],
|
||||
the_work: draft.the_work ?? [],
|
||||
notes: draft.notes ?? [],
|
||||
// The scope picked up front wins; fall back to scale only if unset.
|
||||
targetKind:
|
||||
scope.targetKind || (scale === "multi" ? "product" : "project"),
|
||||
projectId: scope.projectId,
|
||||
productId: scope.productId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pull a DraftProposal out of a `draft` SSE event's data payload. */
|
||||
function draftFromEvent(data: Record<string, unknown> | undefined): {
|
||||
draft: DraftProposal;
|
||||
scale: DraftScale | null;
|
||||
} | null {
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
if (typeof d.title !== "string") return null;
|
||||
const scale =
|
||||
d.scale === "single" || d.scale === "multi"
|
||||
? (d.scale as DraftScale)
|
||||
: null;
|
||||
return { draft: d as unknown as DraftProposal, scale };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -44,103 +147,236 @@ export interface EditableDraft {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function usePrompter() {
|
||||
const createTask = useCreateTask();
|
||||
|
||||
const [state, setState] = useState<PrompterState>("empty");
|
||||
const [state, setState] = useState<PrompterState>("form");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
/** The latest tool the agent is using — "watch it work" status line. */
|
||||
const [activity, setActivity] = useState<string | null>(null);
|
||||
const [createdTaskId, setCreatedTaskId] = useState<string | null>(null);
|
||||
const [createdTaskTitle, setCreatedTaskTitle] = useState<string | null>(null);
|
||||
const [createdTaskTeam, setCreatedTaskTeam] = useState<Team | null>(null);
|
||||
|
||||
/** Draft as shown in the draft-preview card */
|
||||
const [draftProposal, setDraftProposal] = useState<DraftProposal | null>(null);
|
||||
// The up-front scope form.
|
||||
const [targetKind, setTargetKind] = useState<TargetKind>("project");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [productId, setProductId] = useState("");
|
||||
const [initialMessage, setInitialMessage] = useState("");
|
||||
|
||||
/** Editable copy used in the confirmation dialog */
|
||||
const [editableDraft, setEditableDraft] = useState<EditableDraft>({
|
||||
title: "",
|
||||
description: "",
|
||||
acceptance_criteria: [],
|
||||
team: "",
|
||||
priority: 2,
|
||||
task_type: "",
|
||||
estimated_complexity: "",
|
||||
});
|
||||
const [editableDraft, setEditableDraft] = useState<EditableDraft>(EMPTY_DRAFT);
|
||||
|
||||
// Keep a ref to sessionId for callbacks to avoid stale closures
|
||||
// Live-session plumbing held in refs so SSE callbacks never see stale state.
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const sourceRef = useRef<EventSource | null>(null);
|
||||
const streamingIdRef = useRef<string | null>(null);
|
||||
// Synchronous re-entry guard for launch — a double-click was creating two tasks.
|
||||
const launchingRef = useRef(false);
|
||||
const scopeRef = useRef({ targetKind, projectId, productId });
|
||||
scopeRef.current = { targetKind, projectId, productId };
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// Message helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const addMessage = useCallback((msg: Omit<ChatMessage, "id">) => {
|
||||
const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const id = newId();
|
||||
setMessages((prev) => [...prev, { ...msg, id }]);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
/** Append a streamed token delta to the in-flight assistant message,
|
||||
* starting a fresh one if this is the first delta of the turn. */
|
||||
const appendDelta = useCallback((delta: string) => {
|
||||
setMessages((prev) => {
|
||||
const id = streamingIdRef.current;
|
||||
if (id) {
|
||||
return prev.map((m) =>
|
||||
m.id === id ? { ...m, content: m.content + delta } : m
|
||||
);
|
||||
}
|
||||
const newMsgId = newId();
|
||||
streamingIdRef.current = newMsgId;
|
||||
return [...prev, { id: newMsgId, role: "assistant", content: delta }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Attach the agent's proposed draft to the current/last assistant message,
|
||||
* stripping the raw draft block out of that message's displayed text. */
|
||||
const attachDraft = useCallback((draft: DraftProposal) => {
|
||||
setMessages((prev) => {
|
||||
// Attach ONLY to the CURRENT turn's streaming message. Do NOT fall back to
|
||||
// "the last assistant message anywhere" — that can be a PRIOR turn's message
|
||||
// sitting above the user's latest message, which made the draft card render
|
||||
// above the user's "Yes, propose it". When there's no current streaming
|
||||
// message, append a fresh one so the card always lands at the bottom.
|
||||
const id = streamingIdRef.current;
|
||||
if (id) {
|
||||
return prev.map((m) =>
|
||||
m.id === id
|
||||
? { ...m, draft, content: stripDraftFence(m.content) }
|
||||
: m
|
||||
);
|
||||
}
|
||||
return [...prev, { id: newId(), role: "assistant", content: "", draft }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SSE handling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const handleEvent = useCallback(
|
||||
(evt: LiveEvent) => {
|
||||
switch (evt.kind) {
|
||||
case "text":
|
||||
if (evt.text) {
|
||||
setActivity(null); // first text clears the "preparing…" indicator
|
||||
appendDelta(evt.text);
|
||||
setState("streaming");
|
||||
}
|
||||
break;
|
||||
case "tool_use":
|
||||
// A tool call ends the current text bubble, so the agent's words
|
||||
// before and after the tool render as separate messages (fixes
|
||||
// "two waves merged into one big bubble").
|
||||
streamingIdRef.current = null;
|
||||
setActivity(evt.tool ? `Using ${evt.tool}…` : "Working…");
|
||||
break;
|
||||
case "thinking":
|
||||
setActivity("Thinking…");
|
||||
break;
|
||||
case "turn_end":
|
||||
streamingIdRef.current = null;
|
||||
setActivity(null);
|
||||
setIsSending(false);
|
||||
setState((s) => (s === "draft_preview" ? s : "chatting"));
|
||||
break;
|
||||
case "draft": {
|
||||
const parsed = draftFromEvent(evt.data);
|
||||
if (parsed) {
|
||||
attachDraft(parsed.draft);
|
||||
setEditableDraft(
|
||||
toEditable(parsed.draft, parsed.scale, scopeRef.current)
|
||||
);
|
||||
setState("draft_preview");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
streamingIdRef.current = null;
|
||||
setActivity(null);
|
||||
setIsSending(false);
|
||||
addMessage({
|
||||
role: "error",
|
||||
content: evt.text || "The agent hit an error.",
|
||||
});
|
||||
setState("chatting");
|
||||
break;
|
||||
// "system" / "tool_result" are informational — ignored in the UI.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[appendDelta, attachDraft, addMessage]
|
||||
);
|
||||
|
||||
const closeStream = useCallback(() => {
|
||||
sourceRef.current?.close();
|
||||
sourceRef.current = null;
|
||||
}, []);
|
||||
|
||||
const openStream = useCallback(
|
||||
(sid: string) => {
|
||||
closeStream();
|
||||
const es = new EventSource(prompterLiveApi.streamUrl(sid));
|
||||
for (const kind of LIVE_EVENT_KINDS) {
|
||||
es.addEventListener(kind, (e: MessageEvent) => {
|
||||
try {
|
||||
handleEvent(JSON.parse(e.data) as LiveEvent);
|
||||
} catch {
|
||||
// A malformed frame is dropped; the stream stays open.
|
||||
}
|
||||
});
|
||||
}
|
||||
sourceRef.current = es;
|
||||
},
|
||||
[closeStream, handleEvent]
|
||||
);
|
||||
|
||||
// Best-effort reap if the user navigates away mid-chat.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
closeStream();
|
||||
const sid = sessionIdRef.current;
|
||||
if (sid) void prompterLiveApi.stop(sid).catch(() => undefined);
|
||||
};
|
||||
}, [closeStream]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Start the live session (from the scope form)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const isFormValid = useCallback((): boolean => {
|
||||
const scoped = targetKind === "product" ? productId !== "" : projectId !== "";
|
||||
return scoped && initialMessage.trim().length > 0;
|
||||
}, [targetKind, projectId, productId, initialMessage]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!isFormValid() || state === "preparing") return;
|
||||
const opening = initialMessage.trim();
|
||||
setState("preparing");
|
||||
addMessage({ role: "user", content: opening });
|
||||
try {
|
||||
const { session_id } = await prompterLiveApi.start({
|
||||
...(targetKind === "product"
|
||||
? { product_id: productId }
|
||||
: { project_id: projectId }),
|
||||
initial_message: opening,
|
||||
});
|
||||
sessionIdRef.current = session_id;
|
||||
setSessionId(session_id);
|
||||
openStream(session_id);
|
||||
setIsSending(true); // the opening reply is on its way over SSE
|
||||
// start now returns immediately; the container spawns in the background
|
||||
// (clone + image build can take a minute). Show that until the first event.
|
||||
setActivity("Preparing the agent — cloning your repo and reading the code…");
|
||||
setState("streaming");
|
||||
} catch (err) {
|
||||
addMessage({ role: "error", content: getErrorMessage(err) });
|
||||
setState("form");
|
||||
}
|
||||
}, [
|
||||
isFormValid,
|
||||
state,
|
||||
initialMessage,
|
||||
targetKind,
|
||||
productId,
|
||||
projectId,
|
||||
addMessage,
|
||||
openStream,
|
||||
]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Send a chat message
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
if (!text.trim() || isSending) return;
|
||||
const trimmed = text.trim();
|
||||
const sid = sessionIdRef.current;
|
||||
if (!trimmed || isSending || !sid) return;
|
||||
|
||||
setIsSending(true);
|
||||
setState("chatting");
|
||||
|
||||
// Add user message to chat
|
||||
addMessage({ role: "user", content: text.trim() });
|
||||
|
||||
setState("streaming");
|
||||
addMessage({ role: "user", content: trimmed });
|
||||
try {
|
||||
let sid = sessionIdRef.current;
|
||||
|
||||
// Create session on first message
|
||||
if (!sid) {
|
||||
const { session_id } = await prompterApi.createSession();
|
||||
sid = session_id;
|
||||
sessionIdRef.current = sid;
|
||||
setSessionId(sid);
|
||||
}
|
||||
|
||||
// Send message and get reply
|
||||
const response = await prompterApi.sendMessage(sid, text.trim());
|
||||
|
||||
if (response.draft) {
|
||||
// LLM produced a draft — add assistant message with embedded draft
|
||||
addMessage({
|
||||
role: "assistant",
|
||||
content: response.reply,
|
||||
draft: response.draft,
|
||||
});
|
||||
setDraftProposal(response.draft);
|
||||
setEditableDraft({
|
||||
title: response.draft.title,
|
||||
description: response.draft.description,
|
||||
acceptance_criteria: response.draft.acceptance_criteria,
|
||||
team: response.draft.team ?? "",
|
||||
priority: response.draft.priority ?? 2,
|
||||
task_type: response.draft.task_type ?? "",
|
||||
estimated_complexity: response.draft.estimated_complexity ?? "",
|
||||
});
|
||||
setState("draft_preview");
|
||||
} else {
|
||||
// Plain text reply
|
||||
addMessage({ role: "assistant", content: response.reply });
|
||||
setState("chatting");
|
||||
}
|
||||
await prompterLiveApi.sendMessage(sid, trimmed);
|
||||
// The reply streams back over SSE; isSending clears on turn_end.
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
addMessage({
|
||||
role: "error",
|
||||
content: msg,
|
||||
});
|
||||
setState("chatting");
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
addMessage({ role: "error", content: getErrorMessage(err) });
|
||||
setState("chatting");
|
||||
}
|
||||
},
|
||||
[isSending, addMessage]
|
||||
@@ -150,93 +386,124 @@ export function usePrompter() {
|
||||
// Review & Confirm actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const openReview = useCallback(() => {
|
||||
setState("review_modal");
|
||||
}, []);
|
||||
|
||||
const closeReview = useCallback(() => {
|
||||
setState("draft_preview");
|
||||
}, []);
|
||||
|
||||
const keepChatting = useCallback(() => {
|
||||
setState("chatting");
|
||||
}, []);
|
||||
const openReview = useCallback(() => setState("review_modal"), []);
|
||||
const closeReview = useCallback(() => setState("draft_preview"), []);
|
||||
const keepChatting = useCallback(() => setState("chatting"), []);
|
||||
|
||||
const updateDraft = useCallback((updates: Partial<EditableDraft>) => {
|
||||
setEditableDraft((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Validation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const isValidForLaunch = useCallback((): boolean => {
|
||||
return (
|
||||
const base =
|
||||
editableDraft.title.trim().length > 0 &&
|
||||
editableDraft.description.trim().length >= 20 &&
|
||||
editableDraft.acceptance_criteria.length > 0 &&
|
||||
editableDraft.team !== ""
|
||||
);
|
||||
(editableDraft.description ?? "").trim().length >= 20 &&
|
||||
editableDraft.acceptance_criteria.length > 0;
|
||||
const targeted =
|
||||
editableDraft.targetKind === "product"
|
||||
? editableDraft.productId !== ""
|
||||
: editableDraft.projectId !== "" && editableDraft.team !== "";
|
||||
return base && targeted;
|
||||
}, [editableDraft]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Launch (create task)
|
||||
// Launch — confirm the draft → task, then reap the agent
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const launchTask = useCallback(async () => {
|
||||
if (!isValidForLaunch()) return;
|
||||
const launchTask = useCallback(async (route: StartRoute) => {
|
||||
// Re-entry guard FIRST (synchronous, no stale closure): a double-click was
|
||||
// firing two confirms and creating duplicate tasks.
|
||||
if (launchingRef.current) return;
|
||||
const sid = sessionIdRef.current;
|
||||
// Never fail silently — a dead button with no feedback reads as "broken"
|
||||
// (it did: a missing `description` threw inside validation and the click
|
||||
// vanished). Tell the human exactly what's blocking the launch.
|
||||
if (!sid) {
|
||||
toast.error("This chat has ended — start a new one to launch a task.");
|
||||
return;
|
||||
}
|
||||
if (!isValidForLaunch()) {
|
||||
toast.error(
|
||||
"The draft is missing something needed to launch: a title, a 20+ character " +
|
||||
"summary, at least one acceptance criterion, and a target. Keep chatting to refine it."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
launchingRef.current = true;
|
||||
setIsLaunching(true);
|
||||
setState("launching");
|
||||
|
||||
const payload: TaskCreate = {
|
||||
const draft: DraftProposal = {
|
||||
title: editableDraft.title.trim(),
|
||||
description: editableDraft.description.trim(),
|
||||
description: (editableDraft.description ?? "").trim(),
|
||||
acceptance_criteria: editableDraft.acceptance_criteria,
|
||||
team: editableDraft.team as Team,
|
||||
priority: editableDraft.priority,
|
||||
...(editableDraft.task_type ? { task_type: editableDraft.task_type as TaskType } : {}),
|
||||
objective: editableDraft.objective.trim() || null,
|
||||
what_this_builds: editableDraft.what_this_builds,
|
||||
the_work: editableDraft.the_work,
|
||||
notes: editableDraft.notes,
|
||||
...(editableDraft.task_type ? { task_type: editableDraft.task_type } : {}),
|
||||
...(editableDraft.nature ? { nature: editableDraft.nature } : {}),
|
||||
...(editableDraft.estimated_complexity
|
||||
? { estimated_complexity: editableDraft.estimated_complexity as Complexity }
|
||||
? { estimated_complexity: editableDraft.estimated_complexity }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const payload: ConfirmPayload =
|
||||
editableDraft.targetKind === "product"
|
||||
? { product_id: editableDraft.productId, draft, route }
|
||||
: { project_id: editableDraft.projectId, draft, route };
|
||||
|
||||
const effectiveTeam =
|
||||
editableDraft.targetKind === "product"
|
||||
? Team.MAIN_PM
|
||||
: (editableDraft.team as Team);
|
||||
|
||||
try {
|
||||
const task = await createTask.mutateAsync(payload);
|
||||
setCreatedTaskId(task.id);
|
||||
setCreatedTaskTitle(task.title);
|
||||
setCreatedTaskTeam(task.team as Team);
|
||||
toast.success("Task created successfully!");
|
||||
const { task_id } = await prompterLiveApi.confirm(sid, payload);
|
||||
// The draft became a task — reap the agent and close the stream.
|
||||
closeStream();
|
||||
void prompterLiveApi.stop(sid).catch(() => undefined);
|
||||
sessionIdRef.current = null;
|
||||
setCreatedTaskId(task_id);
|
||||
setCreatedTaskTitle(draft.title);
|
||||
setCreatedTaskTeam(effectiveTeam);
|
||||
toast.success("Task created and launched!");
|
||||
setState("success");
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
toast.error(`Failed to create task: ${msg}`);
|
||||
setState("review_modal");
|
||||
toast.error(`Failed to launch task: ${getErrorMessage(err)}`);
|
||||
setState("draft_preview"); // back to the draft card to retry
|
||||
} finally {
|
||||
setIsLaunching(false);
|
||||
launchingRef.current = false;
|
||||
}
|
||||
}, [editableDraft, isValidForLaunch, createTask]);
|
||||
}, [editableDraft, isValidForLaunch, closeStream]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reset to start another conversation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const startAnother = useCallback(() => {
|
||||
closeStream();
|
||||
const sid = sessionIdRef.current;
|
||||
if (sid) void prompterLiveApi.stop(sid).catch(() => undefined);
|
||||
sessionIdRef.current = null;
|
||||
streamingIdRef.current = null;
|
||||
setMessages([]);
|
||||
setSessionId(null);
|
||||
sessionIdRef.current = null;
|
||||
setDraftProposal(null);
|
||||
setEditableDraft({
|
||||
title: "",
|
||||
description: "",
|
||||
acceptance_criteria: [],
|
||||
team: "",
|
||||
priority: 2,
|
||||
task_type: "",
|
||||
estimated_complexity: "",
|
||||
});
|
||||
setActivity(null);
|
||||
setEditableDraft(EMPTY_DRAFT);
|
||||
setProjectId("");
|
||||
setProductId("");
|
||||
setInitialMessage("");
|
||||
setTargetKind("project");
|
||||
setCreatedTaskId(null);
|
||||
setCreatedTaskTitle(null);
|
||||
setCreatedTaskTeam(null);
|
||||
setState("empty");
|
||||
}, []);
|
||||
setState("form");
|
||||
}, [closeStream]);
|
||||
|
||||
return {
|
||||
// State
|
||||
@@ -244,13 +511,25 @@ export function usePrompter() {
|
||||
messages,
|
||||
sessionId,
|
||||
isSending,
|
||||
draftProposal,
|
||||
activity,
|
||||
editableDraft,
|
||||
createdTaskId,
|
||||
createdTaskTitle,
|
||||
createdTaskTeam,
|
||||
|
||||
// Actions
|
||||
// Scope form
|
||||
targetKind,
|
||||
setTargetKind,
|
||||
projectId,
|
||||
setProjectId,
|
||||
productId,
|
||||
setProductId,
|
||||
initialMessage,
|
||||
setInitialMessage,
|
||||
isFormValid,
|
||||
start,
|
||||
|
||||
// Chat + confirm
|
||||
send,
|
||||
openReview,
|
||||
closeReview,
|
||||
@@ -259,6 +538,6 @@ export function usePrompter() {
|
||||
isValidForLaunch,
|
||||
launchTask,
|
||||
startAnother,
|
||||
isLaunching: createTask.isPending,
|
||||
isLaunching,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import api, { API_URL } from "./client";
|
||||
import type { ConfirmPayload } from "./prompter";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live intake chat — the panel side of the spawned-agent bridge.
|
||||
//
|
||||
// Unlike the legacy Ollama session API (`prompter.ts`), the brain here is a
|
||||
// real spawned Claude Code agent. The panel:
|
||||
// 1. POSTs /prompter/live/start with the scope (project XOR product) + the
|
||||
// opening message → gets a session id (the agent is spawned, the opening
|
||||
// message is delivered server-side once its container is reachable).
|
||||
// 2. opens an SSE stream and watches the agent work (token deltas, tool
|
||||
// calls), and renders the draft card when the agent proposes one.
|
||||
// 3. POSTs each subsequent message to /messages; replies arrive over SSE.
|
||||
// 4. on confirm, /confirm turns the draft into a task and reaps the agent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Open a live chat scoped to exactly one of project / product. */
|
||||
export interface StartLivePayload {
|
||||
project_id?: string;
|
||||
product_id?: string;
|
||||
initial_message?: string;
|
||||
}
|
||||
|
||||
export interface StartLiveResponse {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
/** Event kinds the container relays — mirrors the backend driver.StreamChunk. */
|
||||
export type LiveEventKind =
|
||||
| "text"
|
||||
| "thinking"
|
||||
| "tool_use"
|
||||
| "tool_result"
|
||||
| "turn_end"
|
||||
| "system"
|
||||
| "draft"
|
||||
| "error";
|
||||
|
||||
/** One normalized event from the agent's live reply. */
|
||||
export interface LiveEvent {
|
||||
kind: LiveEventKind;
|
||||
text?: string;
|
||||
tool?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Every named SSE event we subscribe to; each carries the full LiveEvent as JSON. */
|
||||
export const LIVE_EVENT_KINDS: LiveEventKind[] = [
|
||||
"text",
|
||||
"thinking",
|
||||
"tool_use",
|
||||
"tool_result",
|
||||
"turn_end",
|
||||
"system",
|
||||
"draft",
|
||||
"error",
|
||||
];
|
||||
|
||||
export const prompterLiveApi = {
|
||||
/** Spawn the intake agent for a new chat. */
|
||||
start: async (payload: StartLivePayload): Promise<StartLiveResponse> => {
|
||||
const { data } = await api.post<StartLiveResponse>(
|
||||
"/prompter/live/start",
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/** SSE URL the panel opens to watch the agent. EventSource sends no headers
|
||||
* (the route is keyed by the opaque session id on the trusted network). */
|
||||
streamUrl: (sessionId: string): string =>
|
||||
`${API_URL}/prompter/live/${sessionId}/stream`,
|
||||
|
||||
/** Deliver the human's message to the running agent; the reply streams back. */
|
||||
sendMessage: async (sessionId: string, text: string): Promise<void> => {
|
||||
await api.post(`/prompter/live/${sessionId}/messages`, { text });
|
||||
},
|
||||
|
||||
/** Reap the session (draft confirmed, or the human left the page). */
|
||||
stop: async (sessionId: string): Promise<void> => {
|
||||
await api.post(`/prompter/live/${sessionId}/stop`);
|
||||
},
|
||||
|
||||
/** Confirm the draft → create the task and reap the agent (Phase 4 backend). */
|
||||
confirm: async (
|
||||
sessionId: string,
|
||||
payload: ConfirmPayload
|
||||
): Promise<{ task_id: string }> => {
|
||||
const { data } = await api.post<{ task_id: string }>(
|
||||
`/prompter/live/${sessionId}/confirm`,
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -1,10 +1,20 @@
|
||||
import api from "./client";
|
||||
import type { Team, TaskType, Complexity } from "@/types";
|
||||
import type { Team, TaskType, TaskNature, Complexity } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// Prompter draft types — shared by the live intake hook (`prompter-live.ts`),
|
||||
// the draft card, and the confirm dialog. The chat itself is driven by the
|
||||
// spawned agent over SSE (`prompter-live.ts`); these are just the shapes of
|
||||
// the structured draft the agent proposes and the human confirms.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One cell's slice of the work — the per-cell breakdown of The Work. */
|
||||
export interface CellWork {
|
||||
team: Team;
|
||||
summary: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
/** A structured task draft, mirroring the backend PrompterDraftTask. */
|
||||
export interface DraftProposal {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -12,98 +22,26 @@ export interface DraftProposal {
|
||||
team: Team;
|
||||
priority?: number;
|
||||
task_type?: TaskType;
|
||||
nature?: TaskNature;
|
||||
estimated_complexity?: Complexity;
|
||||
// Structured spec fields
|
||||
objective?: string | null;
|
||||
what_this_builds?: string[];
|
||||
the_work?: CellWork[];
|
||||
notes?: string[];
|
||||
// Targeting (resolved at confirm time)
|
||||
project_id?: string | null;
|
||||
product_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
reply: string;
|
||||
draft?: DraftProposal | null;
|
||||
session_id: string;
|
||||
/** Single-cell project vs board-led multi-cell product. */
|
||||
export type DraftScale = "single" | "multi";
|
||||
|
||||
/** What the human picked/edited at confirm time. `route` is which start button:
|
||||
* "board" (Board review & Start) or "main_pm" (Approve & Start). */
|
||||
export interface ConfirmPayload {
|
||||
project_id?: string;
|
||||
product_id?: string;
|
||||
draft?: DraftProposal;
|
||||
route?: "board" | "main_pm";
|
||||
}
|
||||
|
||||
export interface CreateSessionResponse {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
// A message record as returned by the backend (PrompterMessageResponse).
|
||||
interface BackendMessage {
|
||||
id: string;
|
||||
session_id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Mirrors the backend's draft-ready signal phrases (services/prompter.py).
|
||||
// If the backend list ever drifts, the draft simply doesn't auto-surface (the
|
||||
// user can keep chatting) — it never breaks the conversation flow.
|
||||
const DRAFT_READY_SIGNALS = [
|
||||
"i have enough information",
|
||||
"ready to generate a draft",
|
||||
"ready to draft",
|
||||
"i can now draft",
|
||||
"draft_ready=true",
|
||||
"draft ready",
|
||||
];
|
||||
|
||||
function replyLooksDraftReady(reply: string): boolean {
|
||||
const lower = reply.toLowerCase();
|
||||
return DRAFT_READY_SIGNALS.some((s) => lower.includes(s));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const prompterApi = {
|
||||
/**
|
||||
* Create a new prompter session, returning a session ID.
|
||||
* The endpoint requires a JSON body (optional `context`), so send `{}`, and
|
||||
* map the backend's `id` field onto our `session_id`.
|
||||
*/
|
||||
createSession: async (): Promise<CreateSessionResponse> => {
|
||||
const { data } = await api.post<{ id: string }>("/prompter/sessions", {});
|
||||
return { session_id: data.id };
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a chat message in an existing session. The backend appends the user
|
||||
* message, replies, and returns the full message list. We surface the latest
|
||||
* assistant message as the reply and, when it signals readiness, fetch the
|
||||
* structured draft.
|
||||
*/
|
||||
sendMessage: async (
|
||||
sessionId: string,
|
||||
message: string
|
||||
): Promise<ChatResponse> => {
|
||||
const { data: messages } = await api.post<BackendMessage[]>(
|
||||
`/prompter/sessions/${sessionId}/messages`,
|
||||
{ content: message }
|
||||
);
|
||||
const lastAssistant = [...messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant");
|
||||
const reply = lastAssistant?.content ?? "";
|
||||
|
||||
let draft: DraftProposal | null = null;
|
||||
if (replyLooksDraftReady(reply)) {
|
||||
try {
|
||||
draft = await prompterApi.getDraft(sessionId);
|
||||
} catch {
|
||||
draft = null;
|
||||
}
|
||||
}
|
||||
return { reply, draft, session_id: sessionId };
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the current draft for a session (if the LLM has produced one).
|
||||
* The backend returns a TaskDraftResponse whose `draft` field holds the task.
|
||||
*/
|
||||
getDraft: async (sessionId: string): Promise<DraftProposal | null> => {
|
||||
const { data } = await api.get<{ draft: DraftProposal | null }>(
|
||||
`/prompter/sessions/${sessionId}/draft`
|
||||
);
|
||||
return data.draft;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user