Skip to content

Commit 40beb12

Browse files
committed
feat(agent): add "Copy" and "Redo" actions to assistant chat bubbles with improved timeline handling
1 parent a066c0a commit 40beb12

4 files changed

Lines changed: 123 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333
- **Subagent tool inventory in prompt**: subagent system prompt now lists the actually-provisioned tools grouped by purpose (Workspace, Diffs, Git, Memory, Plans, Tasks, Rules & Skills, Web) — generated from `registry_filtered` so the prompt cannot drift from the schema. Adds an explicit anti-hallucination clause directing the subagent to attempt `list_workspace_files` / `read_workspace_file` before claiming a lack of tools. Stops weaker models from instantly returning `blocked`.
3434
- **Timeline tool-call compaction**: consecutive identical tool calls in the chat timeline collapse into a single row with a live `×N` counter badge that grows as new calls stream in. Expand chevron reveals each invocation's args + detail in a sub-list. Single-call rows keep the prior layout; merged rows aggregate status (any pending → pending, any fail → fail, else ok).
3535
- **Tool group horizontal wrap**: `.agent-tool-group` switched from vertical stack to `flex-direction: row` with `flex-wrap: wrap`, so consecutive tool-call chips sit side-by-side and only wrap when the chat column runs out of width.
36+
- **Per-turn usage stats footer**: new `AgentEvent::TurnUsage { input_tokens, output_tokens, ttft_ms, elapsed_ms }` emitted at the end of each turn from both the OpenAI-compatible (`stream_options.include_usage`) and Anthropic (`message_start.usage` + `message_delta.usage`) paths. TTFT is measured from request send to first streamed content/reasoning delta. The frontend accumulates per-workspace totals in a new `ChatUsageStats` field on `WorkspaceEntry` (serde-default so old snapshots load) and renders a compact mono footer under the chat log: `N turns · in {tokens} · out {tokens} · {tok/s} · ttft {ms|s}`. Footer is hidden until the first turn produces data; resets on Reset Conversation.
37+
- **Copy + Redo buttons on assistant answers**: each assistant chat bubble grew an action row with a Copy button (writes the markdown to the system clipboard via `navigator.clipboard`, switches to a check icon for 1.4 s) and a Redo button (re-submits the previous user-turn prompt). `DisplayTimelineItem::Assistant` carries the preceding user text so Redo knows what to replay; the Redo button is hidden for the welcome bubble. Actions fade up from `opacity: 0.55` to full on hover for visual restraint.
38+
- **Subagent prompt hardening**: subagent system prompt restructured into `# Tools` / `# Required execution flow` / `# Forbidden behaviors` sections with a mandatory first `list_workspace_files {"path":"."}` call when `workspace_read` is provisioned. Explicit ban on `status:"blocked"` without a prior workspace probe and on paraphrasing tool errors as "tools unavailable".
39+
- **Subagent server-side blocked-without-trying guard**: new `validate_submit` rejects a `submit_result` with `status:"blocked"` when the role had `workspace_read` but the agent never called `list_workspace_files`, `read_workspace_file`, or `workspace_search`. The rejection is fed back into the conversation as a tool response so the loop continues, forcing the model to actually probe access before re-submitting. `handle_tool_call` now returns a `ToolCallOutcome` enum (`SubmitAccepted` / `SubmitRejected(msg)` / `NotSubmit`) wired into both the OpenAI and Anthropic subagent loops. 4 unit tests pin the contract.
40+
- **`allowedToolGroups` schema constrained**: `subagents.run` JSON schema now enums `allowedToolGroups.items` to the 9 valid group strings (`environment_read`, `workspace_read`, `diff_read`, `git_read`, `memory_read`, `plans_read`, `tasks_read`, `rules_skills_read`, `web_read`). Previously the array was `{type: string}` with no constraint, so the coordinator could invent names like `"file_access"` / `"files"` and silently end up with an empty subagent toolset.
41+
- **Strict toolgroup parser + role-defaults fallback**: new `parse_allowed_groups_strict(names) → (valid, unknown)` separates parseable groups from typos. `subagents::run` surfaces the unknowns via a `SubagentStep` with `status:"warning"` listing the bad names and the valid alternatives, and falls back to the role's defaults instead of spawning a subagent with only `submit_result`. 2 new unit tests cover the path.
42+
- **Subagent tool-roster diagnostic step**: every subagent emits a `SubagentStep` immediately after `SubagentStarted` listing the actual provisioned tools (`Provisioned N tool(s): list_workspace_files, read_workspace_file, …`). When a model claims "no tools" the operator can compare against this list in one glance and tell hallucination from real misprovisioning.
43+
- **Subagent tools list `×N` compaction**: consecutive same-named tool calls inside a subagent card collapse into one row with a `×N` mint-green badge — same merge logic the main timeline uses. `Search workspace ×5` instead of five identical rows.
3644

3745
### Changed
3846

