From 2befaeca54871035bb63f68d3055f57b12efbbc5 Mon Sep 17 00:00:00 2001 From: Gabriel Vidal Date: Sun, 9 Aug 2026 23:17:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(conversations):=20show=20a=20"Deploying?= =?UTF-8?q?=E2=80=A6"=20chip=20on=20cards=20mid-deploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track a deploy command (Bash matching the deploy regex, or the deploy-html skill) as in-flight from tool_use to its tool_result, and surface it as a gated `deploying` field (only true while the conversation itself is running) — same shape as `runningAgents`. The list card renders an amber spinner pill next to the subagent chips. Co-Authored-By: Claude Sonnet 5 --- backend/conversations.py | 38 ++++++++++++++++--- backend/main.py | 13 +++++++ backend/schemas.py | 4 ++ frontend/openapi.json | 22 +++++++++++ .../conversations/components/ConvMetaBits.tsx | 15 ++++++++ .../components/ConversationListElement.tsx | 3 ++ .../conversations/pages/Conversations.tsx | 3 ++ .../src/generated/model/conversationDetail.ts | 1 + .../generated/model/conversationSummary.ts | 1 + 9 files changed, 94 insertions(+), 6 deletions(-) diff --git a/backend/conversations.py b/backend/conversations.py index 5582353..d78ea2e 100644 --- a/backend/conversations.py +++ b/backend/conversations.py @@ -210,6 +210,16 @@ _MEMORY_RE = re.compile( # action happened, and stamp it with that turn's timestamp. These derived values # are merged non-destructively under `lifecycleAuto` (the live sidecar wins). # +# The actual deploy commands (as opposed to the `conv-meta ... deployed` stamp +# below, which only *records* one after the fact) — reused to detect a deploy +# in flight, see `_is_deploy_call`. +_DEPLOY_CMD_RE = re.compile( + r"\bdeploy-html\b" + r"|\b(?:npm|pnpm|yarn)\s+(?:run\s+)?deploy\b" + r"|\bzipgo\s+deploy\b" + r"|\bmake\s+deploy[\w.]*\b" + r"|\bdeploy[\w.-]*\.sh\b") + # Each pattern matches a command/skill-name string; a hit lights its stage(s). _LIFECYCLE_PATTERNS: list[tuple[str, re.Pattern]] = [ # commit-homelab / commit-project both commit *and* push to Gitea. @@ -219,12 +229,7 @@ _LIFECYCLE_PATTERNS: list[tuple[str, re.Pattern]] = [ ("pushed", re.compile(r"\bgit\b[^\n|&;]*\bpush\b")), ("merged", re.compile(r"\bgit\s+merge\b")), # deploy paths: skill/alias, npm/pnpm/yarn deploy, zipgo, make deploy, *.sh. - ("deployed", re.compile( - r"\bdeploy-html\b" - r"|\b(?:npm|pnpm|yarn)\s+(?:run\s+)?deploy\b" - r"|\bzipgo\s+deploy\b" - r"|\bmake\s+deploy[\w.]*\b" - r"|\bdeploy[\w.-]*\.sh\b")), + ("deployed", _DEPLOY_CMD_RE), ("notified", re.compile(r"\b(?:notify-done|notify-ask|notify\.sh)\b")), # explicit conv-meta stamps (e.g. `conv-meta.sh committed`). ("committed", re.compile(r"\bconv-meta(?:\.sh)?\b[^\n|&;]*\bcommitted\b")), @@ -280,6 +285,16 @@ def _lifecycle_stages(name: str | None, inp: dict) -> set[str]: return stages +def _is_deploy_call(name: str | None, inp: dict) -> bool: + """True if this tool call itself performs (or wraps) a deploy — used to + detect one in flight, unlike `_lifecycle_stages`'s "deployed" hit, which + also fires on the post-hoc `conv-meta ... deployed` stamp.""" + if name == "Skill" and inp.get("skill") == "deploy-html": + return True + cmd = inp.get("command") + return isinstance(cmd, str) and bool(_DEPLOY_CMD_RE.search(cmd)) + + def _bucket(tool: str | None) -> str: return TOOL_BUCKETS.get(tool or "", "tool") @@ -672,6 +687,10 @@ class ParserState: self._turn_item: dict | None = None # Open Task/Agent calls: an entry here *is* a running subagent. self.agent_calls: dict[str, dict] = {} + # Set while a deploy command (Bash or the deploy-html skill) has been + # issued and hasn't returned yet — cleared on its tool_result. + self.deploying_since: str | None = None + self._deploying_tool_use_id: str | None = None # Skill card awaiting its injected SKILL.md body. self.pending_skill: dict | None = None # Timestamp of the previous record (assistant latency baseline). @@ -850,6 +869,9 @@ class ParserState: for stage in _lifecycle_stages(b.get("name"), inp): if ts and (stage not in self.lifecycle or ts > self.lifecycle[stage]): self.lifecycle[stage] = ts + if b.get("id") and _is_deploy_call(b.get("name"), inp): + self.deploying_since = ts + self._deploying_tool_use_id = b["id"] if b.get("name") == "Skill": sk = (b.get("input") or {}).get("skill") if sk: @@ -1101,6 +1123,9 @@ class ParserState: self.agent_calls.pop(b["tool_use_id"], None) elif call is not None: call["background"] = True + if b.get("tool_use_id") == self._deploying_tool_use_id: + self.deploying_since = None + self._deploying_tool_use_id = None if "node_type: memory" in rtext: self.memories_read.update(_MEMORY_RE.findall(rtext)) if self.full: @@ -1235,6 +1260,7 @@ class ParserState: "worktreesAuto": list(self.worktrees.values()), "tasks": list(self.tasks.values()), "runningAgents": list(self.agent_calls.values()), + "deploying": bool(self.deploying_since), "lifecycleAuto": self.lifecycle, "memoriesRead": sorted(self.memories_read), "doneMarker": bool(_DONE_RE.search(self.last_assistant_text)), diff --git a/backend/main.py b/backend/main.py index bc19bc0..a969a31 100644 --- a/backend/main.py +++ b/backend/main.py @@ -988,6 +988,16 @@ def _running_agents(summary: dict, state: str | None = None) -> list[dict]: return ra if st == "running" else [] +def _is_deploying(summary: dict, state: str | None = None) -> bool: + """True while this conversation has a deploy command in flight (issued, no + tool_result yet) — gated the same way as `_running_agents`: a dangling call + left open by an interrupted/dead run isn't actually deploying anymore.""" + if not summary.get("deploying"): + return False + st = state if state is not None else _conv_meta(summary).get("state") + return st == "running" + + def _sidechain_state(ap: pathlib.Path) -> str | None: """A subagent's own lifecycle state: it runs exactly as long as the parent's Task call is still open. `None` if `ap` isn't a subagent transcript. @@ -1262,6 +1272,7 @@ def _conversation_cards(full: bool) -> list[dict]: out.append({"id": cid, **{k: s.get(k) for k in keys}, "title": _conv_title(s), "runningAgents": _running_agents(s, m.get("state")), + "deploying": _is_deploying(s, m.get("state")), "skillsUsed": skills_used, "agentsUsed": agents_by_conv.get(cid, []), "meta": m}) @@ -1367,6 +1378,7 @@ def conversation_summary(id: str): return {"id": id, **{k: s.get(k) for k in _CARD_KEYS}, "title": _conv_title(s), "runningAgents": _running_agents(s, m.get("state")), + "deploying": _is_deploying(s, m.get("state")), "skillsUsed": skills_used, "agentsUsed": _agents_by_conversation().get(id, []), "meta": m} @@ -1632,6 +1644,7 @@ def conversation_detail(id: str): data["meta"]["state"] = sub_state # Resolve the state first: which Task calls count as running depends on it. data["runningAgents"] = _running_agents(data, data["meta"].get("state")) + data["deploying"] = _is_deploying(data, data["meta"].get("state")) _attach_subagents(ap, data) # Cross-link the memories this conversation read (recalled) and created. read_slugs = set(data.get("memoriesRead") or []) diff --git a/backend/schemas.py b/backend/schemas.py index 2b3c4f4..8820333 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -384,6 +384,10 @@ class ConversationSummary(Schema): # Subagents still in flight (their Task call never came back). The list card # shows one "running" chip per entry while the conversation itself is running. runningAgents: Optional[list[RunningAgent]] = None + # A deploy command this conversation issued hasn't returned yet. The list + # card shows a "Deploying…" chip while the conversation itself is running, + # same gating as `runningAgents`. + deploying: Optional[bool] = None # Current context size: the last assistant API call's full prompt+reply # (input + cache read/write + output) — what the next request replays. # `contextModel` is the model that made that call, so the UI can pick the diff --git a/frontend/openapi.json b/frontend/openapi.json index 80133f0..dbd10b9 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -5257,6 +5257,17 @@ ], "title": "Runningagents" }, + "deploying": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Deploying" + }, "contextTokens": { "anyOf": [ { @@ -5696,6 +5707,17 @@ ], "title": "Runningagents" }, + "deploying": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Deploying" + }, "contextTokens": { "anyOf": [ { diff --git a/frontend/src/business/conversations/components/ConvMetaBits.tsx b/frontend/src/business/conversations/components/ConvMetaBits.tsx index 6905db7..6a53d28 100644 --- a/frontend/src/business/conversations/components/ConvMetaBits.tsx +++ b/frontend/src/business/conversations/components/ConvMetaBits.tsx @@ -591,6 +591,21 @@ export function RunningAgentTags({ agents }: { agents?: RunningAgent[] | null }) ); } +/** + * A deploy command this conversation issued hasn't returned yet. Shown next to + * `RunningAgentTags` on the card — same "still in flight" shape, but for the + * conversation's own deploy call rather than a spawned subagent. + */ +export function DeployingBadge({ deploying }: { deploying?: boolean | null }) { + if (!deploying) return null; + return ( + + + Deploying… + + ); +} + /** The CI/CD-style lifecycle stages, in order. */ const STAGES = [ { key: "committed", label: "Committed", Icon: GitCommit }, diff --git a/frontend/src/business/conversations/components/ConversationListElement.tsx b/frontend/src/business/conversations/components/ConversationListElement.tsx index 99f1dcf..4cfb093 100644 --- a/frontend/src/business/conversations/components/ConversationListElement.tsx +++ b/frontend/src/business/conversations/components/ConversationListElement.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router-dom"; import { Archive, Bot, Loader2, User } from "lucide-react"; import { cn, fmtCost, fmtDuration, relTime } from "@/lib/utils"; import { + DeployingBadge, PendingFormBadge, ProjectTags, RunningAgentTags, @@ -71,6 +72,7 @@ export function ConversationListElement({ c.meta?.services?.length || c.meta?.worktrees?.length || (running && c.runningAgents?.length) || + (running && c.deploying) || pendingForm ); @@ -125,6 +127,7 @@ export function ConversationListElement({ {/* Subagents in flight — only meaningful while the run is live; a Task call left open by an interrupted run isn't a running agent. */} {running && } + {running && } diff --git a/frontend/src/business/conversations/pages/Conversations.tsx b/frontend/src/business/conversations/pages/Conversations.tsx index e92f58f..3727ea2 100644 --- a/frontend/src/business/conversations/pages/Conversations.tsx +++ b/frontend/src/business/conversations/pages/Conversations.tsx @@ -29,6 +29,7 @@ import { AgentBadge, CronBadge, CtaBadge, + DeployingBadge, PendingFormBadge, ProjectTags, RunningAgentTags, @@ -390,12 +391,14 @@ function ConversationCard({ c.meta?.services?.length || c.meta?.worktrees?.length || (running && c.runningAgents?.length) || + (running && c.deploying) || pendingForm) && (
{/* Subagents in flight — only meaningful while the run is live; a Task call left open by an interrupted run isn't a running agent. */} {running && } + {running && } diff --git a/frontend/src/generated/model/conversationDetail.ts b/frontend/src/generated/model/conversationDetail.ts index c14812a..834381b 100644 --- a/frontend/src/generated/model/conversationDetail.ts +++ b/frontend/src/generated/model/conversationDetail.ts @@ -42,6 +42,7 @@ export interface ConversationDetail { subagentTokens?: number | null; subagentCost?: number | null; runningAgents?: RunningAgent[] | null; + deploying?: boolean | null; contextTokens?: number | null; contextModel?: string | null; firstContextTokens?: number | null; diff --git a/frontend/src/generated/model/conversationSummary.ts b/frontend/src/generated/model/conversationSummary.ts index 575a34a..f4c6ff4 100644 --- a/frontend/src/generated/model/conversationSummary.ts +++ b/frontend/src/generated/model/conversationSummary.ts @@ -35,6 +35,7 @@ export interface ConversationSummary { subagentTokens?: number | null; subagentCost?: number | null; runningAgents?: RunningAgent[] | null; + deploying?: boolean | null; contextTokens?: number | null; contextModel?: string | null; firstContextTokens?: number | null; -- 2.49.1