Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fffd2a55f3 | ||
|
|
9dead5652e |
+1
-1
@@ -70,7 +70,7 @@ command. `<duration>` follows in the rail's colour when shown. There is no
|
|||||||
state word and no second badge: the rail exists so the state is still legible
|
state word and no second badge: the rail exists so the state is still legible
|
||||||
when a row is dimmed or the terminal renders the duration in the default fg.
|
when a row is dimmed or the terminal renders the duration in the default fg.
|
||||||
|
|
||||||
In the **web client** the rail is a 7px bar pinned to the row's own left
|
In the **web client** the rail is a 5px bar pinned to the row's own left
|
||||||
edge, square corners, spanning the full card height edge to edge — it
|
edge, square corners, spanning the full card height edge to edge — it
|
||||||
shifts outward with nesting depth along with the row's own padding, since
|
shifts outward with nesting depth along with the row's own padding, since
|
||||||
it is positioned relative to the row. It carries motion only for states that
|
it is positioned relative to the row. It carries motion only for states that
|
||||||
|
|||||||
+13
-7
@@ -190,13 +190,19 @@ choices, not daemon ones.
|
|||||||
from the transcript; the TUI may show the current plan in its status line,
|
from the transcript; the TUI may show the current plan in its status line,
|
||||||
while the web client deliberately has no stats or plan rail.
|
while the web client deliberately has no stats or plan rail.
|
||||||
- The web client groups two or more tool rows after the same `User` row into
|
- The web client groups two or more tool rows after the same `User` row into
|
||||||
one collapsed turn activity block. `AgentText` and `AgentThought` rows stay
|
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
||||||
outside the group as standalone transcript items — the operator sees all
|
appear *between* tool rows in the same turn are absorbed into the group
|
||||||
reasoning without expanding. Resolved approval rows between tools are
|
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||||
absorbed into the group. The collapsed header reads "N tool calls · Label
|
outside as the prominent final answer. While a turn is still active
|
||||||
'arg'" where the label and argument come from the last tool row's
|
(responding or thinking), the most recent trailing text or thought row is
|
||||||
`splitToolTitle` — the operator sees which command is current at a glance.
|
kept outside as visible narration — the operator always sees the agent's
|
||||||
Opening the group reveals tool rows in chronological order.
|
latest reasoning without opening the group. It is replaced by the next text
|
||||||
|
row when one arrives, and absorbed into the group when the turn ends (unless
|
||||||
|
it is `AgentText`, which stays outside as the final answer). The collapsed
|
||||||
|
header reads "N tool calls · Label 'arg'" where the label and argument come
|
||||||
|
from the last tool row's `splitToolTitle` — the operator sees which command
|
||||||
|
is current at a glance. Opening the group reveals tools and intermediate
|
||||||
|
text in chronological order, with the text rendered in a subdued style.
|
||||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||||
copy their complete source text when clicked or keyboard-activated.
|
copy their complete source text when clicked or keyboard-activated.
|
||||||
- The web client consumes `available_commands_update` notices as the live
|
- The web client consumes `available_commands_update` notices as the live
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// docs/SIDEBAR.md: a 7px bar pinned to the row's own left edge, square
|
// docs/SIDEBAR.md: a 5px bar pinned to the row's own left edge, square
|
||||||
// corners, full card height. It paints for every card, Idle included, and it
|
// corners, full card height. It paints for every card, Idle included, and it
|
||||||
// carries the accessible name of the state so status is never colour-only.
|
// carries the accessible name of the state so status is never colour-only.
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export function ActivityRail({ activity }: { activity: ActivityState | string |
|
|||||||
const word = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
const word = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`absolute left-0 top-0 bottom-0 w-[7px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
|
className={`absolute left-0 top-0 bottom-0 w-[5px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
|
||||||
data-activity={slug}
|
data-activity={slug}
|
||||||
role="img"
|
role="img"
|
||||||
title={word}
|
title={word}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ describe("activity grouping", () => {
|
|||||||
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
|
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps intermediate text outside the group as standalone items", () => {
|
it("absorbs intermediate narration but keeps the final answer outside", () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
text("user:1", "user", "go"),
|
text("user:1", "user", "go"),
|
||||||
tool("tool:1"),
|
tool("tool:1"),
|
||||||
@@ -133,11 +133,11 @@ describe("activity grouping", () => {
|
|||||||
text("agent:2", "agent_text", "done"),
|
text("agent:2", "agent_text", "done"),
|
||||||
];
|
];
|
||||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
|
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||||||
expect(group.toolCount).toBe(2);
|
expect(group.toolCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps thoughts outside the group as standalone items", () => {
|
it("keeps the newest thought outside while the turn is still live", () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
text("user:1", "user", "go"),
|
text("user:1", "user", "go"),
|
||||||
tool("tool:1"),
|
tool("tool:1"),
|
||||||
@@ -145,8 +145,26 @@ describe("activity grouping", () => {
|
|||||||
text("thought:1", "agent_thought", "hmm"),
|
text("thought:1", "agent_thought", "hmm"),
|
||||||
text("thought:2", "agent_thought", "aha"),
|
text("thought:2", "agent_thought", "aha"),
|
||||||
];
|
];
|
||||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
|
||||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
|
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]);
|
||||||
|
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||||
|
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the newest agent_text outside while the turn is still live", () => {
|
||||||
|
const rows = [
|
||||||
|
text("user:1", "user", "go"),
|
||||||
|
tool("tool:1"),
|
||||||
|
text("agent:1", "agent_text", "working on it"),
|
||||||
|
tool("tool:2"),
|
||||||
|
text("agent:2", "agent_text", "almost done"),
|
||||||
|
];
|
||||||
|
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
|
||||||
|
// "almost done" stays outside as visible narration while the turn is live
|
||||||
|
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||||||
|
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||||
|
// Once ended, the final agent_text stays outside as the answer
|
||||||
|
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks the last tool's label and argument for the collapsed header", () => {
|
it("tracks the last tool's label and argument for the collapsed header", () => {
|
||||||
|
|||||||
@@ -144,10 +144,9 @@ export function turnIsActive(state: ReducedState): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityGroup {
|
export interface ActivityGroup {
|
||||||
/** Header label count — tool rows only. */
|
/** Header label count — tool rows only, never absorbed text. */
|
||||||
toolCount: number;
|
toolCount: number;
|
||||||
/** Tool rows and resolved approvals, in transcript order. Text/thought
|
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
|
||||||
* rows stay outside the group as standalone transcript items. */
|
|
||||||
rows: TranscriptRow[];
|
rows: TranscriptRow[];
|
||||||
/** `splitToolTitle` of the last tool row — the collapsed header shows it. */
|
/** `splitToolTitle` of the last tool row — the collapsed header shows it. */
|
||||||
lastToolLabel: string;
|
lastToolLabel: string;
|
||||||
@@ -155,16 +154,23 @@ export interface ActivityGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group tool rows (and resolved approvals between them) by turn. Text and
|
* Group tool rows (and intermediate agent text / resolved approvals between
|
||||||
* thought rows stay outside the group as standalone transcript items — the
|
* them) by turn. An agent_text/agent_thought row is "intermediate" when a tool
|
||||||
* operator sees all reasoning without expanding anything.
|
* row follows it in the same turn — it gets absorbed into the group so the
|
||||||
|
* collapsed block preserves chronological order. Resolved approval rows (not
|
||||||
|
* the currently pending one) are absorbed the same way. The *last* agent text
|
||||||
|
* with no subsequent tool stays standalone (the prominent final answer).
|
||||||
|
*
|
||||||
|
* While a turn is still live, the most recent text or thought row trailing the
|
||||||
|
* group also stays outside as visible narration — the operator always sees the
|
||||||
|
* agent's latest reasoning without opening the group.
|
||||||
*
|
*
|
||||||
* Returns groups keyed by `turn:<userId>`.
|
* Returns groups keyed by `turn:<userId>`.
|
||||||
*/
|
*/
|
||||||
export function activityGroupsByTurn(
|
export function activityGroupsByTurn(
|
||||||
rows: TranscriptRow[],
|
rows: TranscriptRow[],
|
||||||
pendingApprovalId: string | null,
|
pendingApprovalId: string | null,
|
||||||
_turnActive: boolean,
|
turnActive: boolean,
|
||||||
): Map<string, ActivityGroup> {
|
): Map<string, ActivityGroup> {
|
||||||
// First: count tools per turn to know which turns qualify for grouping.
|
// First: count tools per turn to know which turns qualify for grouping.
|
||||||
const toolCounts = new Map<string, number>();
|
const toolCounts = new Map<string, number>();
|
||||||
@@ -175,9 +181,11 @@ export function activityGroupsByTurn(
|
|||||||
const gid = `turn:${turnId}`;
|
const gid = `turn:${turnId}`;
|
||||||
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
// Second: build groups for turns with 2+ tools.
|
// Second: build groups with interleaved text for turns with 2+ tools.
|
||||||
const groups = new Map<string, ActivityGroup>();
|
const groups = new Map<string, ActivityGroup>();
|
||||||
turnId = "before-first-message";
|
turnId = "before-first-message";
|
||||||
|
// Pending rows that might be intermediate (not yet confirmed by a following
|
||||||
|
// tool). If no tool follows, they stay standalone.
|
||||||
let pending: TranscriptRow[] = [];
|
let pending: TranscriptRow[] = [];
|
||||||
let currentGid = `turn:${turnId}`;
|
let currentGid = `turn:${turnId}`;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -189,13 +197,15 @@ export function activityGroupsByTurn(
|
|||||||
}
|
}
|
||||||
const count = toolCounts.get(currentGid) ?? 0;
|
const count = toolCounts.get(currentGid) ?? 0;
|
||||||
if (count < 2) continue;
|
if (count < 2) continue;
|
||||||
|
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
|
||||||
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
|
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
|
||||||
if (isResolvedApproval) {
|
if (isIntermediateText || isResolvedApproval) {
|
||||||
pending.push(row);
|
pending.push(row);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
||||||
const group = groups.get(currentGid) ?? { toolCount: count, rows: [], lastToolLabel: "", lastToolArg: "" };
|
const group = groups.get(currentGid) ?? { toolCount: count, rows: [], lastToolLabel: "", lastToolArg: "" };
|
||||||
|
// Flush any pending rows — a tool follows, so they're intermediate.
|
||||||
group.rows.push(...pending);
|
group.rows.push(...pending);
|
||||||
pending = [];
|
pending = [];
|
||||||
group.rows.push(row);
|
group.rows.push(row);
|
||||||
@@ -206,6 +216,25 @@ export function activityGroupsByTurn(
|
|||||||
groups.set(currentGid, group);
|
groups.set(currentGid, group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Trailing text/thought rows after the last tool in this turn.
|
||||||
|
// While the turn is still live, the newest one stays outside as visible
|
||||||
|
// narration — the operator always sees the agent's latest reasoning.
|
||||||
|
// A final agent_text (the "answer") stays outside regardless.
|
||||||
|
const lastGroup = groups.get(currentGid);
|
||||||
|
if (lastGroup && pending.length > 0) {
|
||||||
|
const allThoughts = pending.every((row) => row.kind === "agent_thought");
|
||||||
|
if (allThoughts) {
|
||||||
|
// Pure thoughts: absorb all when done, keep the newest outside while live.
|
||||||
|
const absorb = turnActive ? pending.slice(0, -1) : pending;
|
||||||
|
lastGroup.rows.push(...absorb);
|
||||||
|
} else if (turnActive) {
|
||||||
|
// Mixed or agent_text trailing: keep the very last row outside while live,
|
||||||
|
// absorb the rest.
|
||||||
|
lastGroup.rows.push(...pending.slice(0, -1));
|
||||||
|
}
|
||||||
|
// When the turn ends with agent_text last, nothing extra is absorbed — the
|
||||||
|
// text stays outside as the final answer.
|
||||||
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,12 +196,24 @@ export function useStructuredSession(
|
|||||||
connect();
|
connect();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mobile browsers suspend timers and silently kill WebSockets in the
|
||||||
|
// background. Kick a fresh connect when the page becomes visible again.
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState !== "visible" || disposed) return;
|
||||||
|
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
|
||||||
|
cancelTimer();
|
||||||
|
setStatus("reconnecting");
|
||||||
|
connect();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
|
||||||
setStatus("connecting");
|
setStatus("connecting");
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
reconnectRef.current = () => {};
|
reconnectRef.current = () => {};
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
cancelTimer();
|
cancelTimer();
|
||||||
closeSocket();
|
closeSocket();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -302,6 +302,24 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
|
|||||||
ta?.addEventListener("focus", onTaFocus);
|
ta?.addEventListener("focus", onTaFocus);
|
||||||
ta?.addEventListener("blur", onTaBlur);
|
ta?.addEventListener("blur", onTaBlur);
|
||||||
|
|
||||||
|
// Mobile browsers (Android especially) suspend timers and silently kill
|
||||||
|
// WebSockets when the app goes to background. The backoff timer set by
|
||||||
|
// onclose is frozen too, so recovery stalls until visibility returns. Kick
|
||||||
|
// a fresh connect immediately when the page becomes visible again.
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState !== "visible" || disposed) return;
|
||||||
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) return;
|
||||||
|
// Cancel any pending backoff — we're reconnecting right now.
|
||||||
|
if (reconnectTimer !== null) {
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
reconnectMs = INITIAL_RECONNECT_MS;
|
||||||
|
onStatusChange?.("reconnecting");
|
||||||
|
connect();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
|
||||||
window.addEventListener("resize", onResize);
|
window.addEventListener("resize", onResize);
|
||||||
const visual = window.visualViewport ?? null;
|
const visual = window.visualViewport ?? null;
|
||||||
visual?.addEventListener("resize", onResize);
|
visual?.addEventListener("resize", onResize);
|
||||||
@@ -326,6 +344,7 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
|
|||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
}
|
}
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
window.removeEventListener("resize", onResize);
|
window.removeEventListener("resize", onResize);
|
||||||
visual?.removeEventListener("resize", onResize);
|
visual?.removeEventListener("resize", onResize);
|
||||||
ta?.removeEventListener("focus", onTaFocus);
|
ta?.removeEventListener("focus", onTaFocus);
|
||||||
|
|||||||
+1
-1
@@ -1623,7 +1623,7 @@ input.leaf-rename:focus {
|
|||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 7px;
|
width: 5px;
|
||||||
background: currentColor;
|
background: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user