3947
- **Docs**: README, developer setup, build guide, and `.env.release.example` now document the setup and cross-platform release automation paths.
4048
- **Dependencies**: upgraded `leptos` 0.7 → 0.8, `leptos_icons` 0.5 → 0.7, `icondata` 0.5 → 0.7. Leptos 0.8 is backward-compatible for the signal / effect / callback / event-listener APIs in use; only Lucide icon renames required code changes (`LuFileEdit → LuFilePenLine`, `LuPlusCircle → LuCirclePlus`, `LuSendHorizonal → LuSendHorizontal`, `LuAlertTriangle → LuTriangleAlert`, `LuTerminalSquare → LuSquareTerminal`, `LuPlayCircle / LuAlertCircle / LuCheckCircle / LuMinusCircle → LuCircle*` variants).
4149
- **Command palette shortcut**: tmux-style chord moved from `Ctrl+B :` to `Ctrl+B p`. To free `p`, `ToggleRightPanel` (formerly `Ctrl+B p`) was reassigned to `Ctrl+B r`. Classic-mode shortcuts (`Ctrl+Shift+P` palette, `Ctrl+P` side panel) unchanged. Hint strings in `en_us.rs` / `de_de.rs` updated.
4250
- **Subagent card polish**: dedicated CSS for the subagent timeline group — name and status now separated by a baseline-aligned `gap` (no more `scanblocked` run-together), font sizes shrunk (`0.7rem` name / `0.62rem` status / `0.68rem` summary / `0.64rem` tools), default `<details>` marker hidden in favour of the existing chevron pattern.
51+
- **Tool-loop round budgets raised**: `MAX_ROUNDS` for both the OpenAI-compatible and Anthropic coordinator loops bumped from `12 → 36`; `MAX_SUBAGENT_ROUNDS` from `8 → 24`. The previous limits aborted long multi-step turns mid-investigation, especially in subagents that need several file-read rounds before they can synthesize findings.
52+
- **Subagent skill doc — `allowedToolGroups` documented**: `harness_skills/subagents.md` now lists every valid `allowedToolGroups` string with its tool-coverage, recommends omitting the field entirely (defaults are sensible), and explicitly warns against invented names like `"file_access"` / `"files"` / `"shell_exec"`.
4353

4454
### Fixed
4555

