Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1ed10f9
feat(composer): live activity preview for the selected working agent
tellaho Jul 23, 2026
985e45a
fix(composer): make live-activity preview subtree inert to keyboard a…
tellaho Jul 23, 2026
a89d0ce
fix(composer): pin activity row height so composer doesn't shift on a…
tellaho Jul 27, 2026
d1d1eba
feat(composer): flatten agent activity UI into per-agent status pills
tellaho Jul 27, 2026
089f9ba
feat(composer): sync pill reorder motion and add per-action label ticker
tellaho Jul 27, 2026
faf2a11
feat(composer): freeze pill strip on hover, typing-fallback pills, fa…
tellaho Jul 27, 2026
47a35fa
feat(composer): label typing-fallback pills "is typing…" instead of "…
tellaho Jul 27, 2026
eef84f4
feat(composer): collapse typing agents and humans into one indicator …
tellaho Jul 27, 2026
670da44
fix(desktop): align thread composer activity row with the full-width …
tellaho Jul 27, 2026
3352169
feat(composer): ship live activity by default and scroll the pill str…
tellaho Jul 27, 2026
e4d28ca
test(composer): seed activity pills from observer turns and drop anim…
tellaho Jul 27, 2026
6441c20
fix(composer): pin activity strip edge fades to the container edges
tellaho Jul 27, 2026
ef05424
feat(composer): stable turn-start pill order and persistent action la…
tellaho Jul 27, 2026
2e256b9
feat(composer): unify typing indicator into the pill strip and align …
tellaho Jul 27, 2026
ef3f682
feat(composer): terse tool-call headline for the activity pill
tellaho Jul 27, 2026
222284b
fix(composer): keep strip edge fades live when pill content grows aft…
tellaho Jul 27, 2026
90b8ea4
style(composer): rebalance activity pill spacing
tellaho Jul 27, 2026
65b63a3
feat(composer): placement-aware pill resize guard for the activity strip
tellaho Jul 27, 2026
0a2a925
feat(composer): relabel working pill to "is typing" in place while ag…
tellaho Jul 28, 2026
ed45a43
fix(composer): scope slow reorder pacing to slot travel, not enter/exit
tellaho Jul 28, 2026
57f0097
feat(composer): capability-based pill partition keeps pills through t…
tellaho Jul 28, 2026
0ad1076
feat(composer): morph the avatar between typing group and pill on par…
tellaho Jul 28, 2026
8e058c5
fix(composer): past-tense plan and thought headlines in the activity …
tellaho Jul 28, 2026
c7df52d
fix(composer): lone typing group uses the full row width instead of t…
tellaho Jul 28, 2026
d390efb
fix(composer): reconcile live-activity strip with the dock geometry f…
tellaho Jul 30, 2026
d3e1f6a
fix(composer): restore card mint status in activity rail
tellaho Aug 4, 2026
b2434c6
fix(composer): partition thread agent typing by activity
tellaho Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions desktop/src/features/agents/agentWorkingSignal.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";

import {
getAgentChannelTypingSince,
getAgentWorkingState,
getWorkingAgentPubkeysForChannel,
getWorkingChannels,
Expand Down Expand Up @@ -116,6 +117,32 @@ describe("getAgentWorkingState", () => {
});
});

describe("getAgentChannelTypingSince", () => {
it("returns null when the agent is not typing in the channel", () => {
assert.equal(getAgentChannelTypingSince(AGENT, "chan-1"), null);
reportChannelBotTyping("chan-1", [AGENT_2]);
assert.equal(getAgentChannelTypingSince(AGENT, "chan-1"), null);
assert.equal(getAgentChannelTypingSince(AGENT_2, "chan-2"), null);
assert.equal(getAgentChannelTypingSince(null, "chan-1"), null);
assert.equal(getAgentChannelTypingSince(AGENT, null), null);
});

it("returns the first-seen anchor while typing", () => {
reportChannelBotTyping("chan-1", [AGENT]);
const since = getAgentChannelTypingSince(AGENT, "chan-1");
assert.ok(since !== null && since <= Date.now());
reportChannelBotTyping("chan-1", []);
assert.equal(getAgentChannelTypingSince(AGENT, "chan-1"), null);
});

it("is NOT folded under observer precedence (unlike getAgentWorkingState)", () => {
startTurn(AGENT, "chan-1");
reportChannelBotTyping("chan-1", [AGENT]);
assert.equal(getAgentWorkingState(AGENT, "chan-1").source, "observer");
assert.notEqual(getAgentChannelTypingSince(AGENT, "chan-1"), null);
});
});

