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
|
# 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)),
|
||||||
|
|||||||
@@ -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 [])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user