feat(conversations): show a Deploying… chip on cards mid-deploy #3

Merged
gabrielvidal merged 1 commits from deploying-loader into main 2026-08-09 23:22:30 +02:00
9 changed files with 94 additions and 6 deletions

View File

@@ -210,6 +210,16 @@ _MEMORY_RE = re.compile(
# action happened, and stamp it with that turn's timestamp. These derived values # action happened, and stamp it with that turn's timestamp. These derived values
# are merged non-destructively under `lifecycleAuto` (the live sidecar wins). # 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). # Each pattern matches a command/skill-name string; a hit lights its stage(s).
_LIFECYCLE_PATTERNS: list[tuple[str, re.Pattern]] = [ _LIFECYCLE_PATTERNS: list[tuple[str, re.Pattern]] = [
# commit-homelab / commit-project both commit *and* push to Gitea. # 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")), ("pushed", re.compile(r"\bgit\b[^\n|&;]*\bpush\b")),
("merged", re.compile(r"\bgit\s+merge\b")), ("merged", re.compile(r"\bgit\s+merge\b")),
# deploy paths: skill/alias, npm/pnpm/yarn deploy, zipgo, make deploy, *.sh. # deploy paths: skill/alias, npm/pnpm/yarn deploy, zipgo, make deploy, *.sh.
("deployed", re.compile( ("deployed", _DEPLOY_CMD_RE),
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")),
("notified", re.compile(r"\b(?:notify-done|notify-ask|notify\.sh)\b")), ("notified", re.compile(r"\b(?:notify-done|notify-ask|notify\.sh)\b")),
# explicit conv-meta stamps (e.g. `conv-meta.sh committed`). # explicit conv-meta stamps (e.g. `conv-meta.sh committed`).
("committed", re.compile(r"\bconv-meta(?:\.sh)?\b[^\n|&;]*\bcommitted\b")), ("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 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: def _bucket(tool: str | None) -> str:
return TOOL_BUCKETS.get(tool or "", "tool") return TOOL_BUCKETS.get(tool or "", "tool")
@@ -672,6 +687,10 @@ class ParserState:
self._turn_item: dict | None = None self._turn_item: dict | None = None
# Open Task/Agent calls: an entry here *is* a running subagent. # Open Task/Agent calls: an entry here *is* a running subagent.
self.agent_calls: dict[str, dict] = {} 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. # Skill card awaiting its injected SKILL.md body.
self.pending_skill: dict | None = None self.pending_skill: dict | None = None
# Timestamp of the previous record (assistant latency baseline). # Timestamp of the previous record (assistant latency baseline).
@@ -850,6 +869,9 @@ class ParserState:
for stage in _lifecycle_stages(b.get("name"), inp): for stage in _lifecycle_stages(b.get("name"), inp):
if ts and (stage not in self.lifecycle or ts > self.lifecycle[stage]): if ts and (stage not in self.lifecycle or ts > self.lifecycle[stage]):
self.lifecycle[stage] = ts 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": if b.get("name") == "Skill":
sk = (b.get("input") or {}).get("skill") sk = (b.get("input") or {}).get("skill")
if sk: if sk:
@@ -1101,6 +1123,9 @@ class ParserState:
self.agent_calls.pop(b["tool_use_id"], None) self.agent_calls.pop(b["tool_use_id"], None)
elif call is not None: elif call is not None:
call["background"] = True 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: if "node_type: memory" in rtext:
self.memories_read.update(_MEMORY_RE.findall(rtext)) self.memories_read.update(_MEMORY_RE.findall(rtext))
if self.full: if self.full:
@@ -1235,6 +1260,7 @@ class ParserState:
"worktreesAuto": list(self.worktrees.values()), "worktreesAuto": list(self.worktrees.values()),
"tasks": list(self.tasks.values()), "tasks": list(self.tasks.values()),
"runningAgents": list(self.agent_calls.values()), "runningAgents": list(self.agent_calls.values()),
"deploying": bool(self.deploying_since),
"lifecycleAuto": self.lifecycle, "lifecycleAuto": self.lifecycle,
"memoriesRead": sorted(self.memories_read), "memoriesRead": sorted(self.memories_read),
"doneMarker": bool(_DONE_RE.search(self.last_assistant_text)), "doneMarker": bool(_DONE_RE.search(self.last_assistant_text)),

View File

@@ -988,6 +988,16 @@ def _running_agents(summary: dict, state: str | None = None) -> list[dict]:
return ra if st == "running" else [] 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: def _sidechain_state(ap: pathlib.Path) -> str | None:
"""A subagent's own lifecycle state: it runs exactly as long as the parent's """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. 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}, out.append({"id": cid, **{k: s.get(k) for k in keys},
"title": _conv_title(s), "title": _conv_title(s),
"runningAgents": _running_agents(s, m.get("state")), "runningAgents": _running_agents(s, m.get("state")),
"deploying": _is_deploying(s, m.get("state")),
"skillsUsed": skills_used, "skillsUsed": skills_used,
"agentsUsed": agents_by_conv.get(cid, []), "agentsUsed": agents_by_conv.get(cid, []),
"meta": m}) "meta": m})
@@ -1367,6 +1378,7 @@ def conversation_summary(id: str):
return {"id": id, **{k: s.get(k) for k in _CARD_KEYS}, return {"id": id, **{k: s.get(k) for k in _CARD_KEYS},
"title": _conv_title(s), "title": _conv_title(s),
"runningAgents": _running_agents(s, m.get("state")), "runningAgents": _running_agents(s, m.get("state")),
"deploying": _is_deploying(s, m.get("state")),
"skillsUsed": skills_used, "skillsUsed": skills_used,
"agentsUsed": _agents_by_conversation().get(id, []), "agentsUsed": _agents_by_conversation().get(id, []),
"meta": m} "meta": m}
@@ -1632,6 +1644,7 @@ def conversation_detail(id: str):
data["meta"]["state"] = sub_state data["meta"]["state"] = sub_state
# Resolve the state first: which Task calls count as running depends on it. # Resolve the state first: which Task calls count as running depends on it.
data["runningAgents"] = _running_agents(data, data["meta"].get("state")) data["runningAgents"] = _running_agents(data, data["meta"].get("state"))
data["deploying"] = _is_deploying(data, data["meta"].get("state"))
_attach_subagents(ap, data) _attach_subagents(ap, data)
# Cross-link the memories this conversation read (recalled) and created. # Cross-link the memories this conversation read (recalled) and created.
read_slugs = set(data.get("memoriesRead") or []) read_slugs = set(data.get("memoriesRead") or [])

View File

@@ -384,6 +384,10 @@ class ConversationSummary(Schema):
# Subagents still in flight (their Task call never came back). The list card # 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. # shows one "running" chip per entry while the conversation itself is running.
runningAgents: Optional[list[RunningAgent]] = None 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 # Current context size: the last assistant API call's full prompt+reply
# (input + cache read/write + output) — what the next request replays. # (input + cache read/write + output) — what the next request replays.
# `contextModel` is the model that made that call, so the UI can pick the # `contextModel` is the model that made that call, so the UI can pick the

View File

@@ -5257,6 +5257,17 @@
], ],
"title": "Runningagents" "title": "Runningagents"
}, },
"deploying": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Deploying"
},
"contextTokens": { "contextTokens": {
"anyOf": [ "anyOf": [
{ {
@@ -5696,6 +5707,17 @@
], ],
"title": "Runningagents" "title": "Runningagents"
}, },
"deploying": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Deploying"
},
"contextTokens": { "contextTokens": {
"anyOf": [ "anyOf": [
{ {

View File

@@ -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 (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-500">
<Loader2 className="h-2.5 w-2.5 shrink-0 animate-spin" />
Deploying
</span>
);
}
/** The CI/CD-style lifecycle stages, in order. */ /** The CI/CD-style lifecycle stages, in order. */
const STAGES = [ const STAGES = [
{ key: "committed", label: "Committed", Icon: GitCommit }, { key: "committed", label: "Committed", Icon: GitCommit },

View File

@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { Archive, Bot, Loader2, User } from "lucide-react"; import { Archive, Bot, Loader2, User } from "lucide-react";
import { cn, fmtCost, fmtDuration, relTime } from "@/lib/utils"; import { cn, fmtCost, fmtDuration, relTime } from "@/lib/utils";
import { import {
DeployingBadge,
PendingFormBadge, PendingFormBadge,
ProjectTags, ProjectTags,
RunningAgentTags, RunningAgentTags,
@@ -71,6 +72,7 @@ export function ConversationListElement({
c.meta?.services?.length || c.meta?.services?.length ||
c.meta?.worktrees?.length || c.meta?.worktrees?.length ||
(running && c.runningAgents?.length) || (running && c.runningAgents?.length) ||
(running && c.deploying) ||
pendingForm pendingForm
); );
@@ -125,6 +127,7 @@ export function ConversationListElement({
{/* Subagents in flight — only meaningful while the run is live; a {/* Subagents in flight — only meaningful while the run is live; a
Task call left open by an interrupted run isn't a running agent. */} Task call left open by an interrupted run isn't a running agent. */}
{running && <RunningAgentTags agents={c.runningAgents} />} {running && <RunningAgentTags agents={c.runningAgents} />}
{running && <DeployingBadge deploying={c.deploying} />}
<ProjectTags projects={c.meta?.projects} /> <ProjectTags projects={c.meta?.projects} />
<ServiceTags services={c.meta?.services} /> <ServiceTags services={c.meta?.services} />
<WorktreeTag worktrees={c.meta?.worktrees} /> <WorktreeTag worktrees={c.meta?.worktrees} />

View File

@@ -29,6 +29,7 @@ import {
AgentBadge, AgentBadge,
CronBadge, CronBadge,
CtaBadge, CtaBadge,
DeployingBadge,
PendingFormBadge, PendingFormBadge,
ProjectTags, ProjectTags,
RunningAgentTags, RunningAgentTags,
@@ -390,12 +391,14 @@ function ConversationCard({
c.meta?.services?.length || c.meta?.services?.length ||
c.meta?.worktrees?.length || c.meta?.worktrees?.length ||
(running && c.runningAgents?.length) || (running && c.runningAgents?.length) ||
(running && c.deploying) ||
pendingForm) && ( pendingForm) && (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<TaskBadge tasks={c.tasks} /> <TaskBadge tasks={c.tasks} />
{/* Subagents in flight — only meaningful while the run is live; a Task {/* Subagents in flight — only meaningful while the run is live; a Task
call left open by an interrupted run isn't a running agent. */} call left open by an interrupted run isn't a running agent. */}
{running && <RunningAgentTags agents={c.runningAgents} />} {running && <RunningAgentTags agents={c.runningAgents} />}
{running && <DeployingBadge deploying={c.deploying} />}
<ProjectTags projects={c.meta?.projects} /> <ProjectTags projects={c.meta?.projects} />
<ServiceTags services={c.meta?.services} /> <ServiceTags services={c.meta?.services} />
<WorktreeTag worktrees={c.meta?.worktrees} /> <WorktreeTag worktrees={c.meta?.worktrees} />

View File

@@ -42,6 +42,7 @@ export interface ConversationDetail {
subagentTokens?: number | null; subagentTokens?: number | null;
subagentCost?: number | null; subagentCost?: number | null;
runningAgents?: RunningAgent[] | null; runningAgents?: RunningAgent[] | null;
deploying?: boolean | null;
contextTokens?: number | null; contextTokens?: number | null;
contextModel?: string | null; contextModel?: string | null;
firstContextTokens?: number | null; firstContextTokens?: number | null;

View File

@@ -35,6 +35,7 @@ export interface ConversationSummary {
subagentTokens?: number | null; subagentTokens?: number | null;
subagentCost?: number | null; subagentCost?: number | null;
runningAgents?: RunningAgent[] | null; runningAgents?: RunningAgent[] | null;
deploying?: boolean | null;
contextTokens?: number | null; contextTokens?: number | null;
contextModel?: string | null; contextModel?: string | null;
firstContextTokens?: number | null; firstContextTokens?: number | null;