feat(conversations): show a Deploying… chip on cards mid-deploy #3
@@ -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)),
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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. */
|
||||
const STAGES = [
|
||||
{ key: "committed", label: "Committed", Icon: GitCommit },
|
||||
|
||||
@@ -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 && <RunningAgentTags agents={c.runningAgents} />}
|
||||
{running && <DeployingBadge deploying={c.deploying} />}
|
||||
<ProjectTags projects={c.meta?.projects} />
|
||||
<ServiceTags services={c.meta?.services} />
|
||||
<WorktreeTag worktrees={c.meta?.worktrees} />
|
||||
|
||||
@@ -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) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<TaskBadge tasks={c.tasks} />
|
||||
{/* 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 && <RunningAgentTags agents={c.runningAgents} />}
|
||||
{running && <DeployingBadge deploying={c.deploying} />}
|
||||
<ProjectTags projects={c.meta?.projects} />
|
||||
<ServiceTags services={c.meta?.services} />
|
||||
<WorktreeTag worktrees={c.meta?.worktrees} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user