describe("getWorkingChannels", () => {
it("merges typing-only agents into an observer channel summary", () => {
startTurn(AGENT, "chan-1");
Expand Down
22 changes: 22 additions & 0 deletions desktop/src/features/agents/agentWorkingSignal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,28 @@ export function reportChannelBotTyping(
notify();
}

/**
* First-seen timestamp (ms) of the agent's channel-scoped typing entry, or
* null when the agent is not typing there.
*
* Raw registry read: unlike `getAgentWorkingState`, this does NOT fold
* typing under observer precedence — an observer-backed surface (the
* composer activity pill) uses it to relabel IN PLACE to "is typing…" while
* a turn is in flight, instead of the agent spawning a second, separate
* typing-group item.
*/
export function getAgentChannelTypingSince(
agentPubkey: string | null | undefined,
channelId: string | null | undefined,
): number | null {
if (!agentPubkey || !channelId) {
return null;
}
return (
typingByChannel.get(channelId)?.get(normalizePubkey(agentPubkey)) ?? null
);
}

function computeAgentWorkingState(
agentPubkey: string,
channelId: string | null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ function getDiffStats(
return null;
}

function basename(path: string) {
/** Final path segment (cross-platform); returns the input when empty. */
export function basename(path: string) {
const parts = path.replace(/\\/g, "/").split("/");
return parts[parts.length - 1] || path;
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,133 @@ function makeMessage(overrides = {}) {
};
}

test("getActivityHeadline formats tool titles and assistant text", () => {
assert.equal(getActivityHeadline(makeTool()), "Send Message · abc");
test("getActivityHeadline formats tool actions tersely and assistant text", () => {
// Tool items use the terse action tier (verb + object), not label · preview.
assert.equal(getActivityHeadline(makeTool()), "Sent abc");
assert.equal(
getActivityHeadline(makeMessage({ text: "First line\nSecond line" })),
"First line",
);
assert.equal(getActivityHeadline(makeMessage({ text: " " })), "Responding");
});

test("getActivityHeadline basenames path-like tool objects", () => {
assert.equal(
getActivityHeadline(
makeTool({
title: "Read file",
toolName: "dev__read_file",
buzzToolName: null,
args: { path: "src/agents/ui/foo.ts" },
descriptor: {
renderClass: "file-read",
label: "Read file",
preview: "src/agents/ui/foo.ts",
action: { verb: "Read", object: "src/agents/ui/foo.ts" },
source: "harness",
groupKey: "read_file",
},
}),
),
"Read foo.ts",
);
assert.equal(
getActivityHeadline(
makeTool({
title: "Edit file",
toolName: "dev__str_replace",
buzzToolName: null,
args: { path: "desktop/src/features/channels/ui/ChannelPane.tsx" },
descriptor: {
renderClass: "file-edit",
label: "Edited file",
preview: "desktop/src/features/channels/ui/ChannelPane.tsx",
action: {
verb: "Edited",
object: "desktop/src/features/channels/ui/ChannelPane.tsx",
},
source: "harness",
groupKey: "file-edit:str_replace",
},
}),
),
"Edited ChannelPane.tsx",
);
});

test("getActivityHeadline renders plan and thought items in past tense", () => {
// Bare "Plan" / "Thinking" titles must not headline next to the past-tense
// tool verbs ("Read foo.ts", "Ran …").
assert.equal(
getActivityHeadline({
id: "plan:1",
type: "plan",
renderClass: "plan",
title: "Plan",
text: "1. Do the thing",
timestamp: baseTimestamp,
}),
"Created plan",
);
assert.equal(
getActivityHeadline({
id: "plan:1:update:x",
type: "plan",
renderClass: "plan",
title: "Plan updated",
text: "Now doing step 2",
timestamp: baseTimestamp,
isUpdate: true,
targetId: "plan:1",
}),
"Updated plan",
);
assert.equal(
getActivityHeadline({
id: "thought:1",
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "hmm",
timestamp: baseTimestamp,
}),
"Thought",
);
assert.equal(
getActivityHeadline({
id: "thought:2",
type: "thought",
renderClass: "thought",
title: "Plan",
text: "plan-shaped thought",
timestamp: baseTimestamp,
}),
"Planned",
);
});

test("getActivityHeadline keeps shell commands whole (no basenaming)", () => {
assert.equal(
getActivityHeadline(
makeTool({
title: "Shell",
toolName: "dev__shell",
buzzToolName: null,
args: { command: "cat src/agents/ui/foo.ts" },
descriptor: {
renderClass: "shell",
label: "Ran command",
preview: "cat src/agents/ui/foo.ts",
action: { verb: "Ran", object: "cat src/agents/ui/foo.ts" },
source: "harness",
groupKey: "shell:command",
},
}),
),
"Ran cat src/agents/ui/foo.ts",
);
});

test("isMeaningfulItem ignores lifecycle noise and raw JSON-RPC metadata", () => {
assert.equal(
isMeaningfulItem({
Expand Down Expand Up @@ -111,7 +229,7 @@ test("isMeaningfulItem ignores lifecycle noise and raw JSON-RPC metadata", () =>
);
});

test("getActivityHeadline uses semantic tool descriptors", () => {
test("getActivityHeadline falls back to label · preview when a descriptor has no action", () => {
assert.equal(
getActivityHeadline(
makeTool({
Expand Down Expand Up @@ -225,7 +343,7 @@ test("two-tier headline: metadata excluded when spine work is present", () => {
"System prompt should not headline when spine work exists",
);
assert.ok(
headlines.some((h) => h?.includes("Send Message")),
headlines.some((h) => h?.includes("Sent")),
"Tool headline should appear",
);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { TranscriptItem } from "./agentSessionTypes";
import { buildCompactToolSummary } from "./agentSessionToolSummary";
import { basename } from "./agentSessionFileEditDiff";
import {
buildCompactToolSummary,
type CompactToolSummary,
} from "./agentSessionToolSummary";

/**
* Whether a polished activity row should render the opt-in timestamp footer.
Expand All @@ -26,10 +30,54 @@ const LIFECYCLE_NOISE = new Set([
"wire parse error",
]);

/** Human-readable headline for a single transcript item. */
/**
* Render classes whose action object is a filesystem path — compact to the
* basename for terse headlines. Shell commands are deliberately excluded:
* they legitimately contain "/" (e.g. `cat src/foo.ts`) and must not be
* reduced to their last path segment.
*/
const PATHLIKE_RENDER_CLASSES = new Set([
"file-read",
"file-edit",
"skill-read",
]);

function terseActionObject(summary: CompactToolSummary): string | null {
if (summary.fileEditSummary) {
return summary.fileEditSummary.filename;
}
const object = summary.action?.object ?? null;
if (
object &&
PATHLIKE_RENDER_CLASSES.has(summary.kind) &&
/[\\/]/.test(object)
) {
return basename(object);
}
return object;
}

/**
* Human-readable headline for a single transcript item.
*
* Tool items use the terse action tier — verb + compact object (file paths
* reduced to their basename), e.g. "Read foo.ts" rather than
* "Read file · src/agents/ui/foo.ts" — so the composer pill's 200px cap
* shows the informative part instead of ellipsizing a long preview.
*
* Action headlines are past-tense verb phrases throughout ("Read foo.ts",
* "Thought", "Updated plan") — plan and thought items are normalized here
* so no bare noun ("Plan") or present participle ("Thinking") leaks into
* the pill next to the past-tense tool verbs.
*/
export function getActivityHeadline(item: TranscriptItem): string | null {
if (item.type === "tool") {
const summary = buildCompactToolSummary(item);
if (summary.action) {
return [summary.action.verb, terseActionObject(summary)]
.filter(Boolean)
.join(" ");
}
return [summary.label, summary.preview].filter(Boolean).join(" · ");
}

Expand All @@ -50,7 +98,14 @@ export function getActivityHeadline(item: TranscriptItem): string | null {
}

if (item.type === "thought") {
return item.title === "Plan" ? "Planning" : item.title;
if (item.title === "Plan") {
return "Planned";
}
return item.title === "Thinking" ? "Thought" : item.title;
}

if (item.type === "plan") {
return item.isUpdate ? "Updated plan" : "Created plan";
}

if (item.type === "metadata") {
Expand Down
Loading
Loading