src/workbench/agent_panel/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,11 +405,18 @@ pub fn AgentPanelDock() -> impl IntoView {
405405
>
406406
<ol class="agent-chat-list" aria-label=move || i18n.tr(I18nKey::AgTimelineAria)()>
407407
{move || {
408+
let on_redo = Callback::new(move |text: String| {
409+
draft.set(text);
410+
submit_turn(
411+
wb, i18n, draft, busy, status_line,
412+
timeline, task_snapshot, thinking_open, voice_handle,
413+
);
414+
});
408415
compact_timeline(timeline.get())
409416
.into_iter()
410417
.enumerate()
411418
.map(|(idx, entry)| {
412-
view! { <TimelineRow idx=idx entry=entry i18n=i18n thinking_open=thinking_open voice_handle=voice_handle /> }
419+
view! { <TimelineRow idx=idx entry=entry i18n=i18n thinking_open=thinking_open voice_handle=voice_handle on_redo=on_redo /> }
413420
})
414421
.collect_view()
415422
}}

src/workbench/agent_panel/timeline.rs

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ use std::collections::HashMap;
1919
#[derive(Clone, Debug, PartialEq)]
2020
pub enum DisplayTimelineItem {
2121
User { text: String },
22-
Assistant { text: String },
22+
Assistant {
23+
text: String,
24+
/// Latest user-message text preceding this assistant block — used as
25+
/// the "Redo" button's payload. `None` for the welcome/system bubble
26+
/// and for any assistant block that has no preceding user turn.
27+
prev_user: Option<String>,
28+
},
2329
ToolGroup(Vec<ToolActivity>),
2430
SubagentGroup(SubagentGroup),
2531
Thinking { text: String, done: bool },
@@ -422,6 +428,7 @@ fn synthesize_completion_message(rows: &[TimelineItem]) -> Option<String> {
422428
pub fn compact_timeline(items: Vec<TimelineItem>) -> Vec<DisplayTimelineItem> {
423429
let mut out = Vec::new();
424430
let mut pending_tools = Vec::new();
431+
let mut last_user_text: Option<String> = None;
425432

426433
let flush_tools = |out: &mut Vec<DisplayTimelineItem>,
427434
pending_tools: &mut Vec<ToolActivity>| {
@@ -436,11 +443,15 @@ pub fn compact_timeline(items: Vec<TimelineItem>) -> Vec<DisplayTimelineItem> {
436443
match item {
437444
TimelineItem::User { text } => {
438445
flush_tools(&mut out, &mut pending_tools);
446+
last_user_text = Some(text.clone());
439447
out.push(DisplayTimelineItem::User { text });
440448
}
441449
TimelineItem::Assistant { text } => {
442450
flush_tools(&mut out, &mut pending_tools);
443-
out.push(DisplayTimelineItem::Assistant { text });
451+
out.push(DisplayTimelineItem::Assistant {
452+
text,
453+
prev_user: last_user_text.clone(),
454+
});
444455
}
445456
TimelineItem::Tool(tool) => pending_tools.push(tool),
446457
TimelineItem::Thinking { text, done } => {
@@ -563,6 +574,7 @@ pub fn TimelineRow(
563574
i18n: I18nService,
564575
thinking_open: RwSignal<HashMap<usize, bool>>,
565576
voice_handle: VoiceOrbHandle,
577+
on_redo: Callback<String>,
566578
) -> impl IntoView {
567579
let line_no = format!("{:02}", idx + 1);
568580
match entry {
@@ -576,14 +588,65 @@ pub fn TimelineRow(
576588
</li>
577589
}
578590
.into_any(),
579-
DisplayTimelineItem::Assistant { text } => {
591+
DisplayTimelineItem::Assistant { text, prev_user } => {
580592
let tts_text = text.clone();
593+
let copy_text = text.clone();
594+
let redo_text = prev_user.clone();
595+
let copied = RwSignal::new(false);
581596
view! {
582597
<li class="agent-chat-line agent-chat-line--agent">
583598
<ChatLineIndexColumn line_no=line_no.clone() tts_text=Some(tts_text) voice_handle=voice_handle />
584599
<div class="agent-chat-body">
585600
<strong>{move || i18n.tr(I18nKey::AgAssistant)()}</strong>
586601
<div class="workbench-agent-markdown" inner_html=render_markdown_to_html(&text)></div>
602+
<div class="agent-chat-actions">
603+
<button
604+
type="button"
605+
class="agent-chat-action"
606+
title="Copy answer to clipboard"
607+
aria-label="Copy answer"
608+
on:click=move |_| {
609+
let text = copy_text.clone();
610+
copied.set(true);
611+
leptos::task::spawn_local(async move {
612+
if let Some(win) = web_sys::window() {
613+
let clipboard = win.navigator().clipboard();
614+
let promise = clipboard.write_text(&text);
615+
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
616+
}
617+
gloo_timers::future::TimeoutFuture::new(1400).await;
618+
copied.set(false);
619+
});
620+
}
621+
>
622+
{move || if copied.get() {
623+
view! { <LxIcon icon=icondata::LuCheck width="0.78rem" height="0.78rem" /> }
624+
} else {
625+
view! { <LxIcon icon=icondata::LuCopy width="0.78rem" height="0.78rem" /> }
626+
}}
627+
</button>
628+
<Show when={
629+
let r = redo_text.clone();
630+
move || r.as_ref().is_some_and(|s| !s.trim().is_empty())
631+
}>
632+
<button
633+
type="button"
634+
class="agent-chat-action"
635+
title="Redo this turn (resubmit the same prompt)"
636+
aria-label="Redo"
637+
on:click={
638+
let r = redo_text.clone();
639+
move |_| {
640+
if let Some(text) = r.clone() {
641+
on_redo.run(text);
642+
}
643+
}
644+
}
645+
>
646+
<LxIcon icon=icondata::LuRefreshCw width="0.78rem" height="0.78rem" />
647+
</button>
648+
</Show>
649+
</div>
587650
</div>
588651
</li>
589652
}

styles.css

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2741,6 +2741,45 @@ button.workbench-shortcut-row--action:focus-visible {
27412741
border-color: rgba(255, 120, 120, 0.4);
27422742
}
27432743

2744+
.agent-chat-actions {
2745+
display: flex;
2746+
align-items: center;
2747+
gap: 0.32rem;
2748+
margin-top: 0.36rem;
2749+
opacity: 0.55;
2750+
transition: opacity 140ms ease;
2751+
}
2752+
2753+
.agent-chat-line--agent:hover .agent-chat-actions {
2754+
opacity: 1;
2755+
}
2756+
2757+
.agent-chat-action {
2758+
display: inline-flex;
2759+
align-items: center;
2760+
justify-content: center;
2761+
width: 1.5rem;
2762+
height: 1.5rem;
2763+
padding: 0;
2764+
border: 1px solid rgba(255, 255, 255, 0.08);
2765+
border-radius: 0.4rem;
2766+
background: rgba(255, 255, 255, 0.025);
2767+
color: rgba(241, 242, 245, 0.72);
2768+
cursor: pointer;
2769+
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
2770+
}
2771+
2772+
.agent-chat-action:hover {
2773+
background: rgba(255, 255, 255, 0.06);
2774+
border-color: rgba(255, 255, 255, 0.16);
2775+
color: rgba(241, 242, 245, 0.95);
2776+
}
2777+
2778+
.agent-chat-action:focus-visible {
2779+
outline: 2px solid rgba(143, 217, 196, 0.6);
2780+
outline-offset: 1px;
2781+
}
2782+
27442783
.agent-chat-usage {
27452784
display: flex;
27462785
flex-wrap: wrap;

0 commit comments

Comments
 (0)