Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions crates/ui/src/chat/components/work_log.rs
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
use std::path::Path;

use crate::icon::{Icon, IconName};
use crate::sizing::Sizable as _;
use crate::theme::ActiveTheme as _;
use crate::widgets::spinner::Spinner;
use agent::TurnStatus;
use gpui::{
AnyElement, App, ClickEvent, InteractiveElement as _, IntoElement as _, ParentElement as _,
Expand DownExpand Up@@ -72,6 +74,9 @@ pub(crate) fn work_log(
.on_click(on_toggle)
.child(Icon::new(chevron(expanded)).size(px(12.)).text_color(muted))
.child(capsule_label)
.when(running, |row| {
row.child(Spinner::new().xsmall().color(cx.theme().primary))
})
.when(!running, |row| {
// A settled run needs no badge; only a bad ending is called out.
let failure = match outcome {
Expand Down
174 changes: 86 additions & 88 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,11 +48,10 @@ use self::components::assistant::MdState;
use self::components::command_panel::CommandPanelCache;
use self::model::{
ListSync, Segment, TurnIndexCache, TurnListItem, TurnRenderArgs, activity_run_duration_ms,
auto_expanded, displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, manual_override_key, plain_text_as_markdown,
displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, partition_activity_run, plain_text_as_markdown,
segment_entries, start_hub_projects, timeline_overdraw, user_content, user_visible_text,
work_log_auto_expands, work_log_capsule_label, work_log_counts, work_log_outcome,
work_log_row_entries,
work_log_capsule_label, work_log_counts, work_log_outcome,
};
use self::residency::{
MarkdownEntry, ResidencyInput, ResidencyScope, decide, tail_turn_window, viewport_turn_window,
Expand DownExpand Up@@ -615,33 +614,6 @@ impl ChatView {
cx.notify();
}

fn toggle_auto_expanded(
&mut self,
turn: usize,
key: &str,
automatic: bool,
cx: &mut Context<Self>,
) {
let was_expanded = auto_expanded(&self.expanded, key, automatic);
self.expanded.insert(manual_override_key(key));
if was_expanded {
self.expanded.remove(key);
} else {
self.expanded.insert(key.to_string());
}
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

fn promote_auto_expanded(&mut self, turn: usize, key: &str, cx: &mut Context<Self>) {
self.expanded.insert(manual_override_key(key));
self.expanded.insert(key.to_string());
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

// -- turn rendering -----------------------------------------------------

/// Render one turn as chronological messages, errors, and Work Log runs.
Expand DownExpand Up@@ -1055,69 +1027,95 @@ impl ChatView {
let (index, segment_id, turn, cwd, activities, is_last) = args;
let section_key = format!("worklog-{index}-{segment_id}");
let running = is_last && turn.running;
let automatic = work_log_auto_expands(activities, turn.running, is_last);
let expanded = auto_expanded(&self.expanded, &section_key, automatic);
let manually_expanded =
expanded && (self.expanded.contains(&manual_override_key(&section_key)) || !automatic);
let segment_counts = work_log_counts(activities);
let mut capsule_label = work_log_capsule_label(&segment_counts, activities.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
let (folded, visible) = partition_activity_run(activities, running);
let expanded = self.expanded.contains(&section_key);
let live_reasoning_id = running
.then(|| activities.last().copied())
.flatten()
.filter(|entry| {
matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
)
})
.map(|entry| entry.id.as_str());

let mut flow = v_flex().w_full().gap_1();
if !folded.is_empty() {
let segment_counts = work_log_counts(folded);
let mut capsule_label = work_log_capsule_label(&segment_counts, folded.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
}
let duration =
format_span((activity_run_duration_ms(folded, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, folded, is_last);
let rows = if expanded {
self.compose_work_log_rows(folded, cwd, live_reasoning_id, cx)
} else {
Vec::new()
};

let toggle_section_key = section_key;
flow = flow.child(components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
this.toggle_expanded(index, &toggle_section_key, cx);
}),
cx,
));
}
let duration =
format_span((activity_run_duration_ms(activities, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, activities, is_last);

if !visible.is_empty() {
flow = flow.child(
v_flex()
.w_full()
.gap_1()
.children(self.compose_work_log_rows(visible, cwd, live_reasoning_id, cx)),
);
}

flow.into_any_element()
}

fn compose_work_log_rows(
&mut self,
activities: &[&TimelineEntry],
cwd: &Path,
live_reasoning_id: Option<&str>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let mut rows = Vec::new();
if expanded {
let visible = work_log_row_entries(activities, !manually_expanded);

for entry in &visible {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content
{
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
let live_reasoning = running
&& activities.last().is_some_and(|last| last.id == entry.id)
&& matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
);
rows.push(self.compose_activity_row(entry, false, live_reasoning, cx));
for entry in activities {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content {
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
rows.push(self.compose_activity_row(
entry,
false,
live_reasoning_id == Some(entry.id.as_str()),
cx,
));
}
}

let toggle_section_key = section_key;
let ticker_expanded = expanded && !manually_expanded;
components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
if ticker_expanded {
this.promote_auto_expanded(index, &toggle_section_key, cx);
} else {
this.toggle_auto_expanded(index, &toggle_section_key, automatic, cx);
}
}),
cx,
)
rows
}

/// Prepare one stateless Work Log activity component.
Expand Down
107 changes: 38 additions & 69 deletions crates/ui/src/chat/model.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -280,18 +280,6 @@ pub(crate) fn work_log_outcome(
}
}

pub(crate) fn manual_override_key(key: &str) -> String {
format!("manual-{key}")
}

pub(crate) fn auto_expanded(expanded: &HashSet<String>, key: &str, automatic: bool) -> bool {
if expanded.contains(&manual_override_key(key)) {
expanded.contains(key)
} else {
automatic
}
}

/// `text` collapsed to a single spaced line: every whitespace run (newlines
/// included) becomes one space, so a multi-line command shows its full content
/// in a one-line preview instead of just its first line. Clipped to more
Expand DownExpand Up@@ -558,33 +546,21 @@ pub(crate) fn live_edit_rows(changes: &[FileChange], cwd: &Path) -> Vec<LiveEdit
.collect()
}

/// Whether a Work Log segment opens on its own, before any user toggle.
///
/// Only the final segment of a running turn is live. As soon as later prose
/// starts, an earlier segment settles and folds; file evidence remains visible
/// in the separate changed-file chip row. Manual overrides still win via
/// [`auto_expanded`].
pub(crate) fn work_log_auto_expands(
_activities: &[&TimelineEntry],
turn_running: bool,
is_last: bool,
) -> bool {
turn_running && is_last
}

/// The activity entries a Work Log segment renders as rows.
///
/// An automatically expanded live run is a two-row ticker. Manual expansion
/// returns every activity in the segment, including file changes.
pub(crate) fn work_log_row_entries<'a>(
activities: &[&'a TimelineEntry],
automatic_expansion: bool,
) -> Vec<&'a TimelineEntry> {
if automatic_expansion {
activities[activities.len().saturating_sub(2)..].to_vec()
/// Maximum number of entries kept directly visible at the tail of a live
/// activity run. Older entries move into a separate collapsed Work Log; once
/// prose ends the run, the full run becomes that settled Work Log instead.
pub(crate) const LIVE_ACTIVITY_WINDOW: usize = 5;

pub(crate) fn partition_activity_run<'a>(
activities: &'a [&'a TimelineEntry],
live: bool,
) -> (&'a [&'a TimelineEntry], &'a [&'a TimelineEntry]) {
let visible = if live {
activities.len().min(LIVE_ACTIVITY_WINDOW)
} else {
activities.to_vec()
}
0
};
activities.split_at(activities.len() - visible)
}

/// Format a unix-ms timestamp as a local 12-hour clock, e.g. "2:39 AM".
Expand DownExpand Up@@ -1873,30 +1849,44 @@ mod tests {
}

#[test]
fn work_log_rows_use_a_ticker_only_for_automatic_expansion() {
fn live_activity_run_keeps_five_entries_outside_the_folded_prefix() {
let entries = [
command("cargo check"),
file_change("edit", &["src/foo.rs"]),
command("cargo test"),
command("cargo clippy"),
command("cargo fmt"),
command("cargo nextest"),
];
let activities = refs(&entries);
let ids = |rows: Vec<&TimelineEntry>| {
let ids = |rows: &[&TimelineEntry]| {
rows.iter()
.map(|entry| entry.id.clone())
.collect::<Vec<String>>()
};

let (folded, visible) = partition_activity_run(&activities, true);
assert_eq!(ids(folded), ["cargo check"]);
assert_eq!(
ids(work_log_row_entries(&activities, true)),
["cargo test", "cargo clippy"]
);
assert_eq!(
ids(work_log_row_entries(&activities, false)),
["cargo check", "edit", "cargo test", "cargo clippy"]
ids(visible),
[
"edit",
"cargo test",
"cargo clippy",
"cargo fmt",
"cargo nextest"
]
);
// Row selection never touches the summary counts.
assert_eq!(work_log_counts(&activities).files, 1);

let (folded, visible) = partition_activity_run(&activities[..5], true);
assert!(folded.is_empty());
assert_eq!(ids(visible), ids(&activities[..5]));

// Assistant prose settles the run: the same six entries are now all
// represented by one collapsed Work Log and none remain loose.
let (folded, visible) = partition_activity_run(&activities, false);
assert_eq!(ids(folded), ids(&activities));
assert!(visible.is_empty());
}

#[test]
Expand DownExpand Up@@ -1990,27 +1980,6 @@ mod tests {
);
}

#[test]
fn earlier_file_change_segments_settle_after_prose_starts() {
let file_edits = [command("cargo check"), file_change("edit", &["src/a.rs"])];
let ordinary = [command("cargo check"), command("cargo test")];
let file_edits = refs(&file_edits);
let ordinary = refs(&ordinary);

// Live, no longer the final segment: every run settles.
assert!(!work_log_auto_expands(&file_edits, true, false));
assert!(!work_log_auto_expands(&ordinary, true, false));

// The final live segment keeps opening on its own, as it always did.
assert!(work_log_auto_expands(&file_edits, true, true));
assert!(work_log_auto_expands(&ordinary, true, true));

// Finished turns force nothing open; CHANGED FILES takes over.
assert!(!work_log_auto_expands(&file_edits, false, true));
assert!(!work_log_auto_expands(&file_edits, false, false));
assert!(!work_log_auto_expands(&ordinary, false, true));
}

#[test]
fn finished_activity_runs_use_segment_scoped_counts() {
let _locale_guard = crate::settings::TestLocaleGuard::acquire();
Expand Down
14 changes: 8 additions & 6 deletions docs/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,15 +142,17 @@ platform pays that inset.
typographic step down from the 15px bubble to the muted activity summary.
- Turn activity = collapsible "Work Log" sections: an expanded section starts
with an 11px uppercase muted label, followed by activity rows (muted ✓ +
one-line summary; command/tool/subagent/reasoning); >2 rows → last 2 +
"+N previous log entries" expander. Once expanded, that row becomes "Hide N
previous log entries" with an upward chevron so the rows can be collapsed
again. A completed section's toggle summarizes only its real, nonzero events
one-line summary; command/tool/subagent/reasoning). While a turn is running,
the latest five activities remain directly visible. Once a sixth arrives,
only the older prefix is summarized by a collapsed Work Log row (with a
working spinner on its right); those five visible activities are excluded
from that row's counts. Assistant prose settles the run, folding every
activity in it under one summary row. A completed section's toggle summarizes
only its real, nonzero events
(commands, unique edited files, tool calls, subagents, and compactions); an
earlier section uses its own counts and the final section uses turn-wide
counts, prefixed once with Chinese “共” to make the aggregate scope explicit.
A zero-event summary is omitted. The active section stays expanded with "•••
Working for Ns" ticking.
A zero-event summary is omitted.
- Assistant markdown 15px, relaxed line-height, inline code chips (mono 13,
muted bg, 4px radius). Streaming appends via push_str with
follow-when-near-bottom.
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions crates/ui/src/chat/components/work_log.rs
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
use std::path::Path;

use crate::icon::{Icon, IconName};
use crate::sizing::Sizable as _;
use crate::theme::ActiveTheme as _;
use crate::widgets::spinner::Spinner;
use agent::TurnStatus;
use gpui::{
AnyElement, App, ClickEvent, InteractiveElement as _, IntoElement as _, ParentElement as _,
Expand DownExpand Up@@ -72,6 +74,9 @@ pub(crate) fn work_log(
.on_click(on_toggle)
.child(Icon::new(chevron(expanded)).size(px(12.)).text_color(muted))
.child(capsule_label)
.when(running, |row| {
row.child(Spinner::new().xsmall().color(cx.theme().primary))
})
.when(!running, |row| {
// A settled run needs no badge; only a bad ending is called out.
let failure = match outcome {
Expand Down
174 changes: 86 additions & 88 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,11 +48,10 @@ use self::components::assistant::MdState;
use self::components::command_panel::CommandPanelCache;
use self::model::{
ListSync, Segment, TurnIndexCache, TurnListItem, TurnRenderArgs, activity_run_duration_ms,
auto_expanded, displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, manual_override_key, plain_text_as_markdown,
displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, partition_activity_run, plain_text_as_markdown,
segment_entries, start_hub_projects, timeline_overdraw, user_content, user_visible_text,
work_log_auto_expands, work_log_capsule_label, work_log_counts, work_log_outcome,
work_log_row_entries,
work_log_capsule_label, work_log_counts, work_log_outcome,
};
use self::residency::{
MarkdownEntry, ResidencyInput, ResidencyScope, decide, tail_turn_window, viewport_turn_window,
Expand DownExpand Up@@ -615,33 +614,6 @@ impl ChatView {
cx.notify();
}

fn toggle_auto_expanded(
&mut self,
turn: usize,
key: &str,
automatic: bool,
cx: &mut Context<Self>,
) {
let was_expanded = auto_expanded(&self.expanded, key, automatic);
self.expanded.insert(manual_override_key(key));
if was_expanded {
self.expanded.remove(key);
} else {
self.expanded.insert(key.to_string());
}
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

fn promote_auto_expanded(&mut self, turn: usize, key: &str, cx: &mut Context<Self>) {
self.expanded.insert(manual_override_key(key));
self.expanded.insert(key.to_string());
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

// -- turn rendering -----------------------------------------------------

/// Render one turn as chronological messages, errors, and Work Log runs.
Expand DownExpand Up@@ -1055,69 +1027,95 @@ impl ChatView {
let (index, segment_id, turn, cwd, activities, is_last) = args;
let section_key = format!("worklog-{index}-{segment_id}");
let running = is_last && turn.running;
let automatic = work_log_auto_expands(activities, turn.running, is_last);
let expanded = auto_expanded(&self.expanded, &section_key, automatic);
let manually_expanded =
expanded && (self.expanded.contains(&manual_override_key(&section_key)) || !automatic);
let segment_counts = work_log_counts(activities);
let mut capsule_label = work_log_capsule_label(&segment_counts, activities.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
let (folded, visible) = partition_activity_run(activities, running);
let expanded = self.expanded.contains(&section_key);
let live_reasoning_id = running
.then(|| activities.last().copied())
.flatten()
.filter(|entry| {
matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
)
})
.map(|entry| entry.id.as_str());

let mut flow = v_flex().w_full().gap_1();
if !folded.is_empty() {
let segment_counts = work_log_counts(folded);
let mut capsule_label = work_log_capsule_label(&segment_counts, folded.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
}
let duration =
format_span((activity_run_duration_ms(folded, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, folded, is_last);
let rows = if expanded {
self.compose_work_log_rows(folded, cwd, live_reasoning_id, cx)
} else {
Vec::new()
};

let toggle_section_key = section_key;
flow = flow.child(components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
this.toggle_expanded(index, &toggle_section_key, cx);
}),
cx,
));
}
let duration =
format_span((activity_run_duration_ms(activities, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, activities, is_last);

if !visible.is_empty() {
flow = flow.child(
v_flex()
.w_full()
.gap_1()
.children(self.compose_work_log_rows(visible, cwd, live_reasoning_id, cx)),
);
}

flow.into_any_element()
}

fn compose_work_log_rows(
&mut self,
activities: &[&TimelineEntry],
cwd: &Path,
live_reasoning_id: Option<&str>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let mut rows = Vec::new();
if expanded {
let visible = work_log_row_entries(activities, !manually_expanded);

for entry in &visible {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content
{
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
let live_reasoning = running
&& activities.last().is_some_and(|last| last.id == entry.id)
&& matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
);
rows.push(self.compose_activity_row(entry, false, live_reasoning, cx));
for entry in activities {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content {
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
rows.push(self.compose_activity_row(
entry,
false,
live_reasoning_id == Some(entry.id.as_str()),
cx,
));
}
}

let toggle_section_key = section_key;
let ticker_expanded = expanded && !manually_expanded;
components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
if ticker_expanded {
this.promote_auto_expanded(index, &toggle_section_key, cx);
} else {
this.toggle_auto_expanded(index, &toggle_section_key, automatic, cx);
}
}),
cx,
)
rows
}

/// Prepare one stateless Work Log activity component.
Expand Down
107 changes: 38 additions & 69 deletions crates/ui/src/chat/model.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -280,18 +280,6 @@ pub(crate) fn work_log_outcome(
}
}

pub(crate) fn manual_override_key(key: &str) -> String {
format!("manual-{key}")
}

pub(crate) fn auto_expanded(expanded: &HashSet<String>, key: &str, automatic: bool) -> bool {
if expanded.contains(&manual_override_key(key)) {
expanded.contains(key)
} else {
automatic
}
}

/// `text` collapsed to a single spaced line: every whitespace run (newlines
/// included) becomes one space, so a multi-line command shows its full content
/// in a one-line preview instead of just its first line. Clipped to more
Expand DownExpand Up@@ -558,33 +546,21 @@ pub(crate) fn live_edit_rows(changes: &[FileChange], cwd: &Path) -> Vec<LiveEdit
.collect()
}

/// Whether a Work Log segment opens on its own, before any user toggle.
///
/// Only the final segment of a running turn is live. As soon as later prose
/// starts, an earlier segment settles and folds; file evidence remains visible
/// in the separate changed-file chip row. Manual overrides still win via
/// [`auto_expanded`].
pub(crate) fn work_log_auto_expands(
_activities: &[&TimelineEntry],
turn_running: bool,
is_last: bool,
) -> bool {
turn_running && is_last
}

/// The activity entries a Work Log segment renders as rows.
///
/// An automatically expanded live run is a two-row ticker. Manual expansion
/// returns every activity in the segment, including file changes.
pub(crate) fn work_log_row_entries<'a>(
activities: &[&'a TimelineEntry],
automatic_expansion: bool,
) -> Vec<&'a TimelineEntry> {
if automatic_expansion {
activities[activities.len().saturating_sub(2)..].to_vec()
/// Maximum number of entries kept directly visible at the tail of a live
/// activity run. Older entries move into a separate collapsed Work Log; once
/// prose ends the run, the full run becomes that settled Work Log instead.
pub(crate) const LIVE_ACTIVITY_WINDOW: usize = 5;

pub(crate) fn partition_activity_run<'a>(
activities: &'a [&'a TimelineEntry],
live: bool,
) -> (&'a [&'a TimelineEntry], &'a [&'a TimelineEntry]) {
let visible = if live {
activities.len().min(LIVE_ACTIVITY_WINDOW)
} else {
activities.to_vec()
}
0
};
activities.split_at(activities.len() - visible)
}

/// Format a unix-ms timestamp as a local 12-hour clock, e.g. "2:39 AM".
Expand DownExpand Up@@ -1873,30 +1849,44 @@ mod tests {
}

#[test]
fn work_log_rows_use_a_ticker_only_for_automatic_expansion() {
fn live_activity_run_keeps_five_entries_outside_the_folded_prefix() {
let entries = [
command("cargo check"),
file_change("edit", &["src/foo.rs"]),
command("cargo test"),
command("cargo clippy"),
command("cargo fmt"),
command("cargo nextest"),
];
let activities = refs(&entries);
let ids = |rows: Vec<&TimelineEntry>| {
let ids = |rows: &[&TimelineEntry]| {
rows.iter()
.map(|entry| entry.id.clone())
.collect::<Vec<String>>()
};

let (folded, visible) = partition_activity_run(&activities, true);
assert_eq!(ids(folded), ["cargo check"]);
assert_eq!(
ids(work_log_row_entries(&activities, true)),
["cargo test", "cargo clippy"]
);
assert_eq!(
ids(work_log_row_entries(&activities, false)),
["cargo check", "edit", "cargo test", "cargo clippy"]
ids(visible),
[
"edit",
"cargo test",
"cargo clippy",
"cargo fmt",
"cargo nextest"
]
);
// Row selection never touches the summary counts.
assert_eq!(work_log_counts(&activities).files, 1);

let (folded, visible) = partition_activity_run(&activities[..5], true);
assert!(folded.is_empty());
assert_eq!(ids(visible), ids(&activities[..5]));

// Assistant prose settles the run: the same six entries are now all
// represented by one collapsed Work Log and none remain loose.
let (folded, visible) = partition_activity_run(&activities, false);
assert_eq!(ids(folded), ids(&activities));
assert!(visible.is_empty());
}

#[test]
Expand DownExpand Up@@ -1990,27 +1980,6 @@ mod tests {
);
}

#[test]
fn earlier_file_change_segments_settle_after_prose_starts() {
let file_edits = [command("cargo check"), file_change("edit", &["src/a.rs"])];
let ordinary = [command("cargo check"), command("cargo test")];
let file_edits = refs(&file_edits);
let ordinary = refs(&ordinary);

// Live, no longer the final segment: every run settles.
assert!(!work_log_auto_expands(&file_edits, true, false));
assert!(!work_log_auto_expands(&ordinary, true, false));

// The final live segment keeps opening on its own, as it always did.
assert!(work_log_auto_expands(&file_edits, true, true));
assert!(work_log_auto_expands(&ordinary, true, true));

// Finished turns force nothing open; CHANGED FILES takes over.
assert!(!work_log_auto_expands(&file_edits, false, true));
assert!(!work_log_auto_expands(&file_edits, false, false));
assert!(!work_log_auto_expands(&ordinary, false, true));
}

#[test]
fn finished_activity_runs_use_segment_scoped_counts() {
let _locale_guard = crate::settings::TestLocaleGuard::acquire();
Expand Down
14 changes: 8 additions & 6 deletions docs/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,15 +142,17 @@ platform pays that inset.
typographic step down from the 15px bubble to the muted activity summary.
- Turn activity = collapsible "Work Log" sections: an expanded section starts
with an 11px uppercase muted label, followed by activity rows (muted ✓ +
one-line summary; command/tool/subagent/reasoning); >2 rows → last 2 +
"+N previous log entries" expander. Once expanded, that row becomes "Hide N
previous log entries" with an upward chevron so the rows can be collapsed
again. A completed section's toggle summarizes only its real, nonzero events
one-line summary; command/tool/subagent/reasoning). While a turn is running,
the latest five activities remain directly visible. Once a sixth arrives,
only the older prefix is summarized by a collapsed Work Log row (with a
working spinner on its right); those five visible activities are excluded
from that row's counts. Assistant prose settles the run, folding every
activity in it under one summary row. A completed section's toggle summarizes
only its real, nonzero events
(commands, unique edited files, tool calls, subagents, and compactions); an
earlier section uses its own counts and the final section uses turn-wide
counts, prefixed once with Chinese “共” to make the aggregate scope explicit.
A zero-event summary is omitted. The active section stays expanded with "•••
Working for Ns" ticking.
A zero-event summary is omitted.
- Assistant markdown 15px, relaxed line-height, inline code chips (mono 13,
muted bg, 4px radius). Streaming appends via push_str with
follow-when-near-bottom.
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions crates/ui/src/chat/components/work_log.rs
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
use std::path::Path;

use crate::icon::{Icon, IconName};
use crate::sizing::Sizable as _;
use crate::theme::ActiveTheme as _;
use crate::widgets::spinner::Spinner;
use agent::TurnStatus;
use gpui::{
AnyElement, App, ClickEvent, InteractiveElement as _, IntoElement as _, ParentElement as _,
Expand DownExpand Up@@ -72,6 +74,9 @@ pub(crate) fn work_log(
.on_click(on_toggle)
.child(Icon::new(chevron(expanded)).size(px(12.)).text_color(muted))
.child(capsule_label)
.when(running, |row| {
row.child(Spinner::new().xsmall().color(cx.theme().primary))
})
.when(!running, |row| {
// A settled run needs no badge; only a bad ending is called out.
let failure = match outcome {
Expand Down
174 changes: 86 additions & 88 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,11 +48,10 @@ use self::components::assistant::MdState;
use self::components::command_panel::CommandPanelCache;
use self::model::{
ListSync, Segment, TurnIndexCache, TurnListItem, TurnRenderArgs, activity_run_duration_ms,
auto_expanded, displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, manual_override_key, plain_text_as_markdown,
displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, partition_activity_run, plain_text_as_markdown,
segment_entries, start_hub_projects, timeline_overdraw, user_content, user_visible_text,
work_log_auto_expands, work_log_capsule_label, work_log_counts, work_log_outcome,
work_log_row_entries,
work_log_capsule_label, work_log_counts, work_log_outcome,
};
use self::residency::{
MarkdownEntry, ResidencyInput, ResidencyScope, decide, tail_turn_window, viewport_turn_window,
Expand DownExpand Up@@ -615,33 +614,6 @@ impl ChatView {
cx.notify();
}

fn toggle_auto_expanded(
&mut self,
turn: usize,
key: &str,
automatic: bool,
cx: &mut Context<Self>,
) {
let was_expanded = auto_expanded(&self.expanded, key, automatic);
self.expanded.insert(manual_override_key(key));
if was_expanded {
self.expanded.remove(key);
} else {
self.expanded.insert(key.to_string());
}
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

fn promote_auto_expanded(&mut self, turn: usize, key: &str, cx: &mut Context<Self>) {
self.expanded.insert(manual_override_key(key));
self.expanded.insert(key.to_string());
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

// -- turn rendering -----------------------------------------------------

/// Render one turn as chronological messages, errors, and Work Log runs.
Expand DownExpand Up@@ -1055,69 +1027,95 @@ impl ChatView {
let (index, segment_id, turn, cwd, activities, is_last) = args;
let section_key = format!("worklog-{index}-{segment_id}");
let running = is_last && turn.running;
let automatic = work_log_auto_expands(activities, turn.running, is_last);
let expanded = auto_expanded(&self.expanded, &section_key, automatic);
let manually_expanded =
expanded && (self.expanded.contains(&manual_override_key(&section_key)) || !automatic);
let segment_counts = work_log_counts(activities);
let mut capsule_label = work_log_capsule_label(&segment_counts, activities.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
let (folded, visible) = partition_activity_run(activities, running);
let expanded = self.expanded.contains(&section_key);
let live_reasoning_id = running
.then(|| activities.last().copied())
.flatten()
.filter(|entry| {
matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
)
})
.map(|entry| entry.id.as_str());

let mut flow = v_flex().w_full().gap_1();
if !folded.is_empty() {
let segment_counts = work_log_counts(folded);
let mut capsule_label = work_log_capsule_label(&segment_counts, folded.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
}
let duration =
format_span((activity_run_duration_ms(folded, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, folded, is_last);
let rows = if expanded {
self.compose_work_log_rows(folded, cwd, live_reasoning_id, cx)
} else {
Vec::new()
};

let toggle_section_key = section_key;
flow = flow.child(components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
this.toggle_expanded(index, &toggle_section_key, cx);
}),
cx,
));
}
let duration =
format_span((activity_run_duration_ms(activities, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, activities, is_last);

if !visible.is_empty() {
flow = flow.child(
v_flex()
.w_full()
.gap_1()
.children(self.compose_work_log_rows(visible, cwd, live_reasoning_id, cx)),
);
}

flow.into_any_element()
}

fn compose_work_log_rows(
&mut self,
activities: &[&TimelineEntry],
cwd: &Path,
live_reasoning_id: Option<&str>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let mut rows = Vec::new();
if expanded {
let visible = work_log_row_entries(activities, !manually_expanded);

for entry in &visible {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content
{
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
let live_reasoning = running
&& activities.last().is_some_and(|last| last.id == entry.id)
&& matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
);
rows.push(self.compose_activity_row(entry, false, live_reasoning, cx));
for entry in activities {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content {
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
rows.push(self.compose_activity_row(
entry,
false,
live_reasoning_id == Some(entry.id.as_str()),
cx,
));
}
}

let toggle_section_key = section_key;
let ticker_expanded = expanded && !manually_expanded;
components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
if ticker_expanded {
this.promote_auto_expanded(index, &toggle_section_key, cx);
} else {
this.toggle_auto_expanded(index, &toggle_section_key, automatic, cx);
}
}),
cx,
)
rows
}

/// Prepare one stateless Work Log activity component.
Expand Down
107 changes: 38 additions & 69 deletions crates/ui/src/chat/model.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -280,18 +280,6 @@ pub(crate) fn work_log_outcome(
}
}

pub(crate) fn manual_override_key(key: &str) -> String {
format!("manual-{key}")
}

pub(crate) fn auto_expanded(expanded: &HashSet<String>, key: &str, automatic: bool) -> bool {
if expanded.contains(&manual_override_key(key)) {
expanded.contains(key)
} else {
automatic
}
}

/// `text` collapsed to a single spaced line: every whitespace run (newlines
/// included) becomes one space, so a multi-line command shows its full content
/// in a one-line preview instead of just its first line. Clipped to more
Expand DownExpand Up@@ -558,33 +546,21 @@ pub(crate) fn live_edit_rows(changes: &[FileChange], cwd: &Path) -> Vec<LiveEdit
.collect()
}

/// Whether a Work Log segment opens on its own, before any user toggle.
///
/// Only the final segment of a running turn is live. As soon as later prose
/// starts, an earlier segment settles and folds; file evidence remains visible
/// in the separate changed-file chip row. Manual overrides still win via
/// [`auto_expanded`].
pub(crate) fn work_log_auto_expands(
_activities: &[&TimelineEntry],
turn_running: bool,
is_last: bool,
) -> bool {
turn_running && is_last
}

/// The activity entries a Work Log segment renders as rows.
///
/// An automatically expanded live run is a two-row ticker. Manual expansion
/// returns every activity in the segment, including file changes.
pub(crate) fn work_log_row_entries<'a>(
activities: &[&'a TimelineEntry],
automatic_expansion: bool,
) -> Vec<&'a TimelineEntry> {
if automatic_expansion {
activities[activities.len().saturating_sub(2)..].to_vec()
/// Maximum number of entries kept directly visible at the tail of a live
/// activity run. Older entries move into a separate collapsed Work Log; once
/// prose ends the run, the full run becomes that settled Work Log instead.
pub(crate) const LIVE_ACTIVITY_WINDOW: usize = 5;

pub(crate) fn partition_activity_run<'a>(
activities: &'a [&'a TimelineEntry],
live: bool,
) -> (&'a [&'a TimelineEntry], &'a [&'a TimelineEntry]) {
let visible = if live {
activities.len().min(LIVE_ACTIVITY_WINDOW)
} else {
activities.to_vec()
}
0
};
activities.split_at(activities.len() - visible)
}

/// Format a unix-ms timestamp as a local 12-hour clock, e.g. "2:39 AM".
Expand DownExpand Up@@ -1873,30 +1849,44 @@ mod tests {
}

#[test]
fn work_log_rows_use_a_ticker_only_for_automatic_expansion() {
fn live_activity_run_keeps_five_entries_outside_the_folded_prefix() {
let entries = [
command("cargo check"),
file_change("edit", &["src/foo.rs"]),
command("cargo test"),
command("cargo clippy"),
command("cargo fmt"),
command("cargo nextest"),
];
let activities = refs(&entries);
let ids = |rows: Vec<&TimelineEntry>| {
let ids = |rows: &[&TimelineEntry]| {
rows.iter()
.map(|entry| entry.id.clone())
.collect::<Vec<String>>()
};

let (folded, visible) = partition_activity_run(&activities, true);
assert_eq!(ids(folded), ["cargo check"]);
assert_eq!(
ids(work_log_row_entries(&activities, true)),
["cargo test", "cargo clippy"]
);
assert_eq!(
ids(work_log_row_entries(&activities, false)),
["cargo check", "edit", "cargo test", "cargo clippy"]
ids(visible),
[
"edit",
"cargo test",
"cargo clippy",
"cargo fmt",
"cargo nextest"
]
);
// Row selection never touches the summary counts.
assert_eq!(work_log_counts(&activities).files, 1);

let (folded, visible) = partition_activity_run(&activities[..5], true);
assert!(folded.is_empty());
assert_eq!(ids(visible), ids(&activities[..5]));

// Assistant prose settles the run: the same six entries are now all
// represented by one collapsed Work Log and none remain loose.
let (folded, visible) = partition_activity_run(&activities, false);
assert_eq!(ids(folded), ids(&activities));
assert!(visible.is_empty());
}

#[test]
Expand DownExpand Up@@ -1990,27 +1980,6 @@ mod tests {
);
}

#[test]
fn earlier_file_change_segments_settle_after_prose_starts() {
let file_edits = [command("cargo check"), file_change("edit", &["src/a.rs"])];
let ordinary = [command("cargo check"), command("cargo test")];
let file_edits = refs(&file_edits);
let ordinary = refs(&ordinary);

// Live, no longer the final segment: every run settles.
assert!(!work_log_auto_expands(&file_edits, true, false));
assert!(!work_log_auto_expands(&ordinary, true, false));

// The final live segment keeps opening on its own, as it always did.
assert!(work_log_auto_expands(&file_edits, true, true));
assert!(work_log_auto_expands(&ordinary, true, true));

// Finished turns force nothing open; CHANGED FILES takes over.
assert!(!work_log_auto_expands(&file_edits, false, true));
assert!(!work_log_auto_expands(&file_edits, false, false));
assert!(!work_log_auto_expands(&ordinary, false, true));
}

#[test]
fn finished_activity_runs_use_segment_scoped_counts() {
let _locale_guard = crate::settings::TestLocaleGuard::acquire();
Expand Down
14 changes: 8 additions & 6 deletions docs/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,15 +142,17 @@ platform pays that inset.
typographic step down from the 15px bubble to the muted activity summary.
- Turn activity = collapsible "Work Log" sections: an expanded section starts
with an 11px uppercase muted label, followed by activity rows (muted ✓ +
one-line summary; command/tool/subagent/reasoning); >2 rows → last 2 +
"+N previous log entries" expander. Once expanded, that row becomes "Hide N
previous log entries" with an upward chevron so the rows can be collapsed
again. A completed section's toggle summarizes only its real, nonzero events
one-line summary; command/tool/subagent/reasoning). While a turn is running,
the latest five activities remain directly visible. Once a sixth arrives,
only the older prefix is summarized by a collapsed Work Log row (with a
working spinner on its right); those five visible activities are excluded
from that row's counts. Assistant prose settles the run, folding every
activity in it under one summary row. A completed section's toggle summarizes
only its real, nonzero events
(commands, unique edited files, tool calls, subagents, and compactions); an
earlier section uses its own counts and the final section uses turn-wide
counts, prefixed once with Chinese “共” to make the aggregate scope explicit.
A zero-event summary is omitted. The active section stays expanded with "•••
Working for Ns" ticking.
A zero-event summary is omitted.
- Assistant markdown 15px, relaxed line-height, inline code chips (mono 13,
muted bg, 4px radius). Streaming appends via push_str with
follow-when-near-bottom.
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions crates/ui/src/chat/components/work_log.rs
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
use std::path::Path;

use crate::icon::{Icon, IconName};
use crate::sizing::Sizable as _;
use crate::theme::ActiveTheme as _;
use crate::widgets::spinner::Spinner;
use agent::TurnStatus;
use gpui::{
AnyElement, App, ClickEvent, InteractiveElement as _, IntoElement as _, ParentElement as _,
Expand DownExpand Up@@ -72,6 +74,9 @@ pub(crate) fn work_log(
.on_click(on_toggle)
.child(Icon::new(chevron(expanded)).size(px(12.)).text_color(muted))
.child(capsule_label)
.when(running, |row| {
row.child(Spinner::new().xsmall().color(cx.theme().primary))
})
.when(!running, |row| {
// A settled run needs no badge; only a bad ending is called out.
let failure = match outcome {
Expand Down
174 changes: 86 additions & 88 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,11 +48,10 @@ use self::components::assistant::MdState;
use self::components::command_panel::CommandPanelCache;
use self::model::{
ListSync, Segment, TurnIndexCache, TurnListItem, TurnRenderArgs, activity_run_duration_ms,
auto_expanded, displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, manual_override_key, plain_text_as_markdown,
displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, partition_activity_run, plain_text_as_markdown,
segment_entries, start_hub_projects, timeline_overdraw, user_content, user_visible_text,
work_log_auto_expands, work_log_capsule_label, work_log_counts, work_log_outcome,
work_log_row_entries,
work_log_capsule_label, work_log_counts, work_log_outcome,
};
use self::residency::{
MarkdownEntry, ResidencyInput, ResidencyScope, decide, tail_turn_window, viewport_turn_window,
Expand DownExpand Up@@ -615,33 +614,6 @@ impl ChatView {
cx.notify();
}

fn toggle_auto_expanded(
&mut self,
turn: usize,
key: &str,
automatic: bool,
cx: &mut Context<Self>,
) {
let was_expanded = auto_expanded(&self.expanded, key, automatic);
self.expanded.insert(manual_override_key(key));
if was_expanded {
self.expanded.remove(key);
} else {
self.expanded.insert(key.to_string());
}
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

fn promote_auto_expanded(&mut self, turn: usize, key: &str, cx: &mut Context<Self>) {
self.expanded.insert(manual_override_key(key));
self.expanded.insert(key.to_string());
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

// -- turn rendering -----------------------------------------------------

/// Render one turn as chronological messages, errors, and Work Log runs.
Expand DownExpand Up@@ -1055,69 +1027,95 @@ impl ChatView {
let (index, segment_id, turn, cwd, activities, is_last) = args;
let section_key = format!("worklog-{index}-{segment_id}");
let running = is_last && turn.running;
let automatic = work_log_auto_expands(activities, turn.running, is_last);
let expanded = auto_expanded(&self.expanded, &section_key, automatic);
let manually_expanded =
expanded && (self.expanded.contains(&manual_override_key(&section_key)) || !automatic);
let segment_counts = work_log_counts(activities);
let mut capsule_label = work_log_capsule_label(&segment_counts, activities.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
let (folded, visible) = partition_activity_run(activities, running);
let expanded = self.expanded.contains(&section_key);
let live_reasoning_id = running
.then(|| activities.last().copied())
.flatten()
.filter(|entry| {
matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
)
})
.map(|entry| entry.id.as_str());

let mut flow = v_flex().w_full().gap_1();
if !folded.is_empty() {
let segment_counts = work_log_counts(folded);
let mut capsule_label = work_log_capsule_label(&segment_counts, folded.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
}
let duration =
format_span((activity_run_duration_ms(folded, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, folded, is_last);
let rows = if expanded {
self.compose_work_log_rows(folded, cwd, live_reasoning_id, cx)
} else {
Vec::new()
};

let toggle_section_key = section_key;
flow = flow.child(components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
this.toggle_expanded(index, &toggle_section_key, cx);
}),
cx,
));
}
let duration =
format_span((activity_run_duration_ms(activities, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, activities, is_last);

if !visible.is_empty() {
flow = flow.child(
v_flex()
.w_full()
.gap_1()
.children(self.compose_work_log_rows(visible, cwd, live_reasoning_id, cx)),
);
}

flow.into_any_element()
}

fn compose_work_log_rows(
&mut self,
activities: &[&TimelineEntry],
cwd: &Path,
live_reasoning_id: Option<&str>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let mut rows = Vec::new();
if expanded {
let visible = work_log_row_entries(activities, !manually_expanded);

for entry in &visible {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content
{
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
let live_reasoning = running
&& activities.last().is_some_and(|last| last.id == entry.id)
&& matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
);
rows.push(self.compose_activity_row(entry, false, live_reasoning, cx));
for entry in activities {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content {
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
rows.push(self.compose_activity_row(
entry,
false,
live_reasoning_id == Some(entry.id.as_str()),
cx,
));
}
}

let toggle_section_key = section_key;
let ticker_expanded = expanded && !manually_expanded;
components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
if ticker_expanded {
this.promote_auto_expanded(index, &toggle_section_key, cx);
} else {
this.toggle_auto_expanded(index, &toggle_section_key, automatic, cx);
}
}),
cx,
)
rows
}

/// Prepare one stateless Work Log activity component.
Expand Down
107 changes: 38 additions & 69 deletions crates/ui/src/chat/model.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -280,18 +280,6 @@ pub(crate) fn work_log_outcome(
}
}

pub(crate) fn manual_override_key(key: &str) -> String {
format!("manual-{key}")
}

pub(crate) fn auto_expanded(expanded: &HashSet<String>, key: &str, automatic: bool) -> bool {
if expanded.contains(&manual_override_key(key)) {
expanded.contains(key)
} else {
automatic
}
}

/// `text` collapsed to a single spaced line: every whitespace run (newlines
/// included) becomes one space, so a multi-line command shows its full content
/// in a one-line preview instead of just its first line. Clipped to more
Expand DownExpand Up@@ -558,33 +546,21 @@ pub(crate) fn live_edit_rows(changes: &[FileChange], cwd: &Path) -> Vec<LiveEdit
.collect()
}

/// Whether a Work Log segment opens on its own, before any user toggle.
///
/// Only the final segment of a running turn is live. As soon as later prose
/// starts, an earlier segment settles and folds; file evidence remains visible
/// in the separate changed-file chip row. Manual overrides still win via
/// [`auto_expanded`].
pub(crate) fn work_log_auto_expands(
_activities: &[&TimelineEntry],
turn_running: bool,
is_last: bool,
) -> bool {
turn_running && is_last
}

/// The activity entries a Work Log segment renders as rows.
///
/// An automatically expanded live run is a two-row ticker. Manual expansion
/// returns every activity in the segment, including file changes.
pub(crate) fn work_log_row_entries<'a>(
activities: &[&'a TimelineEntry],
automatic_expansion: bool,
) -> Vec<&'a TimelineEntry> {
if automatic_expansion {
activities[activities.len().saturating_sub(2)..].to_vec()
/// Maximum number of entries kept directly visible at the tail of a live
/// activity run. Older entries move into a separate collapsed Work Log; once
/// prose ends the run, the full run becomes that settled Work Log instead.
pub(crate) const LIVE_ACTIVITY_WINDOW: usize = 5;

pub(crate) fn partition_activity_run<'a>(
activities: &'a [&'a TimelineEntry],
live: bool,
) -> (&'a [&'a TimelineEntry], &'a [&'a TimelineEntry]) {
let visible = if live {
activities.len().min(LIVE_ACTIVITY_WINDOW)
} else {
activities.to_vec()
}
0
};
activities.split_at(activities.len() - visible)
}

/// Format a unix-ms timestamp as a local 12-hour clock, e.g. "2:39 AM".
Expand DownExpand Up@@ -1873,30 +1849,44 @@ mod tests {
}

#[test]
fn work_log_rows_use_a_ticker_only_for_automatic_expansion() {
fn live_activity_run_keeps_five_entries_outside_the_folded_prefix() {
let entries = [
command("cargo check"),
file_change("edit", &["src/foo.rs"]),
command("cargo test"),
command("cargo clippy"),
command("cargo fmt"),
command("cargo nextest"),
];
let activities = refs(&entries);
let ids = |rows: Vec<&TimelineEntry>| {
let ids = |rows: &[&TimelineEntry]| {
rows.iter()
.map(|entry| entry.id.clone())
.collect::<Vec<String>>()
};

let (folded, visible) = partition_activity_run(&activities, true);
assert_eq!(ids(folded), ["cargo check"]);
assert_eq!(
ids(work_log_row_entries(&activities, true)),
["cargo test", "cargo clippy"]
);
assert_eq!(
ids(work_log_row_entries(&activities, false)),
["cargo check", "edit", "cargo test", "cargo clippy"]
ids(visible),
[
"edit",
"cargo test",
"cargo clippy",
"cargo fmt",
"cargo nextest"
]
);
// Row selection never touches the summary counts.
assert_eq!(work_log_counts(&activities).files, 1);

let (folded, visible) = partition_activity_run(&activities[..5], true);
assert!(folded.is_empty());
assert_eq!(ids(visible), ids(&activities[..5]));

// Assistant prose settles the run: the same six entries are now all
// represented by one collapsed Work Log and none remain loose.
let (folded, visible) = partition_activity_run(&activities, false);
assert_eq!(ids(folded), ids(&activities));
assert!(visible.is_empty());
}

#[test]
Expand DownExpand Up@@ -1990,27 +1980,6 @@ mod tests {
);
}

#[test]
fn earlier_file_change_segments_settle_after_prose_starts() {
let file_edits = [command("cargo check"), file_change("edit", &["src/a.rs"])];
let ordinary = [command("cargo check"), command("cargo test")];
let file_edits = refs(&file_edits);
let ordinary = refs(&ordinary);

// Live, no longer the final segment: every run settles.
assert!(!work_log_auto_expands(&file_edits, true, false));
assert!(!work_log_auto_expands(&ordinary, true, false));

// The final live segment keeps opening on its own, as it always did.
assert!(work_log_auto_expands(&file_edits, true, true));
assert!(work_log_auto_expands(&ordinary, true, true));

// Finished turns force nothing open; CHANGED FILES takes over.
assert!(!work_log_auto_expands(&file_edits, false, true));
assert!(!work_log_auto_expands(&file_edits, false, false));
assert!(!work_log_auto_expands(&ordinary, false, true));
}

#[test]
fn finished_activity_runs_use_segment_scoped_counts() {
let _locale_guard = crate::settings::TestLocaleGuard::acquire();
Expand Down
14 changes: 8 additions & 6 deletions docs/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,15 +142,17 @@ platform pays that inset.
typographic step down from the 15px bubble to the muted activity summary.
- Turn activity = collapsible "Work Log" sections: an expanded section starts
with an 11px uppercase muted label, followed by activity rows (muted ✓ +
one-line summary; command/tool/subagent/reasoning); >2 rows → last 2 +
"+N previous log entries" expander. Once expanded, that row becomes "Hide N
previous log entries" with an upward chevron so the rows can be collapsed
again. A completed section's toggle summarizes only its real, nonzero events
one-line summary; command/tool/subagent/reasoning). While a turn is running,
the latest five activities remain directly visible. Once a sixth arrives,
only the older prefix is summarized by a collapsed Work Log row (with a
working spinner on its right); those five visible activities are excluded
from that row's counts. Assistant prose settles the run, folding every
activity in it under one summary row. A completed section's toggle summarizes
only its real, nonzero events
(commands, unique edited files, tool calls, subagents, and compactions); an
earlier section uses its own counts and the final section uses turn-wide
counts, prefixed once with Chinese “共” to make the aggregate scope explicit.
A zero-event summary is omitted. The active section stays expanded with "•••
Working for Ns" ticking.
A zero-event summary is omitted.
- Assistant markdown 15px, relaxed line-height, inline code chips (mono 13,
muted bg, 4px radius). Streaming appends via push_str with
follow-when-near-bottom.
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions crates/ui/src/chat/components/work_log.rs
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
use std::path::Path;

use crate::icon::{Icon, IconName};
use crate::sizing::Sizable as _;
use crate::theme::ActiveTheme as _;
use crate::widgets::spinner::Spinner;
use agent::TurnStatus;
use gpui::{
AnyElement, App, ClickEvent, InteractiveElement as _, IntoElement as _, ParentElement as _,
Expand DownExpand Up@@ -72,6 +74,9 @@ pub(crate) fn work_log(
.on_click(on_toggle)
.child(Icon::new(chevron(expanded)).size(px(12.)).text_color(muted))
.child(capsule_label)
.when(running, |row| {
row.child(Spinner::new().xsmall().color(cx.theme().primary))
})
.when(!running, |row| {
// A settled run needs no badge; only a bad ending is called out.
let failure = match outcome {
Expand Down
174 changes: 86 additions & 88 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,11 +48,10 @@ use self::components::assistant::MdState;
use self::components::command_panel::CommandPanelCache;
use self::model::{
ListSync, Segment, TurnIndexCache, TurnListItem, TurnRenderArgs, activity_run_duration_ms,
auto_expanded, displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, manual_override_key, plain_text_as_markdown,
displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, partition_activity_run, plain_text_as_markdown,
segment_entries, start_hub_projects, timeline_overdraw, user_content, user_visible_text,
work_log_auto_expands, work_log_capsule_label, work_log_counts, work_log_outcome,
work_log_row_entries,
work_log_capsule_label, work_log_counts, work_log_outcome,
};
use self::residency::{
MarkdownEntry, ResidencyInput, ResidencyScope, decide, tail_turn_window, viewport_turn_window,
Expand DownExpand Up@@ -615,33 +614,6 @@ impl ChatView {
cx.notify();
}

fn toggle_auto_expanded(
&mut self,
turn: usize,
key: &str,
automatic: bool,
cx: &mut Context<Self>,
) {
let was_expanded = auto_expanded(&self.expanded, key, automatic);
self.expanded.insert(manual_override_key(key));
if was_expanded {
self.expanded.remove(key);
} else {
self.expanded.insert(key.to_string());
}
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

fn promote_auto_expanded(&mut self, turn: usize, key: &str, cx: &mut Context<Self>) {
self.expanded.insert(manual_override_key(key));
self.expanded.insert(key.to_string());
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

// -- turn rendering -----------------------------------------------------

/// Render one turn as chronological messages, errors, and Work Log runs.
Expand DownExpand Up@@ -1055,69 +1027,95 @@ impl ChatView {
let (index, segment_id, turn, cwd, activities, is_last) = args;
let section_key = format!("worklog-{index}-{segment_id}");
let running = is_last && turn.running;
let automatic = work_log_auto_expands(activities, turn.running, is_last);
let expanded = auto_expanded(&self.expanded, &section_key, automatic);
let manually_expanded =
expanded && (self.expanded.contains(&manual_override_key(&section_key)) || !automatic);
let segment_counts = work_log_counts(activities);
let mut capsule_label = work_log_capsule_label(&segment_counts, activities.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
let (folded, visible) = partition_activity_run(activities, running);
let expanded = self.expanded.contains(&section_key);
let live_reasoning_id = running
.then(|| activities.last().copied())
.flatten()
.filter(|entry| {
matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
)
})
.map(|entry| entry.id.as_str());

let mut flow = v_flex().w_full().gap_1();
if !folded.is_empty() {
let segment_counts = work_log_counts(folded);
let mut capsule_label = work_log_capsule_label(&segment_counts, folded.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
}
let duration =
format_span((activity_run_duration_ms(folded, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, folded, is_last);
let rows = if expanded {
self.compose_work_log_rows(folded, cwd, live_reasoning_id, cx)
} else {
Vec::new()
};

let toggle_section_key = section_key;
flow = flow.child(components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
this.toggle_expanded(index, &toggle_section_key, cx);
}),
cx,
));
}
let duration =
format_span((activity_run_duration_ms(activities, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, activities, is_last);

if !visible.is_empty() {
flow = flow.child(
v_flex()
.w_full()
.gap_1()
.children(self.compose_work_log_rows(visible, cwd, live_reasoning_id, cx)),
);
}

flow.into_any_element()
}

fn compose_work_log_rows(
&mut self,
activities: &[&TimelineEntry],
cwd: &Path,
live_reasoning_id: Option<&str>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let mut rows = Vec::new();
if expanded {
let visible = work_log_row_entries(activities, !manually_expanded);

for entry in &visible {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content
{
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
let live_reasoning = running
&& activities.last().is_some_and(|last| last.id == entry.id)
&& matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
);
rows.push(self.compose_activity_row(entry, false, live_reasoning, cx));
for entry in activities {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content {
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
rows.push(self.compose_activity_row(
entry,
false,
live_reasoning_id == Some(entry.id.as_str()),
cx,
));
}
}

let toggle_section_key = section_key;
let ticker_expanded = expanded && !manually_expanded;
components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
if ticker_expanded {
this.promote_auto_expanded(index, &toggle_section_key, cx);
} else {
this.toggle_auto_expanded(index, &toggle_section_key, automatic, cx);
}
}),
cx,
)
rows
}

/// Prepare one stateless Work Log activity component.
Expand Down
107 changes: 38 additions & 69 deletions crates/ui/src/chat/model.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -280,18 +280,6 @@ pub(crate) fn work_log_outcome(
}
}

pub(crate) fn manual_override_key(key: &str) -> String {
format!("manual-{key}")
}

pub(crate) fn auto_expanded(expanded: &HashSet<String>, key: &str, automatic: bool) -> bool {
if expanded.contains(&manual_override_key(key)) {
expanded.contains(key)
} else {
automatic
}
}

/// `text` collapsed to a single spaced line: every whitespace run (newlines
/// included) becomes one space, so a multi-line command shows its full content
/// in a one-line preview instead of just its first line. Clipped to more
Expand DownExpand Up@@ -558,33 +546,21 @@ pub(crate) fn live_edit_rows(changes: &[FileChange], cwd: &Path) -> Vec<LiveEdit
.collect()
}

/// Whether a Work Log segment opens on its own, before any user toggle.
///
/// Only the final segment of a running turn is live. As soon as later prose
/// starts, an earlier segment settles and folds; file evidence remains visible
/// in the separate changed-file chip row. Manual overrides still win via
/// [`auto_expanded`].
pub(crate) fn work_log_auto_expands(
_activities: &[&TimelineEntry],
turn_running: bool,
is_last: bool,
) -> bool {
turn_running && is_last
}

/// The activity entries a Work Log segment renders as rows.
///
/// An automatically expanded live run is a two-row ticker. Manual expansion
/// returns every activity in the segment, including file changes.
pub(crate) fn work_log_row_entries<'a>(
activities: &[&'a TimelineEntry],
automatic_expansion: bool,
) -> Vec<&'a TimelineEntry> {
if automatic_expansion {
activities[activities.len().saturating_sub(2)..].to_vec()
/// Maximum number of entries kept directly visible at the tail of a live
/// activity run. Older entries move into a separate collapsed Work Log; once
/// prose ends the run, the full run becomes that settled Work Log instead.
pub(crate) const LIVE_ACTIVITY_WINDOW: usize = 5;

pub(crate) fn partition_activity_run<'a>(
activities: &'a [&'a TimelineEntry],
live: bool,
) -> (&'a [&'a TimelineEntry], &'a [&'a TimelineEntry]) {
let visible = if live {
activities.len().min(LIVE_ACTIVITY_WINDOW)
} else {
activities.to_vec()
}
0
};
activities.split_at(activities.len() - visible)
}

/// Format a unix-ms timestamp as a local 12-hour clock, e.g. "2:39 AM".
Expand DownExpand Up@@ -1873,30 +1849,44 @@ mod tests {
}

#[test]
fn work_log_rows_use_a_ticker_only_for_automatic_expansion() {
fn live_activity_run_keeps_five_entries_outside_the_folded_prefix() {
let entries = [
command("cargo check"),
file_change("edit", &["src/foo.rs"]),
command("cargo test"),
command("cargo clippy"),
command("cargo fmt"),
command("cargo nextest"),
];
let activities = refs(&entries);
let ids = |rows: Vec<&TimelineEntry>| {
let ids = |rows: &[&TimelineEntry]| {
rows.iter()
.map(|entry| entry.id.clone())
.collect::<Vec<String>>()
};

let (folded, visible) = partition_activity_run(&activities, true);
assert_eq!(ids(folded), ["cargo check"]);
assert_eq!(
ids(work_log_row_entries(&activities, true)),
["cargo test", "cargo clippy"]
);
assert_eq!(
ids(work_log_row_entries(&activities, false)),
["cargo check", "edit", "cargo test", "cargo clippy"]
ids(visible),
[
"edit",
"cargo test",
"cargo clippy",
"cargo fmt",
"cargo nextest"
]
);
// Row selection never touches the summary counts.
assert_eq!(work_log_counts(&activities).files, 1);

let (folded, visible) = partition_activity_run(&activities[..5], true);
assert!(folded.is_empty());
assert_eq!(ids(visible), ids(&activities[..5]));

// Assistant prose settles the run: the same six entries are now all
// represented by one collapsed Work Log and none remain loose.
let (folded, visible) = partition_activity_run(&activities, false);
assert_eq!(ids(folded), ids(&activities));
assert!(visible.is_empty());
}

#[test]
Expand DownExpand Up@@ -1990,27 +1980,6 @@ mod tests {
);
}

#[test]
fn earlier_file_change_segments_settle_after_prose_starts() {
let file_edits = [command("cargo check"), file_change("edit", &["src/a.rs"])];
let ordinary = [command("cargo check"), command("cargo test")];
let file_edits = refs(&file_edits);
let ordinary = refs(&ordinary);

// Live, no longer the final segment: every run settles.
assert!(!work_log_auto_expands(&file_edits, true, false));
assert!(!work_log_auto_expands(&ordinary, true, false));

// The final live segment keeps opening on its own, as it always did.
assert!(work_log_auto_expands(&file_edits, true, true));
assert!(work_log_auto_expands(&ordinary, true, true));

// Finished turns force nothing open; CHANGED FILES takes over.
assert!(!work_log_auto_expands(&file_edits, false, true));
assert!(!work_log_auto_expands(&file_edits, false, false));
assert!(!work_log_auto_expands(&ordinary, false, true));
}

#[test]
fn finished_activity_runs_use_segment_scoped_counts() {
let _locale_guard = crate::settings::TestLocaleGuard::acquire();
Expand Down
14 changes: 8 additions & 6 deletions docs/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,15 +142,17 @@ platform pays that inset.
typographic step down from the 15px bubble to the muted activity summary.
- Turn activity = collapsible "Work Log" sections: an expanded section starts
with an 11px uppercase muted label, followed by activity rows (muted ✓ +
one-line summary; command/tool/subagent/reasoning); >2 rows → last 2 +
"+N previous log entries" expander. Once expanded, that row becomes "Hide N
previous log entries" with an upward chevron so the rows can be collapsed
again. A completed section's toggle summarizes only its real, nonzero events
one-line summary; command/tool/subagent/reasoning). While a turn is running,
the latest five activities remain directly visible. Once a sixth arrives,
only the older prefix is summarized by a collapsed Work Log row (with a
working spinner on its right); those five visible activities are excluded
from that row's counts. Assistant prose settles the run, folding every
activity in it under one summary row. A completed section's toggle summarizes
only its real, nonzero events
(commands, unique edited files, tool calls, subagents, and compactions); an
earlier section uses its own counts and the final section uses turn-wide
counts, prefixed once with Chinese “共” to make the aggregate scope explicit.
A zero-event summary is omitted. The active section stays expanded with "•••
Working for Ns" ticking.
A zero-event summary is omitted.
- Assistant markdown 15px, relaxed line-height, inline code chips (mono 13,
muted bg, 4px radius). Streaming appends via push_str with
follow-when-near-bottom.
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions crates/ui/src/chat/components/work_log.rs
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
use std::path::Path;

use crate::icon::{Icon, IconName};
use crate::sizing::Sizable as _;
use crate::theme::ActiveTheme as _;
use crate::widgets::spinner::Spinner;
use agent::TurnStatus;
use gpui::{
AnyElement, App, ClickEvent, InteractiveElement as _, IntoElement as _, ParentElement as _,
Expand DownExpand Up@@ -72,6 +74,9 @@ pub(crate) fn work_log(
.on_click(on_toggle)
.child(Icon::new(chevron(expanded)).size(px(12.)).text_color(muted))
.child(capsule_label)
.when(running, |row| {
row.child(Spinner::new().xsmall().color(cx.theme().primary))
})
.when(!running, |row| {
// A settled run needs no badge; only a bad ending is called out.
let failure = match outcome {
Expand Down
174 changes: 86 additions & 88 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,11 +48,10 @@ use self::components::assistant::MdState;
use self::components::command_panel::CommandPanelCache;
use self::model::{
ListSync, Segment, TurnIndexCache, TurnListItem, TurnRenderArgs, activity_run_duration_ms,
auto_expanded, displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, manual_override_key, plain_text_as_markdown,
displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, partition_activity_run, plain_text_as_markdown,
segment_entries, start_hub_projects, timeline_overdraw, user_content, user_visible_text,
work_log_auto_expands, work_log_capsule_label, work_log_counts, work_log_outcome,
work_log_row_entries,
work_log_capsule_label, work_log_counts, work_log_outcome,
};
use self::residency::{
MarkdownEntry, ResidencyInput, ResidencyScope, decide, tail_turn_window, viewport_turn_window,
Expand DownExpand Up@@ -615,33 +614,6 @@ impl ChatView {
cx.notify();
}

fn toggle_auto_expanded(
&mut self,
turn: usize,
key: &str,
automatic: bool,
cx: &mut Context<Self>,
) {
let was_expanded = auto_expanded(&self.expanded, key, automatic);
self.expanded.insert(manual_override_key(key));
if was_expanded {
self.expanded.remove(key);
} else {
self.expanded.insert(key.to_string());
}
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

fn promote_auto_expanded(&mut self, turn: usize, key: &str, cx: &mut Context<Self>) {
self.expanded.insert(manual_override_key(key));
self.expanded.insert(key.to_string());
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

// -- turn rendering -----------------------------------------------------

/// Render one turn as chronological messages, errors, and Work Log runs.
Expand DownExpand Up@@ -1055,69 +1027,95 @@ impl ChatView {
let (index, segment_id, turn, cwd, activities, is_last) = args;
let section_key = format!("worklog-{index}-{segment_id}");
let running = is_last && turn.running;
let automatic = work_log_auto_expands(activities, turn.running, is_last);
let expanded = auto_expanded(&self.expanded, &section_key, automatic);
let manually_expanded =
expanded && (self.expanded.contains(&manual_override_key(&section_key)) || !automatic);
let segment_counts = work_log_counts(activities);
let mut capsule_label = work_log_capsule_label(&segment_counts, activities.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
let (folded, visible) = partition_activity_run(activities, running);
let expanded = self.expanded.contains(&section_key);
let live_reasoning_id = running
.then(|| activities.last().copied())
.flatten()
.filter(|entry| {
matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
)
})
.map(|entry| entry.id.as_str());

let mut flow = v_flex().w_full().gap_1();
if !folded.is_empty() {
let segment_counts = work_log_counts(folded);
let mut capsule_label = work_log_capsule_label(&segment_counts, folded.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
}
let duration =
format_span((activity_run_duration_ms(folded, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, folded, is_last);
let rows = if expanded {
self.compose_work_log_rows(folded, cwd, live_reasoning_id, cx)
} else {
Vec::new()
};

let toggle_section_key = section_key;
flow = flow.child(components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
this.toggle_expanded(index, &toggle_section_key, cx);
}),
cx,
));
}
let duration =
format_span((activity_run_duration_ms(activities, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, activities, is_last);

if !visible.is_empty() {
flow = flow.child(
v_flex()
.w_full()
.gap_1()
.children(self.compose_work_log_rows(visible, cwd, live_reasoning_id, cx)),
);
}

flow.into_any_element()
}

fn compose_work_log_rows(
&mut self,
activities: &[&TimelineEntry],
cwd: &Path,
live_reasoning_id: Option<&str>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let mut rows = Vec::new();
if expanded {
let visible = work_log_row_entries(activities, !manually_expanded);

for entry in &visible {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content
{
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
let live_reasoning = running
&& activities.last().is_some_and(|last| last.id == entry.id)
&& matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
);
rows.push(self.compose_activity_row(entry, false, live_reasoning, cx));
for entry in activities {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content {
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
rows.push(self.compose_activity_row(
entry,
false,
live_reasoning_id == Some(entry.id.as_str()),
cx,
));
}
}

let toggle_section_key = section_key;
let ticker_expanded = expanded && !manually_expanded;
components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
if ticker_expanded {
this.promote_auto_expanded(index, &toggle_section_key, cx);
} else {
this.toggle_auto_expanded(index, &toggle_section_key, automatic, cx);
}
}),
cx,
)
rows
}

/// Prepare one stateless Work Log activity component.
Expand Down
107 changes: 38 additions & 69 deletions crates/ui/src/chat/model.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -280,18 +280,6 @@ pub(crate) fn work_log_outcome(
}
}

pub(crate) fn manual_override_key(key: &str) -> String {
format!("manual-{key}")
}

pub(crate) fn auto_expanded(expanded: &HashSet<String>, key: &str, automatic: bool) -> bool {
if expanded.contains(&manual_override_key(key)) {
expanded.contains(key)
} else {
automatic
}
}

/// `text` collapsed to a single spaced line: every whitespace run (newlines
/// included) becomes one space, so a multi-line command shows its full content
/// in a one-line preview instead of just its first line. Clipped to more
Expand DownExpand Up@@ -558,33 +546,21 @@ pub(crate) fn live_edit_rows(changes: &[FileChange], cwd: &Path) -> Vec<LiveEdit
.collect()
}

/// Whether a Work Log segment opens on its own, before any user toggle.
///
/// Only the final segment of a running turn is live. As soon as later prose
/// starts, an earlier segment settles and folds; file evidence remains visible
/// in the separate changed-file chip row. Manual overrides still win via
/// [`auto_expanded`].
pub(crate) fn work_log_auto_expands(
_activities: &[&TimelineEntry],
turn_running: bool,
is_last: bool,
) -> bool {
turn_running && is_last
}

/// The activity entries a Work Log segment renders as rows.
///
/// An automatically expanded live run is a two-row ticker. Manual expansion
/// returns every activity in the segment, including file changes.
pub(crate) fn work_log_row_entries<'a>(
activities: &[&'a TimelineEntry],
automatic_expansion: bool,
) -> Vec<&'a TimelineEntry> {
if automatic_expansion {
activities[activities.len().saturating_sub(2)..].to_vec()
/// Maximum number of entries kept directly visible at the tail of a live
/// activity run. Older entries move into a separate collapsed Work Log; once
/// prose ends the run, the full run becomes that settled Work Log instead.
pub(crate) const LIVE_ACTIVITY_WINDOW: usize = 5;

pub(crate) fn partition_activity_run<'a>(
activities: &'a [&'a TimelineEntry],
live: bool,
) -> (&'a [&'a TimelineEntry], &'a [&'a TimelineEntry]) {
let visible = if live {
activities.len().min(LIVE_ACTIVITY_WINDOW)
} else {
activities.to_vec()
}
0
};
activities.split_at(activities.len() - visible)
}

/// Format a unix-ms timestamp as a local 12-hour clock, e.g. "2:39 AM".
Expand DownExpand Up@@ -1873,30 +1849,44 @@ mod tests {
}

#[test]
fn work_log_rows_use_a_ticker_only_for_automatic_expansion() {
fn live_activity_run_keeps_five_entries_outside_the_folded_prefix() {
let entries = [
command("cargo check"),
file_change("edit", &["src/foo.rs"]),
command("cargo test"),
command("cargo clippy"),
command("cargo fmt"),
command("cargo nextest"),
];
let activities = refs(&entries);
let ids = |rows: Vec<&TimelineEntry>| {
let ids = |rows: &[&TimelineEntry]| {
rows.iter()
.map(|entry| entry.id.clone())
.collect::<Vec<String>>()
};

let (folded, visible) = partition_activity_run(&activities, true);
assert_eq!(ids(folded), ["cargo check"]);
assert_eq!(
ids(work_log_row_entries(&activities, true)),
["cargo test", "cargo clippy"]
);
assert_eq!(
ids(work_log_row_entries(&activities, false)),
["cargo check", "edit", "cargo test", "cargo clippy"]
ids(visible),
[
"edit",
"cargo test",
"cargo clippy",
"cargo fmt",
"cargo nextest"
]
);
// Row selection never touches the summary counts.
assert_eq!(work_log_counts(&activities).files, 1);

let (folded, visible) = partition_activity_run(&activities[..5], true);
assert!(folded.is_empty());
assert_eq!(ids(visible), ids(&activities[..5]));

// Assistant prose settles the run: the same six entries are now all
// represented by one collapsed Work Log and none remain loose.
let (folded, visible) = partition_activity_run(&activities, false);
assert_eq!(ids(folded), ids(&activities));
assert!(visible.is_empty());
}

#[test]
Expand DownExpand Up@@ -1990,27 +1980,6 @@ mod tests {
);
}

#[test]
fn earlier_file_change_segments_settle_after_prose_starts() {
let file_edits = [command("cargo check"), file_change("edit", &["src/a.rs"])];
let ordinary = [command("cargo check"), command("cargo test")];
let file_edits = refs(&file_edits);
let ordinary = refs(&ordinary);

// Live, no longer the final segment: every run settles.
assert!(!work_log_auto_expands(&file_edits, true, false));
assert!(!work_log_auto_expands(&ordinary, true, false));

// The final live segment keeps opening on its own, as it always did.
assert!(work_log_auto_expands(&file_edits, true, true));
assert!(work_log_auto_expands(&ordinary, true, true));

// Finished turns force nothing open; CHANGED FILES takes over.
assert!(!work_log_auto_expands(&file_edits, false, true));
assert!(!work_log_auto_expands(&file_edits, false, false));
assert!(!work_log_auto_expands(&ordinary, false, true));
}

#[test]
fn finished_activity_runs_use_segment_scoped_counts() {
let _locale_guard = crate::settings::TestLocaleGuard::acquire();
Expand Down
14 changes: 8 additions & 6 deletions docs/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,15 +142,17 @@ platform pays that inset.
typographic step down from the 15px bubble to the muted activity summary.
- Turn activity = collapsible "Work Log" sections: an expanded section starts
with an 11px uppercase muted label, followed by activity rows (muted ✓ +
one-line summary; command/tool/subagent/reasoning); >2 rows → last 2 +
"+N previous log entries" expander. Once expanded, that row becomes "Hide N
previous log entries" with an upward chevron so the rows can be collapsed
again. A completed section's toggle summarizes only its real, nonzero events
one-line summary; command/tool/subagent/reasoning). While a turn is running,
the latest five activities remain directly visible. Once a sixth arrives,
only the older prefix is summarized by a collapsed Work Log row (with a
working spinner on its right); those five visible activities are excluded
from that row's counts. Assistant prose settles the run, folding every
activity in it under one summary row. A completed section's toggle summarizes
only its real, nonzero events
(commands, unique edited files, tool calls, subagents, and compactions); an
earlier section uses its own counts and the final section uses turn-wide
counts, prefixed once with Chinese “共” to make the aggregate scope explicit.
A zero-event summary is omitted. The active section stays expanded with "•••
Working for Ns" ticking.
A zero-event summary is omitted.
- Assistant markdown 15px, relaxed line-height, inline code chips (mono 13,
muted bg, 4px radius). Streaming appends via push_str with
follow-when-near-bottom.
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions crates/ui/src/chat/components/work_log.rs
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
use std::path::Path;

use crate::icon::{Icon, IconName};
use crate::sizing::Sizable as _;
use crate::theme::ActiveTheme as _;
use crate::widgets::spinner::Spinner;
use agent::TurnStatus;
use gpui::{
AnyElement, App, ClickEvent, InteractiveElement as _, IntoElement as _, ParentElement as _,
Expand DownExpand Up@@ -72,6 +74,9 @@ pub(crate) fn work_log(
.on_click(on_toggle)
.child(Icon::new(chevron(expanded)).size(px(12.)).text_color(muted))
.child(capsule_label)
.when(running, |row| {
row.child(Spinner::new().xsmall().color(cx.theme().primary))
})
.when(!running, |row| {
// A settled run needs no badge; only a bad ending is called out.
let failure = match outcome {
Expand Down
174 changes: 86 additions & 88 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,11 +48,10 @@ use self::components::assistant::MdState;
use self::components::command_panel::CommandPanelCache;
use self::model::{
ListSync, Segment, TurnIndexCache, TurnListItem, TurnRenderArgs, activity_run_duration_ms,
auto_expanded, displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, manual_override_key, plain_text_as_markdown,
displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, partition_activity_run, plain_text_as_markdown,
segment_entries, start_hub_projects, timeline_overdraw, user_content, user_visible_text,
work_log_auto_expands, work_log_capsule_label, work_log_counts, work_log_outcome,
work_log_row_entries,
work_log_capsule_label, work_log_counts, work_log_outcome,
};
use self::residency::{
MarkdownEntry, ResidencyInput, ResidencyScope, decide, tail_turn_window, viewport_turn_window,
Expand DownExpand Up@@ -615,33 +614,6 @@ impl ChatView {
cx.notify();
}

fn toggle_auto_expanded(
&mut self,
turn: usize,
key: &str,
automatic: bool,
cx: &mut Context<Self>,
) {
let was_expanded = auto_expanded(&self.expanded, key, automatic);
self.expanded.insert(manual_override_key(key));
if was_expanded {
self.expanded.remove(key);
} else {
self.expanded.insert(key.to_string());
}
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

fn promote_auto_expanded(&mut self, turn: usize, key: &str, cx: &mut Context<Self>) {
self.expanded.insert(manual_override_key(key));
self.expanded.insert(key.to_string());
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

// -- turn rendering -----------------------------------------------------

/// Render one turn as chronological messages, errors, and Work Log runs.
Expand DownExpand Up@@ -1055,69 +1027,95 @@ impl ChatView {
let (index, segment_id, turn, cwd, activities, is_last) = args;
let section_key = format!("worklog-{index}-{segment_id}");
let running = is_last && turn.running;
let automatic = work_log_auto_expands(activities, turn.running, is_last);
let expanded = auto_expanded(&self.expanded, &section_key, automatic);
let manually_expanded =
expanded && (self.expanded.contains(&manual_override_key(&section_key)) || !automatic);
let segment_counts = work_log_counts(activities);
let mut capsule_label = work_log_capsule_label(&segment_counts, activities.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
let (folded, visible) = partition_activity_run(activities, running);
let expanded = self.expanded.contains(&section_key);
let live_reasoning_id = running
.then(|| activities.last().copied())
.flatten()
.filter(|entry| {
matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
)
})
.map(|entry| entry.id.as_str());

let mut flow = v_flex().w_full().gap_1();
if !folded.is_empty() {
let segment_counts = work_log_counts(folded);
let mut capsule_label = work_log_capsule_label(&segment_counts, folded.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
}
let duration =
format_span((activity_run_duration_ms(folded, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, folded, is_last);
let rows = if expanded {
self.compose_work_log_rows(folded, cwd, live_reasoning_id, cx)
} else {
Vec::new()
};

let toggle_section_key = section_key;
flow = flow.child(components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
this.toggle_expanded(index, &toggle_section_key, cx);
}),
cx,
));
}
let duration =
format_span((activity_run_duration_ms(activities, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, activities, is_last);

if !visible.is_empty() {
flow = flow.child(
v_flex()
.w_full()
.gap_1()
.children(self.compose_work_log_rows(visible, cwd, live_reasoning_id, cx)),
);
}

flow.into_any_element()
}

fn compose_work_log_rows(
&mut self,
activities: &[&TimelineEntry],
cwd: &Path,
live_reasoning_id: Option<&str>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let mut rows = Vec::new();
if expanded {
let visible = work_log_row_entries(activities, !manually_expanded);

for entry in &visible {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content
{
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
let live_reasoning = running
&& activities.last().is_some_and(|last| last.id == entry.id)
&& matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
);
rows.push(self.compose_activity_row(entry, false, live_reasoning, cx));
for entry in activities {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content {
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
rows.push(self.compose_activity_row(
entry,
false,
live_reasoning_id == Some(entry.id.as_str()),
cx,
));
}
}

let toggle_section_key = section_key;
let ticker_expanded = expanded && !manually_expanded;
components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
if ticker_expanded {
this.promote_auto_expanded(index, &toggle_section_key, cx);
} else {
this.toggle_auto_expanded(index, &toggle_section_key, automatic, cx);
}
}),
cx,
)
rows
}

/// Prepare one stateless Work Log activity component.
Expand Down
107 changes: 38 additions & 69 deletions crates/ui/src/chat/model.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -280,18 +280,6 @@ pub(crate) fn work_log_outcome(
}
}

pub(crate) fn manual_override_key(key: &str) -> String {
format!("manual-{key}")
}

pub(crate) fn auto_expanded(expanded: &HashSet<String>, key: &str, automatic: bool) -> bool {
if expanded.contains(&manual_override_key(key)) {
expanded.contains(key)
} else {
automatic
}
}

/// `text` collapsed to a single spaced line: every whitespace run (newlines
/// included) becomes one space, so a multi-line command shows its full content
/// in a one-line preview instead of just its first line. Clipped to more
Expand DownExpand Up@@ -558,33 +546,21 @@ pub(crate) fn live_edit_rows(changes: &[FileChange], cwd: &Path) -> Vec<LiveEdit
.collect()
}

/// Whether a Work Log segment opens on its own, before any user toggle.
///
/// Only the final segment of a running turn is live. As soon as later prose
/// starts, an earlier segment settles and folds; file evidence remains visible
/// in the separate changed-file chip row. Manual overrides still win via
/// [`auto_expanded`].
pub(crate) fn work_log_auto_expands(
_activities: &[&TimelineEntry],
turn_running: bool,
is_last: bool,
) -> bool {
turn_running && is_last
}

/// The activity entries a Work Log segment renders as rows.
///
/// An automatically expanded live run is a two-row ticker. Manual expansion
/// returns every activity in the segment, including file changes.
pub(crate) fn work_log_row_entries<'a>(
activities: &[&'a TimelineEntry],
automatic_expansion: bool,
) -> Vec<&'a TimelineEntry> {
if automatic_expansion {
activities[activities.len().saturating_sub(2)..].to_vec()
/// Maximum number of entries kept directly visible at the tail of a live
/// activity run. Older entries move into a separate collapsed Work Log; once
/// prose ends the run, the full run becomes that settled Work Log instead.
pub(crate) const LIVE_ACTIVITY_WINDOW: usize = 5;

pub(crate) fn partition_activity_run<'a>(
activities: &'a [&'a TimelineEntry],
live: bool,
) -> (&'a [&'a TimelineEntry], &'a [&'a TimelineEntry]) {
let visible = if live {
activities.len().min(LIVE_ACTIVITY_WINDOW)
} else {
activities.to_vec()
}
0
};
activities.split_at(activities.len() - visible)
}

/// Format a unix-ms timestamp as a local 12-hour clock, e.g. "2:39 AM".
Expand DownExpand Up@@ -1873,30 +1849,44 @@ mod tests {
}

#[test]
fn work_log_rows_use_a_ticker_only_for_automatic_expansion() {
fn live_activity_run_keeps_five_entries_outside_the_folded_prefix() {
let entries = [
command("cargo check"),
file_change("edit", &["src/foo.rs"]),
command("cargo test"),
command("cargo clippy"),
command("cargo fmt"),
command("cargo nextest"),
];
let activities = refs(&entries);
let ids = |rows: Vec<&TimelineEntry>| {
let ids = |rows: &[&TimelineEntry]| {
rows.iter()
.map(|entry| entry.id.clone())
.collect::<Vec<String>>()
};

let (folded, visible) = partition_activity_run(&activities, true);
assert_eq!(ids(folded), ["cargo check"]);
assert_eq!(
ids(work_log_row_entries(&activities, true)),
["cargo test", "cargo clippy"]
);
assert_eq!(
ids(work_log_row_entries(&activities, false)),
["cargo check", "edit", "cargo test", "cargo clippy"]
ids(visible),
[
"edit",
"cargo test",
"cargo clippy",
"cargo fmt",
"cargo nextest"
]
);
// Row selection never touches the summary counts.
assert_eq!(work_log_counts(&activities).files, 1);

let (folded, visible) = partition_activity_run(&activities[..5], true);
assert!(folded.is_empty());
assert_eq!(ids(visible), ids(&activities[..5]));

// Assistant prose settles the run: the same six entries are now all
// represented by one collapsed Work Log and none remain loose.
let (folded, visible) = partition_activity_run(&activities, false);
assert_eq!(ids(folded), ids(&activities));
assert!(visible.is_empty());
}

#[test]
Expand DownExpand Up@@ -1990,27 +1980,6 @@ mod tests {
);
}

#[test]
fn earlier_file_change_segments_settle_after_prose_starts() {
let file_edits = [command("cargo check"), file_change("edit", &["src/a.rs"])];
let ordinary = [command("cargo check"), command("cargo test")];
let file_edits = refs(&file_edits);
let ordinary = refs(&ordinary);

// Live, no longer the final segment: every run settles.
assert!(!work_log_auto_expands(&file_edits, true, false));
assert!(!work_log_auto_expands(&ordinary, true, false));

// The final live segment keeps opening on its own, as it always did.
assert!(work_log_auto_expands(&file_edits, true, true));
assert!(work_log_auto_expands(&ordinary, true, true));

// Finished turns force nothing open; CHANGED FILES takes over.
assert!(!work_log_auto_expands(&file_edits, false, true));
assert!(!work_log_auto_expands(&file_edits, false, false));
assert!(!work_log_auto_expands(&ordinary, false, true));
}

#[test]
fn finished_activity_runs_use_segment_scoped_counts() {
let _locale_guard = crate::settings::TestLocaleGuard::acquire();
Expand Down
14 changes: 8 additions & 6 deletions docs/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,15 +142,17 @@ platform pays that inset.
typographic step down from the 15px bubble to the muted activity summary.
- Turn activity = collapsible "Work Log" sections: an expanded section starts
with an 11px uppercase muted label, followed by activity rows (muted ✓ +
one-line summary; command/tool/subagent/reasoning); >2 rows → last 2 +
"+N previous log entries" expander. Once expanded, that row becomes "Hide N
previous log entries" with an upward chevron so the rows can be collapsed
again. A completed section's toggle summarizes only its real, nonzero events
one-line summary; command/tool/subagent/reasoning). While a turn is running,
the latest five activities remain directly visible. Once a sixth arrives,
only the older prefix is summarized by a collapsed Work Log row (with a
working spinner on its right); those five visible activities are excluded
from that row's counts. Assistant prose settles the run, folding every
activity in it under one summary row. A completed section's toggle summarizes
only its real, nonzero events
(commands, unique edited files, tool calls, subagents, and compactions); an
earlier section uses its own counts and the final section uses turn-wide
counts, prefixed once with Chinese “共” to make the aggregate scope explicit.
A zero-event summary is omitted. The active section stays expanded with "•••
Working for Ns" ticking.
A zero-event summary is omitted.
- Assistant markdown 15px, relaxed line-height, inline code chips (mono 13,
muted bg, 4px radius). Streaming appends via push_str with
follow-when-near-bottom.
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions crates/ui/src/chat/components/work_log.rs
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
use std::path::Path;

use crate::icon::{Icon, IconName};
use crate::sizing::Sizable as _;
use crate::theme::ActiveTheme as _;
use crate::widgets::spinner::Spinner;
use agent::TurnStatus;
use gpui::{
AnyElement, App, ClickEvent, InteractiveElement as _, IntoElement as _, ParentElement as _,
Expand DownExpand Up@@ -72,6 +74,9 @@ pub(crate) fn work_log(
.on_click(on_toggle)
.child(Icon::new(chevron(expanded)).size(px(12.)).text_color(muted))
.child(capsule_label)
.when(running, |row| {
row.child(Spinner::new().xsmall().color(cx.theme().primary))
})
.when(!running, |row| {
// A settled run needs no badge; only a bad ending is called out.
let failure = match outcome {
Expand Down
174 changes: 86 additions & 88 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,11 +48,10 @@ use self::components::assistant::MdState;
use self::components::command_panel::CommandPanelCache;
use self::model::{
ListSync, Segment, TurnIndexCache, TurnListItem, TurnRenderArgs, activity_run_duration_ms,
auto_expanded, displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, manual_override_key, plain_text_as_markdown,
displayed_error_text, divergent_served_model, format_span, latest_message_ids,
live_activity_segment, live_edit_rows, partition_activity_run, plain_text_as_markdown,
segment_entries, start_hub_projects, timeline_overdraw, user_content, user_visible_text,
work_log_auto_expands, work_log_capsule_label, work_log_counts, work_log_outcome,
work_log_row_entries,
work_log_capsule_label, work_log_counts, work_log_outcome,
};
use self::residency::{
MarkdownEntry, ResidencyInput, ResidencyScope, decide, tail_turn_window, viewport_turn_window,
Expand DownExpand Up@@ -615,33 +614,6 @@ impl ChatView {
cx.notify();
}

fn toggle_auto_expanded(
&mut self,
turn: usize,
key: &str,
automatic: bool,
cx: &mut Context<Self>,
) {
let was_expanded = auto_expanded(&self.expanded, key, automatic);
self.expanded.insert(manual_override_key(key));
if was_expanded {
self.expanded.remove(key);
} else {
self.expanded.insert(key.to_string());
}
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

fn promote_auto_expanded(&mut self, turn: usize, key: &str, cx: &mut Context<Self>) {
self.expanded.insert(manual_override_key(key));
self.expanded.insert(key.to_string());
self.sync_markdown_states(cx);
self.list_state.remeasure_items(turn..turn + 1);
cx.notify();
}

// -- turn rendering -----------------------------------------------------

/// Render one turn as chronological messages, errors, and Work Log runs.
Expand DownExpand Up@@ -1055,69 +1027,95 @@ impl ChatView {
let (index, segment_id, turn, cwd, activities, is_last) = args;
let section_key = format!("worklog-{index}-{segment_id}");
let running = is_last && turn.running;
let automatic = work_log_auto_expands(activities, turn.running, is_last);
let expanded = auto_expanded(&self.expanded, &section_key, automatic);
let manually_expanded =
expanded && (self.expanded.contains(&manual_override_key(&section_key)) || !automatic);
let segment_counts = work_log_counts(activities);
let mut capsule_label = work_log_capsule_label(&segment_counts, activities.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
let (folded, visible) = partition_activity_run(activities, running);
let expanded = self.expanded.contains(&section_key);
let live_reasoning_id = running
.then(|| activities.last().copied())
.flatten()
.filter(|entry| {
matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
)
})
.map(|entry| entry.id.as_str());

let mut flow = v_flex().w_full().gap_1();
if !folded.is_empty() {
let segment_counts = work_log_counts(folded);
let mut capsule_label = work_log_capsule_label(&segment_counts, folded.len());
if capsule_label.is_empty() {
capsule_label = crate::tr!("chat.work_log").into_owned();
}
let duration =
format_span((activity_run_duration_ms(folded, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, folded, is_last);
let rows = if expanded {
self.compose_work_log_rows(folded, cwd, live_reasoning_id, cx)
} else {
Vec::new()
};

let toggle_section_key = section_key;
flow = flow.child(components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
this.toggle_expanded(index, &toggle_section_key, cx);
}),
cx,
));
}
let duration =
format_span((activity_run_duration_ms(activities, turn, is_last) + 500) / 1000);
let outcome = work_log_outcome(turn, activities, is_last);

if !visible.is_empty() {
flow = flow.child(
v_flex()
.w_full()
.gap_1()
.children(self.compose_work_log_rows(visible, cwd, live_reasoning_id, cx)),
);
}

flow.into_any_element()
}

fn compose_work_log_rows(
&mut self,
activities: &[&TimelineEntry],
cwd: &Path,
live_reasoning_id: Option<&str>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let mut rows = Vec::new();
if expanded {
let visible = work_log_row_entries(activities, !manually_expanded);

for entry in &visible {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content
{
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
let live_reasoning = running
&& activities.last().is_some_and(|last| last.id == entry.id)
&& matches!(
entry.content,
EntryContent::Item(ItemContent::Reasoning { .. })
);
rows.push(self.compose_activity_row(entry, false, live_reasoning, cx));
for entry in activities {
if let EntryContent::Item(ItemContent::FileChange { changes, .. }) = &entry.content {
for row in live_edit_rows(changes, cwd) {
rows.push(
components::changed_files::file_edit_row(
&row,
&components::changed_files::FileEditRowStyle::from_theme(cx),
)
.into_any_element(),
);
}
} else {
rows.push(self.compose_activity_row(
entry,
false,
live_reasoning_id == Some(entry.id.as_str()),
cx,
));
}
}

let toggle_section_key = section_key;
let ticker_expanded = expanded && !manually_expanded;
components::work_log::work_log(
components::work_log::WorkLogData {
index,
segment_id: segment_id.to_string(),
capsule_label,
duration,
outcome,
expanded,
running,
rows,
},
cx.listener(move |this, _, _, cx| {
if ticker_expanded {
this.promote_auto_expanded(index, &toggle_section_key, cx);
} else {
this.toggle_auto_expanded(index, &toggle_section_key, automatic, cx);
}
}),
cx,
)
rows
}

/// Prepare one stateless Work Log activity component.
Expand Down
107 changes: 38 additions & 69 deletions crates/ui/src/chat/model.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -280,18 +280,6 @@ pub(crate) fn work_log_outcome(
}
}

pub(crate) fn manual_override_key(key: &str) -> String {
format!("manual-{key}")
}

pub(crate) fn auto_expanded(expanded: &HashSet<String>, key: &str, automatic: bool) -> bool {
if expanded.contains(&manual_override_key(key)) {
expanded.contains(key)
} else {
automatic
}
}

/// `text` collapsed to a single spaced line: every whitespace run (newlines
/// included) becomes one space, so a multi-line command shows its full content
/// in a one-line preview instead of just its first line. Clipped to more
Expand DownExpand Up@@ -558,33 +546,21 @@ pub(crate) fn live_edit_rows(changes: &[FileChange], cwd: &Path) -> Vec<LiveEdit
.collect()
}

/// Whether a Work Log segment opens on its own, before any user toggle.
///
/// Only the final segment of a running turn is live. As soon as later prose
/// starts, an earlier segment settles and folds; file evidence remains visible
/// in the separate changed-file chip row. Manual overrides still win via
/// [`auto_expanded`].
pub(crate) fn work_log_auto_expands(
_activities: &[&TimelineEntry],
turn_running: bool,
is_last: bool,
) -> bool {
turn_running && is_last
}

/// The activity entries a Work Log segment renders as rows.
///
/// An automatically expanded live run is a two-row ticker. Manual expansion
/// returns every activity in the segment, including file changes.
pub(crate) fn work_log_row_entries<'a>(
activities: &[&'a TimelineEntry],
automatic_expansion: bool,
) -> Vec<&'a TimelineEntry> {
if automatic_expansion {
activities[activities.len().saturating_sub(2)..].to_vec()
/// Maximum number of entries kept directly visible at the tail of a live
/// activity run. Older entries move into a separate collapsed Work Log; once
/// prose ends the run, the full run becomes that settled Work Log instead.
pub(crate) const LIVE_ACTIVITY_WINDOW: usize = 5;

pub(crate) fn partition_activity_run<'a>(
activities: &'a [&'a TimelineEntry],
live: bool,
) -> (&'a [&'a TimelineEntry], &'a [&'a TimelineEntry]) {
let visible = if live {
activities.len().min(LIVE_ACTIVITY_WINDOW)
} else {
activities.to_vec()
}
0
};
activities.split_at(activities.len() - visible)
}

/// Format a unix-ms timestamp as a local 12-hour clock, e.g. "2:39 AM".
Expand DownExpand Up@@ -1873,30 +1849,44 @@ mod tests {
}

#[test]
fn work_log_rows_use_a_ticker_only_for_automatic_expansion() {
fn live_activity_run_keeps_five_entries_outside_the_folded_prefix() {
let entries = [
command("cargo check"),
file_change("edit", &["src/foo.rs"]),
command("cargo test"),
command("cargo clippy"),
command("cargo fmt"),
command("cargo nextest"),
];
let activities = refs(&entries);
let ids = |rows: Vec<&TimelineEntry>| {
let ids = |rows: &[&TimelineEntry]| {
rows.iter()
.map(|entry| entry.id.clone())
.collect::<Vec<String>>()
};

let (folded, visible) = partition_activity_run(&activities, true);
assert_eq!(ids(folded), ["cargo check"]);
assert_eq!(
ids(work_log_row_entries(&activities, true)),
["cargo test", "cargo clippy"]
);
assert_eq!(
ids(work_log_row_entries(&activities, false)),
["cargo check", "edit", "cargo test", "cargo clippy"]
ids(visible),
[
"edit",
"cargo test",
"cargo clippy",
"cargo fmt",
"cargo nextest"
]
);
// Row selection never touches the summary counts.
assert_eq!(work_log_counts(&activities).files, 1);

let (folded, visible) = partition_activity_run(&activities[..5], true);
assert!(folded.is_empty());
assert_eq!(ids(visible), ids(&activities[..5]));

// Assistant prose settles the run: the same six entries are now all
// represented by one collapsed Work Log and none remain loose.
let (folded, visible) = partition_activity_run(&activities, false);
assert_eq!(ids(folded), ids(&activities));
assert!(visible.is_empty());
}

#[test]
Expand DownExpand Up@@ -1990,27 +1980,6 @@ mod tests {
);
}

#[test]
fn earlier_file_change_segments_settle_after_prose_starts() {
let file_edits = [command("cargo check"), file_change("edit", &["src/a.rs"])];
let ordinary = [command("cargo check"), command("cargo test")];
let file_edits = refs(&file_edits);
let ordinary = refs(&ordinary);

// Live, no longer the final segment: every run settles.
assert!(!work_log_auto_expands(&file_edits, true, false));
assert!(!work_log_auto_expands(&ordinary, true, false));

// The final live segment keeps opening on its own, as it always did.
assert!(work_log_auto_expands(&file_edits, true, true));
assert!(work_log_auto_expands(&ordinary, true, true));

// Finished turns force nothing open; CHANGED FILES takes over.
assert!(!work_log_auto_expands(&file_edits, false, true));
assert!(!work_log_auto_expands(&file_edits, false, false));
assert!(!work_log_auto_expands(&ordinary, false, true));
}

#[test]
fn finished_activity_runs_use_segment_scoped_counts() {
let _locale_guard = crate::settings::TestLocaleGuard::acquire();
Expand Down
14 changes: 8 additions & 6 deletions docs/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,15 +142,17 @@ platform pays that inset.
typographic step down from the 15px bubble to the muted activity summary.
- Turn activity = collapsible "Work Log" sections: an expanded section starts
with an 11px uppercase muted label, followed by activity rows (muted ✓ +
one-line summary; command/tool/subagent/reasoning); >2 rows → last 2 +
"+N previous log entries" expander. Once expanded, that row becomes "Hide N
previous log entries" with an upward chevron so the rows can be collapsed
again. A completed section's toggle summarizes only its real, nonzero events
one-line summary; command/tool/subagent/reasoning). While a turn is running,
the latest five activities remain directly visible. Once a sixth arrives,
only the older prefix is summarized by a collapsed Work Log row (with a
working spinner on its right); those five visible activities are excluded
from that row's counts. Assistant prose settles the run, folding every
activity in it under one summary row. A completed section's toggle summarizes
only its real, nonzero events
(commands, unique edited files, tool calls, subagents, and compactions); an
earlier section uses its own counts and the final section uses turn-wide
counts, prefixed once with Chinese “共” to make the aggregate scope explicit.
A zero-event summary is omitted. The active section stays expanded with "•••
Working for Ns" ticking.
A zero-event summary is omitted.
- Assistant markdown 15px, relaxed line-height, inline code chips (mono 13,
muted bg, 4px radius). Streaming appends via push_str with
follow-when-near-bottom.
Expand Down