Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cb52927f2 | ||
|
|
2724447a51 | ||
|
|
979bfe2fbe | ||
|
|
8f96027878 | ||
|
|
b6f60bfbae | ||
|
|
a8a335eadb | ||
|
|
048e273ff9 | ||
|
|
beef2049ed | ||
|
|
c30592e6b6 | ||
|
|
abae81a0ce | ||
|
|
66a568818a |
@@ -12,7 +12,14 @@
|
|||||||
"Bash(npm test:*)",
|
"Bash(npm test:*)",
|
||||||
"Bash(cargo check:*)",
|
"Bash(cargo check:*)",
|
||||||
"Bash(cargo clippy:*)",
|
"Bash(cargo clippy:*)",
|
||||||
"Bash(cargo test:*)"
|
"Bash(cargo test:*)",
|
||||||
|
"Bash(source:*)",
|
||||||
|
"Bash(git remote get-url:*)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"Bash(git -C /home/naps62/tea/maestro merge grazioso-2 --no-edit)",
|
||||||
|
"Bash(git -C /home/naps62/tea/maestro push)",
|
||||||
|
"Bash(curl:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|||||||
@@ -2916,17 +2916,16 @@ async fn mark_session_dead_with_worktree_settlement(
|
|||||||
// Release anyone attached; the scrollback survives for a later
|
// Release anyone attached; the scrollback survives for a later
|
||||||
// attach.
|
// attach.
|
||||||
record.bridge.seal();
|
record.bridge.seal();
|
||||||
let session = record.session.clone();
|
|
||||||
let worktree = record
|
let worktree = record
|
||||||
.worktree_base_repo
|
.worktree_base_repo
|
||||||
.clone()
|
.clone()
|
||||||
.zip(record.worktree_path.clone());
|
.zip(record.worktree_path.clone());
|
||||||
Some((session, worktree))
|
Some(worktree)
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some((mut session, worktree)) = flipped else {
|
let Some(worktree) = flipped else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let stream = inner
|
let stream = inner
|
||||||
@@ -2943,9 +2942,6 @@ async fn mark_session_dead_with_worktree_settlement(
|
|||||||
if settle_worktree {
|
if settle_worktree {
|
||||||
if let Some(wt) = &worktree {
|
if let Some(wt) = &worktree {
|
||||||
if settle_worktree_on_exit(inner, id, wt).await {
|
if settle_worktree_on_exit(inner, id, wt).await {
|
||||||
// The tree is settled (removed); a stale "in the way" would
|
|
||||||
// describe something the session no longer holds.
|
|
||||||
session.blocked_work = None;
|
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
if let Some(record) = sessions.get_mut(&id) {
|
if let Some(record) = sessions.get_mut(&id) {
|
||||||
record.session.worktree = None;
|
record.session.worktree = None;
|
||||||
@@ -2957,13 +2953,17 @@ async fn mark_session_dead_with_worktree_settlement(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Settle has now been applied or refused either way (#115): from here on
|
// Settle has now been applied or refused either way (#115): from here on
|
||||||
// its outcomes are safe to observe.
|
// its outcomes are safe to observe. Re-read the session from the record
|
||||||
{
|
// so the sidecar write includes any updates (e.g. a haiku rename) that
|
||||||
|
// landed between the initial clone and now.
|
||||||
|
let session = {
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
if let Some(record) = sessions.get_mut(&id) {
|
let Some(record) = sessions.get_mut(&id) else {
|
||||||
record.settled = true;
|
return false;
|
||||||
}
|
};
|
||||||
}
|
record.settled = true;
|
||||||
|
record.session.clone()
|
||||||
|
};
|
||||||
|
|
||||||
let _ = inner.sidecar.write(&session);
|
let _ = inner.sidecar.write(&session);
|
||||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ use common::TmuxFixture;
|
|||||||
use maestro_daemon::sidecar::SidecarStore;
|
use maestro_daemon::sidecar::SidecarStore;
|
||||||
use maestro_daemon::worktree::WorktreeManager;
|
use maestro_daemon::worktree::WorktreeManager;
|
||||||
use maestro_daemon::SessionManager;
|
use maestro_daemon::SessionManager;
|
||||||
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
|
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
struct Fixture {
|
struct Fixture {
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
sidecar: SidecarStore,
|
sidecar: SidecarStore,
|
||||||
_tmux: TmuxFixture,
|
tmux: TmuxFixture,
|
||||||
_state_dir: TempDir,
|
_state_dir: TempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
|
|||||||
Fixture {
|
Fixture {
|
||||||
manager,
|
manager,
|
||||||
sidecar,
|
sidecar,
|
||||||
_tmux: tmux,
|
tmux,
|
||||||
_state_dir: state_dir,
|
_state_dir: state_dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
|
|||||||
let live = fx.sidecar.read(id).unwrap().unwrap();
|
let live = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
assert_eq!(display::name_source(&live), NameSource::Generated);
|
assert_eq!(display::name_source(&live), NameSource::Generated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A session that dies naturally (liveness watcher marks it dead) must not
|
||||||
|
/// overwrite a haiku rename that landed before the death. Regression test for
|
||||||
|
/// the stale-clone sidecar write in `mark_session_dead`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn session_death_preserves_the_haiku_name() {
|
||||||
|
let fx = fixture();
|
||||||
|
let id = spawn(&fx.manager, None).await;
|
||||||
|
|
||||||
|
fx.manager.claim_autoname(id).await.unwrap();
|
||||||
|
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
|
||||||
|
|
||||||
|
// Verify the sidecar has Haiku before the session dies.
|
||||||
|
let before = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
|
assert_eq!(display::name_source(&before), NameSource::Haiku);
|
||||||
|
|
||||||
|
// Kill the tmux session directly so the liveness watcher picks it up.
|
||||||
|
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
|
||||||
|
fx.manager
|
||||||
|
.start_liveness_watcher(std::time::Duration::from_millis(25));
|
||||||
|
|
||||||
|
common::wait_for_async(std::time::Duration::from_secs(3), || async {
|
||||||
|
fx.manager.status(id).await == Some(SessionStatus::Dead)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let after = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
display::name_source(&after),
|
||||||
|
NameSource::Haiku,
|
||||||
|
"mark_session_dead must not overwrite a haiku rename in the sidecar"
|
||||||
|
);
|
||||||
|
assert_eq!(display::custom_name(&after), Some("haiku-name"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ async fn embedded_index_is_real_spa_not_placeholder() {
|
|||||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
html.contains(r#"id="app""#),
|
html.contains(r#"id="root""#),
|
||||||
"served index is missing the SPA mount point; got:\n{html}"
|
"served index is missing the SPA mount point; got:\n{html}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!html.contains("maestro is running"),
|
!html.contains("maestro is running"),
|
||||||
"served index is the placeholder, not the built bundle"
|
"served index is the placeholder, not the built bundle"
|
||||||
);
|
);
|
||||||
// Stronger signal than `id="app"` (which appears in both source and built
|
// Stronger signal than `id="root"` (which appears in both source and built
|
||||||
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
|
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
|
||||||
// bundle, whereas the un-built source references `/src/main.ts`. This catches
|
// bundle, whereas the un-built source references `/src/main.ts`. This catches
|
||||||
// both an empty/placeholder embed and the staleness case.
|
// both an empty/placeholder embed and the staleness case.
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async fn root_serves_the_spa_shell() {
|
|||||||
.map(|v| v.starts_with("text/html")),
|
.map(|v| v.starts_with("text/html")),
|
||||||
Some(true)
|
Some(true)
|
||||||
);
|
);
|
||||||
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -69,7 +69,7 @@ async fn client_side_route_falls_back_to_the_shell() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), 200);
|
assert_eq!(resp.status(), 200);
|
||||||
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
|
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An unmatched API path must 404 rather than fall through to the SPA shell:
|
/// An unmatched API path must 404 rather than fall through to the SPA shell:
|
||||||
|
|||||||
@@ -194,12 +194,15 @@ choices, not daemon ones.
|
|||||||
appear *between* tool rows in the same turn are absorbed into the group
|
appear *between* tool rows in the same turn are absorbed into the group
|
||||||
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||||
outside as the prominent final answer. While a turn is still active
|
outside as the prominent final answer. While a turn is still active
|
||||||
(responding or thinking), the most recent intermediate `AgentText` or
|
(responding or thinking), the most recent trailing text or thought row is
|
||||||
`AgentThought` in the group is kept outside as visible narration — replaced
|
kept outside as visible narration — the operator always sees the agent's
|
||||||
by the next text row when one arrives, and absorbed into the group when the
|
latest reasoning without opening the group. It is replaced by the next text
|
||||||
turn ends. The header still reads "N tool calls" (counting only tool rows).
|
row when one arrives, and absorbed into the group when the turn ends (unless
|
||||||
Opening the group reveals tools and intermediate text in chronological
|
it is `AgentText`, which stays outside as the final answer). The collapsed
|
||||||
order, with the text rendered in a subdued style.
|
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
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ describe("tool rows and activity groups", () => {
|
|||||||
chunk("agent:2", "agent_text", "the answer"),
|
chunk("agent:2", "agent_text", "the answer"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const head = screen.getByText("2 tool calls").closest("button")!;
|
const head = screen.getByText(/^2 tool calls/).closest("button")!;
|
||||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||||
// The final answer stays outside the group; the intermediate text is in it.
|
// The final answer stays outside the group; the intermediate text is in it.
|
||||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
||||||
|
|||||||
@@ -354,6 +354,8 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
id={item.id}
|
id={item.id}
|
||||||
toolCount={item.group.toolCount}
|
toolCount={item.group.toolCount}
|
||||||
|
lastToolLabel={item.group.lastToolLabel}
|
||||||
|
lastToolArg={item.group.lastToolArg}
|
||||||
expanded={expandedGroups.has(item.id)}
|
expanded={expandedGroups.has(item.id)}
|
||||||
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
|
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
// A turn's collapsed activity block: two or more tool rows after the same
|
// A turn's collapsed activity block: two or more tool rows after the same
|
||||||
// `User` row, plus the narration absorbed between them. The header counts tool
|
// `User` row, plus the narration absorbed between them. The header counts tool
|
||||||
// rows only (docs/STRUCTURED-VIEW.md); opening it reveals tools and text in
|
// rows only (docs/STRUCTURED-VIEW.md) and shows the last/current tool label so
|
||||||
// chronological order.
|
// the operator sees activity at a glance without expanding. Opening it reveals
|
||||||
|
// tools and text in chronological order.
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export interface ToolGroupProps {
|
export interface ToolGroupProps {
|
||||||
id: string;
|
id: string;
|
||||||
toolCount: number;
|
toolCount: number;
|
||||||
|
/** Label of the last (or running) tool, e.g. "Execute". */
|
||||||
|
lastToolLabel: string;
|
||||||
|
/** Argument of the last tool, e.g. "`git status`". */
|
||||||
|
lastToolArg: string;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onExpandedChange(expanded: boolean): void;
|
onExpandedChange(expanded: boolean): void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children }: ToolGroupProps) {
|
export function ToolGroup({ id, toolCount, lastToolLabel, lastToolArg, expanded, onExpandedChange, children }: ToolGroupProps) {
|
||||||
|
const summary = lastToolArg
|
||||||
|
? `${toolCount} tool calls · ${lastToolLabel} \u2018${lastToolArg}\u2019`
|
||||||
|
: `${toolCount} tool calls`;
|
||||||
return (
|
return (
|
||||||
<div className="st-tool-group" data-tool-group-id={id}>
|
<div className="st-tool-group" data-tool-group-id={id}>
|
||||||
<button
|
<button
|
||||||
@@ -25,7 +33,7 @@ export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children
|
|||||||
<span className="st-tool-group-glyph" aria-hidden="true">
|
<span className="st-tool-group-glyph" aria-hidden="true">
|
||||||
›
|
›
|
||||||
</span>
|
</span>
|
||||||
<span className="st-tool-group-title">{`${toolCount} tool calls`}</span>
|
<span className="st-tool-group-title">{summary}</span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
|
|||||||
@@ -151,6 +151,33 @@ describe("activity grouping", () => {
|
|||||||
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
|
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", () => {
|
||||||
|
const rows = [
|
||||||
|
text("user:1", "user", "go"),
|
||||||
|
row("tool:1", "tool_call", { title: "Read(src/main.ts)", status: "completed" }),
|
||||||
|
row("tool:2", "tool_call", { title: "Execute(git status)", kind: "execute", status: "completed" }),
|
||||||
|
];
|
||||||
|
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||||
|
expect(group.lastToolLabel).toBe("Execute");
|
||||||
|
expect(group.lastToolArg).toBe("git status");
|
||||||
|
});
|
||||||
|
|
||||||
it("absorbs a resolved approval, never the pending one", () => {
|
it("absorbs a resolved approval, never the pending one", () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
text("user:1", "user", "go"),
|
text("user:1", "user", "go"),
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ export interface ActivityGroup {
|
|||||||
toolCount: number;
|
toolCount: number;
|
||||||
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
|
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
|
||||||
rows: TranscriptRow[];
|
rows: TranscriptRow[];
|
||||||
|
/** `splitToolTitle` of the last tool row — the collapsed header shows it. */
|
||||||
|
lastToolLabel: string;
|
||||||
|
lastToolArg: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,6 +161,10 @@ export interface ActivityGroup {
|
|||||||
* the currently pending one) are absorbed the same way. The *last* agent text
|
* the currently pending one) are absorbed the same way. The *last* agent text
|
||||||
* with no subsequent tool stays standalone (the prominent final answer).
|
* 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(
|
||||||
@@ -197,23 +204,36 @@ export function activityGroupsByTurn(
|
|||||||
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: [] };
|
const group = groups.get(currentGid) ?? { toolCount: count, rows: [], lastToolLabel: "", lastToolArg: "" };
|
||||||
// Flush any pending rows — a tool follows, so they're intermediate.
|
// 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);
|
||||||
|
const tool = (row.data ?? {}) as { title?: string; name?: string | null; kind?: string };
|
||||||
|
const { label, arg } = splitToolTitle(tool.title, tool.name ?? undefined, tool.kind);
|
||||||
|
group.lastToolLabel = label;
|
||||||
|
group.lastToolArg = arg;
|
||||||
groups.set(currentGid, group);
|
groups.set(currentGid, group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A final agent text is the answer, so it stays outside. Thought rows are
|
// Trailing text/thought rows after the last tool in this turn.
|
||||||
// working commentary, though: when a turn already has a disclosure they
|
// While the turn is still live, the newest one stays outside as visible
|
||||||
// belong with its activity even if the next tool call has not arrived yet.
|
// narration — the operator always sees the agent's latest reasoning.
|
||||||
// While the turn is still live the most recent one stays outside as visible
|
// A final agent_text (the "answer") stays outside regardless.
|
||||||
// narration (docs/STRUCTURED-VIEW.md), and is absorbed when the turn ends.
|
|
||||||
const lastGroup = groups.get(currentGid);
|
const lastGroup = groups.get(currentGid);
|
||||||
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
|
if (lastGroup && pending.length > 0) {
|
||||||
const absorb = turnActive ? pending.slice(0, -1) : pending;
|
const allThoughts = pending.every((row) => row.kind === "agent_thought");
|
||||||
lastGroup.rows.push(...absorb);
|
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;
|
||||||
|
|||||||
@@ -1093,7 +1093,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
|
border-color: transparent;
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1287,6 +1287,7 @@
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.structured-attach:hover {
|
.structured-attach:hover {
|
||||||
|
|||||||
Reference in New Issue
Block a user