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
255 changes: 85 additions & 170 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};

pub(crate) mod components;
mod model;
mod residency;

use crate::overlay::{Notification, OverlayExt as _};
use crate::theme::ActiveTheme as _;
Expand DownExpand Up@@ -51,6 +52,9 @@ use self::model::{
user_visible_text, work_log_auto_expands, work_log_capsule_label, work_log_counts,
work_log_outcome, work_log_row_entries,
};
use self::residency::{
MarkdownEntry, ResidencyInput, decide, tail_turn_window, viewport_turn_window,
};
pub(crate) use crate::material::{
CHAT_CONTENT_MAX_WIDTH as CONTENT_MAX_WIDTH, CHAT_CONTENT_MIN_PADDING as CONTENT_MIN_PADDING,
};
Expand All@@ -61,18 +65,6 @@ const TRAFFIC_LIGHT_INSET: f32 = 80.;
/// Vertical rhythm between turns. Turns are separated by space and typographic
/// hierarchy alone — there is deliberately no rule/divider under the user bubble.
const TURN_GAP: f32 = 32.;
/// Before GPUI reports its exact visible range, eight turns is comfortably
/// more than a typical chat viewport. Its list also pre-measures four viewport
/// heights, so the wider eviction band keeps warm rows resident without tying
/// Markdown lifetime to measurement.
const MARKDOWN_VIEWPORT_TURN_HINT: usize = 8;
const MARKDOWN_BUILD_MARGIN_TURNS: usize = 8;
/// Three build margins prevent back-and-forth scrolling from rebuilding the
/// same parsed documents at the edge of the warm window.
const MARKDOWN_EVICT_MARGIN_TURNS: usize = 24;
/// The composer-adjacent tail stays ready even while inspecting old turns.
const MARKDOWN_TAIL_PIN_TURNS: usize = 2;

pub struct ChatView {
workspace_store: Entity<WorkspaceStore>,
window_state: Entity<WindowState>,
Expand DownExpand Up@@ -228,8 +220,7 @@ impl ChatView {
offset_in_item: px(0.),
});
self.highlighted_turn = Some(turn);
self.markdown_visible_turns =
turn..(turn + MARKDOWN_VIEWPORT_TURN_HINT).min(self.turn_items.len());
self.markdown_visible_turns = viewport_turn_window(turn, self.turn_items.len());
self.markdown_scroll_top = Some(turn);
if let Some(session_id) = self.session_key.as_deref() {
self.workspace_store.update(cx, |store, _cx| {
Expand All@@ -238,7 +229,7 @@ impl ChatView {
}
}

self.sync_markdown_residency(cx);
self.sync_markdown_residency(requested_turn, cx);

// Keep a 100ms ticker alive while a turn runs so the live elapsed timer
// advances at decisecond precision; dropping it cancels the task.
Expand All@@ -256,97 +247,103 @@ impl ChatView {
}
}

fn sync_markdown_residency(&mut self, cx: &mut Context<Self>) {
fn sync_markdown_residency(
&mut self,
one_shot_turn_target: Option<usize>,
cx: &mut Context<Self>,
) {
let turn_count = self.turn_items.len();
let build_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_BUILD_MARGIN_TURNS,
turn_count,
);
let keep_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_EVICT_MARGIN_TURNS,
turn_count,
);
let tail_start = turn_count.saturating_sub(MARKDOWN_TAIL_PIN_TURNS);
let (texts, keep_ids) = self
// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let mut selection_drag_active = false;
let mut selection_participants = HashSet::new();
for (id, md) in &self.md_states {
let state = md.state.read(cx);
let selection = state.selection_handle();
let snapshot = selection.snapshot(cx);
selection_drag_active |= snapshot
.as_ref()
.is_some_and(|selection| selection.is_selecting());
if snapshot.is_some() || selection.has_local_selection(cx) {
selection_participants.insert(id.clone());
}
}
let resident_ids = self.md_states.keys().cloned().collect();
let (texts, decisions) = self
.workspace_store
.read(cx)
.with_active_timeline(|timeline| {
let pinned = |turn: usize| {
(turn_count > 0 && turn >= tail_start)
|| timeline.turns.get(turn).is_some_and(|turn| turn.running)
|| (timeline.turn_running
&& turn_count.checked_sub(1).is_some_and(|last| turn == last))
};
let mut entries = Vec::new();
for entry in &timeline.entries {
let markdown_bearing = matches!(
entry.content,
EntryContent::Item(ItemContent::AssistantMessage { .. })
| EntryContent::Item(ItemContent::Reasoning { .. })
) || user_content(&entry.content).is_some();
if markdown_bearing {
entries.push(MarkdownEntry {
id: entry.id.clone(),
turn: entry.turn,
turn_running: timeline
.turns
.get(entry.turn)
.is_some_and(|turn| turn.running),
});
}
}
if let Some(plan) = &timeline.proposed_plan {
entries.push(MarkdownEntry {
id: format!("plan:{}", plan.item_id),
turn: plan.turn,
turn_running: timeline
.turns
.get(plan.turn)
.is_some_and(|turn| turn.running),
});
}
let decisions = decide(ResidencyInput {
turn_count,
visible_turns: self.markdown_visible_turns.clone(),
one_shot_turn_target,
entries: &entries,
stream_running: timeline.turn_running,
resident_ids: &resident_ids,
selection_participants: &selection_participants,
selection_drag_active,
});
let mut texts = Vec::new();
let mut keep_ids = HashSet::new();
for entry in &timeline.entries {
let keep = keep_turns.contains(&entry.turn) || pinned(entry.turn);
let build = build_turns.contains(&entry.turn) || pinned(entry.turn);
if !keep && !build {
if !decisions.build.contains(&entry.id) {
continue;
}
match &entry.content {
EntryContent::Item(ItemContent::AssistantMessage { text })
| EntryContent::Item(ItemContent::Reasoning { text }) => {
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
content => {
let Some((text, _, context_len, _)) = user_content(content) else {
continue;
};
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
}
}
if let Some(plan) = &timeline.proposed_plan {
let id = format!("plan:{}", plan.item_id);
if keep_turns.contains(&plan.turn) || pinned(plan.turn) {
keep_ids.insert(id.clone());
}
if build_turns.contains(&plan.turn) || pinned(plan.turn) {
if decisions.build.contains(&id) {
texts.push((plan.turn, id, plan.markdown.clone()));
}
}
(texts, keep_ids)
(texts, decisions)
})
.unwrap_or_default();

// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let selection_drag_active = self.md_states.values().any(|md| {
md.state
.read(cx)
.selection_handle()
.snapshot(cx)
.is_some_and(|selection| selection.is_selecting())
});
if !selection_drag_active {
self.md_states.retain(|id, md| {
if keep_ids.contains(id) {
return true;
}
let state = md.state.read(cx);
let selection = state.selection_handle();
selection.snapshot(cx).is_some() || selection.has_local_selection(cx)
});
}
self.md_states.retain(|id, _| !decisions.evict.contains(id));

let mut rebuilt_turns = HashSet::new();
for (turn, id, text) in texts {
Expand DownExpand Up@@ -389,7 +386,7 @@ impl ChatView {
return;
}
self.markdown_visible_turns = visible_turns;
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
cx.notify();
}

Expand All@@ -403,9 +400,9 @@ impl ChatView {
self.markdown_visible_turns = if scroll_top == turn_count {
tail_turn_window(turn_count)
} else {
scroll_top..(scroll_top + MARKDOWN_VIEWPORT_TURN_HINT).min(turn_count)
viewport_turn_window(scroll_top, turn_count)
};
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
}

#[cfg(test)]
Expand DownExpand Up@@ -1873,19 +1870,6 @@ impl Render for ChatView {
// Helpers
// ---------------------------------------------------------------------------

fn tail_turn_window(turn_count: usize) -> Range<usize> {
turn_count.saturating_sub(MARKDOWN_VIEWPORT_TURN_HINT)..turn_count
}

fn expand_turn_window(window: Range<usize>, margin: usize, turn_count: usize) -> Range<usize> {
window.start.min(turn_count).saturating_sub(margin)
..window
.end
.min(turn_count)
.saturating_add(margin)
.min(turn_count)
}

/// Launch `zed <cwd>` detached; surface a notification if the CLI is missing.
/// The leading icon for a git quick-action.
fn git_action_icon(action: GitAction) -> Icon {
Expand DownExpand Up@@ -1958,29 +1942,7 @@ mod tests {
static NEXT_RESIDENCY_TEST_ID: AtomicU64 = AtomicU64::new(0);

#[gpui::test]
fn long_session_keeps_tail_markdown_residency_bounded(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, _) = seed_chat(cx, timeline);
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});

let resident = view.read_with(cx, |chat, _| chat.resident_markdown_state_count());
assert_eq!(resident, 48);
assert!(
resident <= 96,
"tail window retained {resident} MarkdownStates; expected at most 96"
);
}

#[gpui::test]
fn scrolling_to_old_turn_rebuilds_markdown_and_evicts_distant_tail(cx: &mut TestAppContext) {
fn chat_view_applies_markdown_residency_decisions(cx: &mut TestAppContext) {
use gpui::{FollowMode, ListOffset, VisualTestContext, px, size};

const TARGET: usize = 40;
Expand All@@ -1993,6 +1955,10 @@ mod tests {
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert_eq!(
view.read_with(cx, |chat, _| chat.resident_markdown_state_count()),
48
);
let list_state = view.read_with(cx, |chat, _| chat.list_state.clone());
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-40")
Expand DownExpand Up@@ -2044,57 +2010,6 @@ mod tests {
);
}

#[gpui::test]
fn turn_target_jump_rebuilds_an_evicted_region(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

const TARGET: usize = 20;
let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, session_id) = seed_chat(cx, timeline);
let target_store = workspace_store.clone();
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-20")
}));

target_store.update(cx, |store, cx| {
store.select_session_at_turn(session_id.clone(), TARGET);
cx.notify();
});
cx.run_until_parked();

let (highlighted, resident, target_resident, distant_tail_evicted, scroll_top) = view
.read_with(cx, |chat, _| {
(
chat.highlighted_turn,
chat.resident_markdown_state_count(),
chat.has_resident_markdown_state("assistant-20"),
!chat.has_resident_markdown_state("assistant-230"),
chat.list_state.logical_scroll_top(),
)
});
assert_eq!(highlighted, Some(TARGET));
assert_eq!(resident, 78);
assert!(target_resident, "the targeted turn was not rebuilt");
assert!(
distant_tail_evicted,
"the target jump retained a tail state beyond the hysteresis band"
);
assert_eq!(scroll_top.item_ix, TARGET);
assert_eq!(scroll_top.offset_in_item, px(0.));
assert_eq!(
target_store.read_with(cx, |store, _| store.pending_chat_turn(&session_id)),
None,
"the one-shot target was not consumed"
);
}

#[gpui::test]
fn long_markdown_paints_middle_blocks_when_scrolled_in_chat_outer_list(
cx: &mut TestAppContext,
Expand Down
Loading
, '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
255 changes: 85 additions & 170 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};

pub(crate) mod components;
mod model;
mod residency;

use crate::overlay::{Notification, OverlayExt as _};
use crate::theme::ActiveTheme as _;
Expand DownExpand Up@@ -51,6 +52,9 @@ use self::model::{
user_visible_text, work_log_auto_expands, work_log_capsule_label, work_log_counts,
work_log_outcome, work_log_row_entries,
};
use self::residency::{
MarkdownEntry, ResidencyInput, decide, tail_turn_window, viewport_turn_window,
};
pub(crate) use crate::material::{
CHAT_CONTENT_MAX_WIDTH as CONTENT_MAX_WIDTH, CHAT_CONTENT_MIN_PADDING as CONTENT_MIN_PADDING,
};
Expand All@@ -61,18 +65,6 @@ const TRAFFIC_LIGHT_INSET: f32 = 80.;
/// Vertical rhythm between turns. Turns are separated by space and typographic
/// hierarchy alone — there is deliberately no rule/divider under the user bubble.
const TURN_GAP: f32 = 32.;
/// Before GPUI reports its exact visible range, eight turns is comfortably
/// more than a typical chat viewport. Its list also pre-measures four viewport
/// heights, so the wider eviction band keeps warm rows resident without tying
/// Markdown lifetime to measurement.
const MARKDOWN_VIEWPORT_TURN_HINT: usize = 8;
const MARKDOWN_BUILD_MARGIN_TURNS: usize = 8;
/// Three build margins prevent back-and-forth scrolling from rebuilding the
/// same parsed documents at the edge of the warm window.
const MARKDOWN_EVICT_MARGIN_TURNS: usize = 24;
/// The composer-adjacent tail stays ready even while inspecting old turns.
const MARKDOWN_TAIL_PIN_TURNS: usize = 2;

pub struct ChatView {
workspace_store: Entity<WorkspaceStore>,
window_state: Entity<WindowState>,
Expand DownExpand Up@@ -228,8 +220,7 @@ impl ChatView {
offset_in_item: px(0.),
});
self.highlighted_turn = Some(turn);
self.markdown_visible_turns =
turn..(turn + MARKDOWN_VIEWPORT_TURN_HINT).min(self.turn_items.len());
self.markdown_visible_turns = viewport_turn_window(turn, self.turn_items.len());
self.markdown_scroll_top = Some(turn);
if let Some(session_id) = self.session_key.as_deref() {
self.workspace_store.update(cx, |store, _cx| {
Expand All@@ -238,7 +229,7 @@ impl ChatView {
}
}

self.sync_markdown_residency(cx);
self.sync_markdown_residency(requested_turn, cx);

// Keep a 100ms ticker alive while a turn runs so the live elapsed timer
// advances at decisecond precision; dropping it cancels the task.
Expand All@@ -256,97 +247,103 @@ impl ChatView {
}
}

fn sync_markdown_residency(&mut self, cx: &mut Context<Self>) {
fn sync_markdown_residency(
&mut self,
one_shot_turn_target: Option<usize>,
cx: &mut Context<Self>,
) {
let turn_count = self.turn_items.len();
let build_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_BUILD_MARGIN_TURNS,
turn_count,
);
let keep_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_EVICT_MARGIN_TURNS,
turn_count,
);
let tail_start = turn_count.saturating_sub(MARKDOWN_TAIL_PIN_TURNS);
let (texts, keep_ids) = self
// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let mut selection_drag_active = false;
let mut selection_participants = HashSet::new();
for (id, md) in &self.md_states {
let state = md.state.read(cx);
let selection = state.selection_handle();
let snapshot = selection.snapshot(cx);
selection_drag_active |= snapshot
.as_ref()
.is_some_and(|selection| selection.is_selecting());
if snapshot.is_some() || selection.has_local_selection(cx) {
selection_participants.insert(id.clone());
}
}
let resident_ids = self.md_states.keys().cloned().collect();
let (texts, decisions) = self
.workspace_store
.read(cx)
.with_active_timeline(|timeline| {
let pinned = |turn: usize| {
(turn_count > 0 && turn >= tail_start)
|| timeline.turns.get(turn).is_some_and(|turn| turn.running)
|| (timeline.turn_running
&& turn_count.checked_sub(1).is_some_and(|last| turn == last))
};
let mut entries = Vec::new();
for entry in &timeline.entries {
let markdown_bearing = matches!(
entry.content,
EntryContent::Item(ItemContent::AssistantMessage { .. })
| EntryContent::Item(ItemContent::Reasoning { .. })
) || user_content(&entry.content).is_some();
if markdown_bearing {
entries.push(MarkdownEntry {
id: entry.id.clone(),
turn: entry.turn,
turn_running: timeline
.turns
.get(entry.turn)
.is_some_and(|turn| turn.running),
});
}
}
if let Some(plan) = &timeline.proposed_plan {
entries.push(MarkdownEntry {
id: format!("plan:{}", plan.item_id),
turn: plan.turn,
turn_running: timeline
.turns
.get(plan.turn)
.is_some_and(|turn| turn.running),
});
}
let decisions = decide(ResidencyInput {
turn_count,
visible_turns: self.markdown_visible_turns.clone(),
one_shot_turn_target,
entries: &entries,
stream_running: timeline.turn_running,
resident_ids: &resident_ids,
selection_participants: &selection_participants,
selection_drag_active,
});
let mut texts = Vec::new();
let mut keep_ids = HashSet::new();
for entry in &timeline.entries {
let keep = keep_turns.contains(&entry.turn) || pinned(entry.turn);
let build = build_turns.contains(&entry.turn) || pinned(entry.turn);
if !keep && !build {
if !decisions.build.contains(&entry.id) {
continue;
}
match &entry.content {
EntryContent::Item(ItemContent::AssistantMessage { text })
| EntryContent::Item(ItemContent::Reasoning { text }) => {
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
content => {
let Some((text, _, context_len, _)) = user_content(content) else {
continue;
};
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
}
}
if let Some(plan) = &timeline.proposed_plan {
let id = format!("plan:{}", plan.item_id);
if keep_turns.contains(&plan.turn) || pinned(plan.turn) {
keep_ids.insert(id.clone());
}
if build_turns.contains(&plan.turn) || pinned(plan.turn) {
if decisions.build.contains(&id) {
texts.push((plan.turn, id, plan.markdown.clone()));
}
}
(texts, keep_ids)
(texts, decisions)
})
.unwrap_or_default();

// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let selection_drag_active = self.md_states.values().any(|md| {
md.state
.read(cx)
.selection_handle()
.snapshot(cx)
.is_some_and(|selection| selection.is_selecting())
});
if !selection_drag_active {
self.md_states.retain(|id, md| {
if keep_ids.contains(id) {
return true;
}
let state = md.state.read(cx);
let selection = state.selection_handle();
selection.snapshot(cx).is_some() || selection.has_local_selection(cx)
});
}
self.md_states.retain(|id, _| !decisions.evict.contains(id));

let mut rebuilt_turns = HashSet::new();
for (turn, id, text) in texts {
Expand DownExpand Up@@ -389,7 +386,7 @@ impl ChatView {
return;
}
self.markdown_visible_turns = visible_turns;
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
cx.notify();
}

Expand All@@ -403,9 +400,9 @@ impl ChatView {
self.markdown_visible_turns = if scroll_top == turn_count {
tail_turn_window(turn_count)
} else {
scroll_top..(scroll_top + MARKDOWN_VIEWPORT_TURN_HINT).min(turn_count)
viewport_turn_window(scroll_top, turn_count)
};
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
}

#[cfg(test)]
Expand DownExpand Up@@ -1873,19 +1870,6 @@ impl Render for ChatView {
// Helpers
// ---------------------------------------------------------------------------

fn tail_turn_window(turn_count: usize) -> Range<usize> {
turn_count.saturating_sub(MARKDOWN_VIEWPORT_TURN_HINT)..turn_count
}

fn expand_turn_window(window: Range<usize>, margin: usize, turn_count: usize) -> Range<usize> {
window.start.min(turn_count).saturating_sub(margin)
..window
.end
.min(turn_count)
.saturating_add(margin)
.min(turn_count)
}

/// Launch `zed <cwd>` detached; surface a notification if the CLI is missing.
/// The leading icon for a git quick-action.
fn git_action_icon(action: GitAction) -> Icon {
Expand DownExpand Up@@ -1958,29 +1942,7 @@ mod tests {
static NEXT_RESIDENCY_TEST_ID: AtomicU64 = AtomicU64::new(0);

#[gpui::test]
fn long_session_keeps_tail_markdown_residency_bounded(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, _) = seed_chat(cx, timeline);
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});

let resident = view.read_with(cx, |chat, _| chat.resident_markdown_state_count());
assert_eq!(resident, 48);
assert!(
resident <= 96,
"tail window retained {resident} MarkdownStates; expected at most 96"
);
}

#[gpui::test]
fn scrolling_to_old_turn_rebuilds_markdown_and_evicts_distant_tail(cx: &mut TestAppContext) {
fn chat_view_applies_markdown_residency_decisions(cx: &mut TestAppContext) {
use gpui::{FollowMode, ListOffset, VisualTestContext, px, size};

const TARGET: usize = 40;
Expand All@@ -1993,6 +1955,10 @@ mod tests {
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert_eq!(
view.read_with(cx, |chat, _| chat.resident_markdown_state_count()),
48
);
let list_state = view.read_with(cx, |chat, _| chat.list_state.clone());
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-40")
Expand DownExpand Up@@ -2044,57 +2010,6 @@ mod tests {
);
}

#[gpui::test]
fn turn_target_jump_rebuilds_an_evicted_region(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

const TARGET: usize = 20;
let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, session_id) = seed_chat(cx, timeline);
let target_store = workspace_store.clone();
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-20")
}));

target_store.update(cx, |store, cx| {
store.select_session_at_turn(session_id.clone(), TARGET);
cx.notify();
});
cx.run_until_parked();

let (highlighted, resident, target_resident, distant_tail_evicted, scroll_top) = view
.read_with(cx, |chat, _| {
(
chat.highlighted_turn,
chat.resident_markdown_state_count(),
chat.has_resident_markdown_state("assistant-20"),
!chat.has_resident_markdown_state("assistant-230"),
chat.list_state.logical_scroll_top(),
)
});
assert_eq!(highlighted, Some(TARGET));
assert_eq!(resident, 78);
assert!(target_resident, "the targeted turn was not rebuilt");
assert!(
distant_tail_evicted,
"the target jump retained a tail state beyond the hysteresis band"
);
assert_eq!(scroll_top.item_ix, TARGET);
assert_eq!(scroll_top.offset_in_item, px(0.));
assert_eq!(
target_store.read_with(cx, |store, _| store.pending_chat_turn(&session_id)),
None,
"the one-shot target was not consumed"
);
}

#[gpui::test]
fn long_markdown_paints_middle_blocks_when_scrolled_in_chat_outer_list(
cx: &mut TestAppContext,
Expand Down
Loading
, '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
255 changes: 85 additions & 170 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};

pub(crate) mod components;
mod model;
mod residency;

use crate::overlay::{Notification, OverlayExt as _};
use crate::theme::ActiveTheme as _;
Expand DownExpand Up@@ -51,6 +52,9 @@ use self::model::{
user_visible_text, work_log_auto_expands, work_log_capsule_label, work_log_counts,
work_log_outcome, work_log_row_entries,
};
use self::residency::{
MarkdownEntry, ResidencyInput, decide, tail_turn_window, viewport_turn_window,
};
pub(crate) use crate::material::{
CHAT_CONTENT_MAX_WIDTH as CONTENT_MAX_WIDTH, CHAT_CONTENT_MIN_PADDING as CONTENT_MIN_PADDING,
};
Expand All@@ -61,18 +65,6 @@ const TRAFFIC_LIGHT_INSET: f32 = 80.;
/// Vertical rhythm between turns. Turns are separated by space and typographic
/// hierarchy alone — there is deliberately no rule/divider under the user bubble.
const TURN_GAP: f32 = 32.;
/// Before GPUI reports its exact visible range, eight turns is comfortably
/// more than a typical chat viewport. Its list also pre-measures four viewport
/// heights, so the wider eviction band keeps warm rows resident without tying
/// Markdown lifetime to measurement.
const MARKDOWN_VIEWPORT_TURN_HINT: usize = 8;
const MARKDOWN_BUILD_MARGIN_TURNS: usize = 8;
/// Three build margins prevent back-and-forth scrolling from rebuilding the
/// same parsed documents at the edge of the warm window.
const MARKDOWN_EVICT_MARGIN_TURNS: usize = 24;
/// The composer-adjacent tail stays ready even while inspecting old turns.
const MARKDOWN_TAIL_PIN_TURNS: usize = 2;

pub struct ChatView {
workspace_store: Entity<WorkspaceStore>,
window_state: Entity<WindowState>,
Expand DownExpand Up@@ -228,8 +220,7 @@ impl ChatView {
offset_in_item: px(0.),
});
self.highlighted_turn = Some(turn);
self.markdown_visible_turns =
turn..(turn + MARKDOWN_VIEWPORT_TURN_HINT).min(self.turn_items.len());
self.markdown_visible_turns = viewport_turn_window(turn, self.turn_items.len());
self.markdown_scroll_top = Some(turn);
if let Some(session_id) = self.session_key.as_deref() {
self.workspace_store.update(cx, |store, _cx| {
Expand All@@ -238,7 +229,7 @@ impl ChatView {
}
}

self.sync_markdown_residency(cx);
self.sync_markdown_residency(requested_turn, cx);

// Keep a 100ms ticker alive while a turn runs so the live elapsed timer
// advances at decisecond precision; dropping it cancels the task.
Expand All@@ -256,97 +247,103 @@ impl ChatView {
}
}

fn sync_markdown_residency(&mut self, cx: &mut Context<Self>) {
fn sync_markdown_residency(
&mut self,
one_shot_turn_target: Option<usize>,
cx: &mut Context<Self>,
) {
let turn_count = self.turn_items.len();
let build_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_BUILD_MARGIN_TURNS,
turn_count,
);
let keep_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_EVICT_MARGIN_TURNS,
turn_count,
);
let tail_start = turn_count.saturating_sub(MARKDOWN_TAIL_PIN_TURNS);
let (texts, keep_ids) = self
// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let mut selection_drag_active = false;
let mut selection_participants = HashSet::new();
for (id, md) in &self.md_states {
let state = md.state.read(cx);
let selection = state.selection_handle();
let snapshot = selection.snapshot(cx);
selection_drag_active |= snapshot
.as_ref()
.is_some_and(|selection| selection.is_selecting());
if snapshot.is_some() || selection.has_local_selection(cx) {
selection_participants.insert(id.clone());
}
}
let resident_ids = self.md_states.keys().cloned().collect();
let (texts, decisions) = self
.workspace_store
.read(cx)
.with_active_timeline(|timeline| {
let pinned = |turn: usize| {
(turn_count > 0 && turn >= tail_start)
|| timeline.turns.get(turn).is_some_and(|turn| turn.running)
|| (timeline.turn_running
&& turn_count.checked_sub(1).is_some_and(|last| turn == last))
};
let mut entries = Vec::new();
for entry in &timeline.entries {
let markdown_bearing = matches!(
entry.content,
EntryContent::Item(ItemContent::AssistantMessage { .. })
| EntryContent::Item(ItemContent::Reasoning { .. })
) || user_content(&entry.content).is_some();
if markdown_bearing {
entries.push(MarkdownEntry {
id: entry.id.clone(),
turn: entry.turn,
turn_running: timeline
.turns
.get(entry.turn)
.is_some_and(|turn| turn.running),
});
}
}
if let Some(plan) = &timeline.proposed_plan {
entries.push(MarkdownEntry {
id: format!("plan:{}", plan.item_id),
turn: plan.turn,
turn_running: timeline
.turns
.get(plan.turn)
.is_some_and(|turn| turn.running),
});
}
let decisions = decide(ResidencyInput {
turn_count,
visible_turns: self.markdown_visible_turns.clone(),
one_shot_turn_target,
entries: &entries,
stream_running: timeline.turn_running,
resident_ids: &resident_ids,
selection_participants: &selection_participants,
selection_drag_active,
});
let mut texts = Vec::new();
let mut keep_ids = HashSet::new();
for entry in &timeline.entries {
let keep = keep_turns.contains(&entry.turn) || pinned(entry.turn);
let build = build_turns.contains(&entry.turn) || pinned(entry.turn);
if !keep && !build {
if !decisions.build.contains(&entry.id) {
continue;
}
match &entry.content {
EntryContent::Item(ItemContent::AssistantMessage { text })
| EntryContent::Item(ItemContent::Reasoning { text }) => {
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
content => {
let Some((text, _, context_len, _)) = user_content(content) else {
continue;
};
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
}
}
if let Some(plan) = &timeline.proposed_plan {
let id = format!("plan:{}", plan.item_id);
if keep_turns.contains(&plan.turn) || pinned(plan.turn) {
keep_ids.insert(id.clone());
}
if build_turns.contains(&plan.turn) || pinned(plan.turn) {
if decisions.build.contains(&id) {
texts.push((plan.turn, id, plan.markdown.clone()));
}
}
(texts, keep_ids)
(texts, decisions)
})
.unwrap_or_default();

// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let selection_drag_active = self.md_states.values().any(|md| {
md.state
.read(cx)
.selection_handle()
.snapshot(cx)
.is_some_and(|selection| selection.is_selecting())
});
if !selection_drag_active {
self.md_states.retain(|id, md| {
if keep_ids.contains(id) {
return true;
}
let state = md.state.read(cx);
let selection = state.selection_handle();
selection.snapshot(cx).is_some() || selection.has_local_selection(cx)
});
}
self.md_states.retain(|id, _| !decisions.evict.contains(id));

let mut rebuilt_turns = HashSet::new();
for (turn, id, text) in texts {
Expand DownExpand Up@@ -389,7 +386,7 @@ impl ChatView {
return;
}
self.markdown_visible_turns = visible_turns;
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
cx.notify();
}

Expand All@@ -403,9 +400,9 @@ impl ChatView {
self.markdown_visible_turns = if scroll_top == turn_count {
tail_turn_window(turn_count)
} else {
scroll_top..(scroll_top + MARKDOWN_VIEWPORT_TURN_HINT).min(turn_count)
viewport_turn_window(scroll_top, turn_count)
};
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
}

#[cfg(test)]
Expand DownExpand Up@@ -1873,19 +1870,6 @@ impl Render for ChatView {
// Helpers
// ---------------------------------------------------------------------------

fn tail_turn_window(turn_count: usize) -> Range<usize> {
turn_count.saturating_sub(MARKDOWN_VIEWPORT_TURN_HINT)..turn_count
}

fn expand_turn_window(window: Range<usize>, margin: usize, turn_count: usize) -> Range<usize> {
window.start.min(turn_count).saturating_sub(margin)
..window
.end
.min(turn_count)
.saturating_add(margin)
.min(turn_count)
}

/// Launch `zed <cwd>` detached; surface a notification if the CLI is missing.
/// The leading icon for a git quick-action.
fn git_action_icon(action: GitAction) -> Icon {
Expand DownExpand Up@@ -1958,29 +1942,7 @@ mod tests {
static NEXT_RESIDENCY_TEST_ID: AtomicU64 = AtomicU64::new(0);

#[gpui::test]
fn long_session_keeps_tail_markdown_residency_bounded(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, _) = seed_chat(cx, timeline);
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});

let resident = view.read_with(cx, |chat, _| chat.resident_markdown_state_count());
assert_eq!(resident, 48);
assert!(
resident <= 96,
"tail window retained {resident} MarkdownStates; expected at most 96"
);
}

#[gpui::test]
fn scrolling_to_old_turn_rebuilds_markdown_and_evicts_distant_tail(cx: &mut TestAppContext) {
fn chat_view_applies_markdown_residency_decisions(cx: &mut TestAppContext) {
use gpui::{FollowMode, ListOffset, VisualTestContext, px, size};

const TARGET: usize = 40;
Expand All@@ -1993,6 +1955,10 @@ mod tests {
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert_eq!(
view.read_with(cx, |chat, _| chat.resident_markdown_state_count()),
48
);
let list_state = view.read_with(cx, |chat, _| chat.list_state.clone());
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-40")
Expand DownExpand Up@@ -2044,57 +2010,6 @@ mod tests {
);
}

#[gpui::test]
fn turn_target_jump_rebuilds_an_evicted_region(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

const TARGET: usize = 20;
let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, session_id) = seed_chat(cx, timeline);
let target_store = workspace_store.clone();
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-20")
}));

target_store.update(cx, |store, cx| {
store.select_session_at_turn(session_id.clone(), TARGET);
cx.notify();
});
cx.run_until_parked();

let (highlighted, resident, target_resident, distant_tail_evicted, scroll_top) = view
.read_with(cx, |chat, _| {
(
chat.highlighted_turn,
chat.resident_markdown_state_count(),
chat.has_resident_markdown_state("assistant-20"),
!chat.has_resident_markdown_state("assistant-230"),
chat.list_state.logical_scroll_top(),
)
});
assert_eq!(highlighted, Some(TARGET));
assert_eq!(resident, 78);
assert!(target_resident, "the targeted turn was not rebuilt");
assert!(
distant_tail_evicted,
"the target jump retained a tail state beyond the hysteresis band"
);
assert_eq!(scroll_top.item_ix, TARGET);
assert_eq!(scroll_top.offset_in_item, px(0.));
assert_eq!(
target_store.read_with(cx, |store, _| store.pending_chat_turn(&session_id)),
None,
"the one-shot target was not consumed"
);
}

#[gpui::test]
fn long_markdown_paints_middle_blocks_when_scrolled_in_chat_outer_list(
cx: &mut TestAppContext,
Expand Down
Loading
, '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
255 changes: 85 additions & 170 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};

pub(crate) mod components;
mod model;
mod residency;

use crate::overlay::{Notification, OverlayExt as _};
use crate::theme::ActiveTheme as _;
Expand DownExpand Up@@ -51,6 +52,9 @@ use self::model::{
user_visible_text, work_log_auto_expands, work_log_capsule_label, work_log_counts,
work_log_outcome, work_log_row_entries,
};
use self::residency::{
MarkdownEntry, ResidencyInput, decide, tail_turn_window, viewport_turn_window,
};
pub(crate) use crate::material::{
CHAT_CONTENT_MAX_WIDTH as CONTENT_MAX_WIDTH, CHAT_CONTENT_MIN_PADDING as CONTENT_MIN_PADDING,
};
Expand All@@ -61,18 +65,6 @@ const TRAFFIC_LIGHT_INSET: f32 = 80.;
/// Vertical rhythm between turns. Turns are separated by space and typographic
/// hierarchy alone — there is deliberately no rule/divider under the user bubble.
const TURN_GAP: f32 = 32.;
/// Before GPUI reports its exact visible range, eight turns is comfortably
/// more than a typical chat viewport. Its list also pre-measures four viewport
/// heights, so the wider eviction band keeps warm rows resident without tying
/// Markdown lifetime to measurement.
const MARKDOWN_VIEWPORT_TURN_HINT: usize = 8;
const MARKDOWN_BUILD_MARGIN_TURNS: usize = 8;
/// Three build margins prevent back-and-forth scrolling from rebuilding the
/// same parsed documents at the edge of the warm window.
const MARKDOWN_EVICT_MARGIN_TURNS: usize = 24;
/// The composer-adjacent tail stays ready even while inspecting old turns.
const MARKDOWN_TAIL_PIN_TURNS: usize = 2;

pub struct ChatView {
workspace_store: Entity<WorkspaceStore>,
window_state: Entity<WindowState>,
Expand DownExpand Up@@ -228,8 +220,7 @@ impl ChatView {
offset_in_item: px(0.),
});
self.highlighted_turn = Some(turn);
self.markdown_visible_turns =
turn..(turn + MARKDOWN_VIEWPORT_TURN_HINT).min(self.turn_items.len());
self.markdown_visible_turns = viewport_turn_window(turn, self.turn_items.len());
self.markdown_scroll_top = Some(turn);
if let Some(session_id) = self.session_key.as_deref() {
self.workspace_store.update(cx, |store, _cx| {
Expand All@@ -238,7 +229,7 @@ impl ChatView {
}
}

self.sync_markdown_residency(cx);
self.sync_markdown_residency(requested_turn, cx);

// Keep a 100ms ticker alive while a turn runs so the live elapsed timer
// advances at decisecond precision; dropping it cancels the task.
Expand All@@ -256,97 +247,103 @@ impl ChatView {
}
}

fn sync_markdown_residency(&mut self, cx: &mut Context<Self>) {
fn sync_markdown_residency(
&mut self,
one_shot_turn_target: Option<usize>,
cx: &mut Context<Self>,
) {
let turn_count = self.turn_items.len();
let build_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_BUILD_MARGIN_TURNS,
turn_count,
);
let keep_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_EVICT_MARGIN_TURNS,
turn_count,
);
let tail_start = turn_count.saturating_sub(MARKDOWN_TAIL_PIN_TURNS);
let (texts, keep_ids) = self
// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let mut selection_drag_active = false;
let mut selection_participants = HashSet::new();
for (id, md) in &self.md_states {
let state = md.state.read(cx);
let selection = state.selection_handle();
let snapshot = selection.snapshot(cx);
selection_drag_active |= snapshot
.as_ref()
.is_some_and(|selection| selection.is_selecting());
if snapshot.is_some() || selection.has_local_selection(cx) {
selection_participants.insert(id.clone());
}
}
let resident_ids = self.md_states.keys().cloned().collect();
let (texts, decisions) = self
.workspace_store
.read(cx)
.with_active_timeline(|timeline| {
let pinned = |turn: usize| {
(turn_count > 0 && turn >= tail_start)
|| timeline.turns.get(turn).is_some_and(|turn| turn.running)
|| (timeline.turn_running
&& turn_count.checked_sub(1).is_some_and(|last| turn == last))
};
let mut entries = Vec::new();
for entry in &timeline.entries {
let markdown_bearing = matches!(
entry.content,
EntryContent::Item(ItemContent::AssistantMessage { .. })
| EntryContent::Item(ItemContent::Reasoning { .. })
) || user_content(&entry.content).is_some();
if markdown_bearing {
entries.push(MarkdownEntry {
id: entry.id.clone(),
turn: entry.turn,
turn_running: timeline
.turns
.get(entry.turn)
.is_some_and(|turn| turn.running),
});
}
}
if let Some(plan) = &timeline.proposed_plan {
entries.push(MarkdownEntry {
id: format!("plan:{}", plan.item_id),
turn: plan.turn,
turn_running: timeline
.turns
.get(plan.turn)
.is_some_and(|turn| turn.running),
});
}
let decisions = decide(ResidencyInput {
turn_count,
visible_turns: self.markdown_visible_turns.clone(),
one_shot_turn_target,
entries: &entries,
stream_running: timeline.turn_running,
resident_ids: &resident_ids,
selection_participants: &selection_participants,
selection_drag_active,
});
let mut texts = Vec::new();
let mut keep_ids = HashSet::new();
for entry in &timeline.entries {
let keep = keep_turns.contains(&entry.turn) || pinned(entry.turn);
let build = build_turns.contains(&entry.turn) || pinned(entry.turn);
if !keep && !build {
if !decisions.build.contains(&entry.id) {
continue;
}
match &entry.content {
EntryContent::Item(ItemContent::AssistantMessage { text })
| EntryContent::Item(ItemContent::Reasoning { text }) => {
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
content => {
let Some((text, _, context_len, _)) = user_content(content) else {
continue;
};
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
}
}
if let Some(plan) = &timeline.proposed_plan {
let id = format!("plan:{}", plan.item_id);
if keep_turns.contains(&plan.turn) || pinned(plan.turn) {
keep_ids.insert(id.clone());
}
if build_turns.contains(&plan.turn) || pinned(plan.turn) {
if decisions.build.contains(&id) {
texts.push((plan.turn, id, plan.markdown.clone()));
}
}
(texts, keep_ids)
(texts, decisions)
})
.unwrap_or_default();

// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let selection_drag_active = self.md_states.values().any(|md| {
md.state
.read(cx)
.selection_handle()
.snapshot(cx)
.is_some_and(|selection| selection.is_selecting())
});
if !selection_drag_active {
self.md_states.retain(|id, md| {
if keep_ids.contains(id) {
return true;
}
let state = md.state.read(cx);
let selection = state.selection_handle();
selection.snapshot(cx).is_some() || selection.has_local_selection(cx)
});
}
self.md_states.retain(|id, _| !decisions.evict.contains(id));

let mut rebuilt_turns = HashSet::new();
for (turn, id, text) in texts {
Expand DownExpand Up@@ -389,7 +386,7 @@ impl ChatView {
return;
}
self.markdown_visible_turns = visible_turns;
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
cx.notify();
}

Expand All@@ -403,9 +400,9 @@ impl ChatView {
self.markdown_visible_turns = if scroll_top == turn_count {
tail_turn_window(turn_count)
} else {
scroll_top..(scroll_top + MARKDOWN_VIEWPORT_TURN_HINT).min(turn_count)
viewport_turn_window(scroll_top, turn_count)
};
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
}

#[cfg(test)]
Expand DownExpand Up@@ -1873,19 +1870,6 @@ impl Render for ChatView {
// Helpers
// ---------------------------------------------------------------------------

fn tail_turn_window(turn_count: usize) -> Range<usize> {
turn_count.saturating_sub(MARKDOWN_VIEWPORT_TURN_HINT)..turn_count
}

fn expand_turn_window(window: Range<usize>, margin: usize, turn_count: usize) -> Range<usize> {
window.start.min(turn_count).saturating_sub(margin)
..window
.end
.min(turn_count)
.saturating_add(margin)
.min(turn_count)
}

/// Launch `zed <cwd>` detached; surface a notification if the CLI is missing.
/// The leading icon for a git quick-action.
fn git_action_icon(action: GitAction) -> Icon {
Expand DownExpand Up@@ -1958,29 +1942,7 @@ mod tests {
static NEXT_RESIDENCY_TEST_ID: AtomicU64 = AtomicU64::new(0);

#[gpui::test]
fn long_session_keeps_tail_markdown_residency_bounded(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, _) = seed_chat(cx, timeline);
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});

let resident = view.read_with(cx, |chat, _| chat.resident_markdown_state_count());
assert_eq!(resident, 48);
assert!(
resident <= 96,
"tail window retained {resident} MarkdownStates; expected at most 96"
);
}

#[gpui::test]
fn scrolling_to_old_turn_rebuilds_markdown_and_evicts_distant_tail(cx: &mut TestAppContext) {
fn chat_view_applies_markdown_residency_decisions(cx: &mut TestAppContext) {
use gpui::{FollowMode, ListOffset, VisualTestContext, px, size};

const TARGET: usize = 40;
Expand All@@ -1993,6 +1955,10 @@ mod tests {
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert_eq!(
view.read_with(cx, |chat, _| chat.resident_markdown_state_count()),
48
);
let list_state = view.read_with(cx, |chat, _| chat.list_state.clone());
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-40")
Expand DownExpand Up@@ -2044,57 +2010,6 @@ mod tests {
);
}

#[gpui::test]
fn turn_target_jump_rebuilds_an_evicted_region(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

const TARGET: usize = 20;
let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, session_id) = seed_chat(cx, timeline);
let target_store = workspace_store.clone();
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-20")
}));

target_store.update(cx, |store, cx| {
store.select_session_at_turn(session_id.clone(), TARGET);
cx.notify();
});
cx.run_until_parked();

let (highlighted, resident, target_resident, distant_tail_evicted, scroll_top) = view
.read_with(cx, |chat, _| {
(
chat.highlighted_turn,
chat.resident_markdown_state_count(),
chat.has_resident_markdown_state("assistant-20"),
!chat.has_resident_markdown_state("assistant-230"),
chat.list_state.logical_scroll_top(),
)
});
assert_eq!(highlighted, Some(TARGET));
assert_eq!(resident, 78);
assert!(target_resident, "the targeted turn was not rebuilt");
assert!(
distant_tail_evicted,
"the target jump retained a tail state beyond the hysteresis band"
);
assert_eq!(scroll_top.item_ix, TARGET);
assert_eq!(scroll_top.offset_in_item, px(0.));
assert_eq!(
target_store.read_with(cx, |store, _| store.pending_chat_turn(&session_id)),
None,
"the one-shot target was not consumed"
);
}

#[gpui::test]
fn long_markdown_paints_middle_blocks_when_scrolled_in_chat_outer_list(
cx: &mut TestAppContext,
Expand Down
Loading
, '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
255 changes: 85 additions & 170 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};

pub(crate) mod components;
mod model;
mod residency;

use crate::overlay::{Notification, OverlayExt as _};
use crate::theme::ActiveTheme as _;
Expand DownExpand Up@@ -51,6 +52,9 @@ use self::model::{
user_visible_text, work_log_auto_expands, work_log_capsule_label, work_log_counts,
work_log_outcome, work_log_row_entries,
};
use self::residency::{
MarkdownEntry, ResidencyInput, decide, tail_turn_window, viewport_turn_window,
};
pub(crate) use crate::material::{
CHAT_CONTENT_MAX_WIDTH as CONTENT_MAX_WIDTH, CHAT_CONTENT_MIN_PADDING as CONTENT_MIN_PADDING,
};
Expand All@@ -61,18 +65,6 @@ const TRAFFIC_LIGHT_INSET: f32 = 80.;
/// Vertical rhythm between turns. Turns are separated by space and typographic
/// hierarchy alone — there is deliberately no rule/divider under the user bubble.
const TURN_GAP: f32 = 32.;
/// Before GPUI reports its exact visible range, eight turns is comfortably
/// more than a typical chat viewport. Its list also pre-measures four viewport
/// heights, so the wider eviction band keeps warm rows resident without tying
/// Markdown lifetime to measurement.
const MARKDOWN_VIEWPORT_TURN_HINT: usize = 8;
const MARKDOWN_BUILD_MARGIN_TURNS: usize = 8;
/// Three build margins prevent back-and-forth scrolling from rebuilding the
/// same parsed documents at the edge of the warm window.
const MARKDOWN_EVICT_MARGIN_TURNS: usize = 24;
/// The composer-adjacent tail stays ready even while inspecting old turns.
const MARKDOWN_TAIL_PIN_TURNS: usize = 2;

pub struct ChatView {
workspace_store: Entity<WorkspaceStore>,
window_state: Entity<WindowState>,
Expand DownExpand Up@@ -228,8 +220,7 @@ impl ChatView {
offset_in_item: px(0.),
});
self.highlighted_turn = Some(turn);
self.markdown_visible_turns =
turn..(turn + MARKDOWN_VIEWPORT_TURN_HINT).min(self.turn_items.len());
self.markdown_visible_turns = viewport_turn_window(turn, self.turn_items.len());
self.markdown_scroll_top = Some(turn);
if let Some(session_id) = self.session_key.as_deref() {
self.workspace_store.update(cx, |store, _cx| {
Expand All@@ -238,7 +229,7 @@ impl ChatView {
}
}

self.sync_markdown_residency(cx);
self.sync_markdown_residency(requested_turn, cx);

// Keep a 100ms ticker alive while a turn runs so the live elapsed timer
// advances at decisecond precision; dropping it cancels the task.
Expand All@@ -256,97 +247,103 @@ impl ChatView {
}
}

fn sync_markdown_residency(&mut self, cx: &mut Context<Self>) {
fn sync_markdown_residency(
&mut self,
one_shot_turn_target: Option<usize>,
cx: &mut Context<Self>,
) {
let turn_count = self.turn_items.len();
let build_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_BUILD_MARGIN_TURNS,
turn_count,
);
let keep_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_EVICT_MARGIN_TURNS,
turn_count,
);
let tail_start = turn_count.saturating_sub(MARKDOWN_TAIL_PIN_TURNS);
let (texts, keep_ids) = self
// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let mut selection_drag_active = false;
let mut selection_participants = HashSet::new();
for (id, md) in &self.md_states {
let state = md.state.read(cx);
let selection = state.selection_handle();
let snapshot = selection.snapshot(cx);
selection_drag_active |= snapshot
.as_ref()
.is_some_and(|selection| selection.is_selecting());
if snapshot.is_some() || selection.has_local_selection(cx) {
selection_participants.insert(id.clone());
}
}
let resident_ids = self.md_states.keys().cloned().collect();
let (texts, decisions) = self
.workspace_store
.read(cx)
.with_active_timeline(|timeline| {
let pinned = |turn: usize| {
(turn_count > 0 && turn >= tail_start)
|| timeline.turns.get(turn).is_some_and(|turn| turn.running)
|| (timeline.turn_running
&& turn_count.checked_sub(1).is_some_and(|last| turn == last))
};
let mut entries = Vec::new();
for entry in &timeline.entries {
let markdown_bearing = matches!(
entry.content,
EntryContent::Item(ItemContent::AssistantMessage { .. })
| EntryContent::Item(ItemContent::Reasoning { .. })
) || user_content(&entry.content).is_some();
if markdown_bearing {
entries.push(MarkdownEntry {
id: entry.id.clone(),
turn: entry.turn,
turn_running: timeline
.turns
.get(entry.turn)
.is_some_and(|turn| turn.running),
});
}
}
if let Some(plan) = &timeline.proposed_plan {
entries.push(MarkdownEntry {
id: format!("plan:{}", plan.item_id),
turn: plan.turn,
turn_running: timeline
.turns
.get(plan.turn)
.is_some_and(|turn| turn.running),
});
}
let decisions = decide(ResidencyInput {
turn_count,
visible_turns: self.markdown_visible_turns.clone(),
one_shot_turn_target,
entries: &entries,
stream_running: timeline.turn_running,
resident_ids: &resident_ids,
selection_participants: &selection_participants,
selection_drag_active,
});
let mut texts = Vec::new();
let mut keep_ids = HashSet::new();
for entry in &timeline.entries {
let keep = keep_turns.contains(&entry.turn) || pinned(entry.turn);
let build = build_turns.contains(&entry.turn) || pinned(entry.turn);
if !keep && !build {
if !decisions.build.contains(&entry.id) {
continue;
}
match &entry.content {
EntryContent::Item(ItemContent::AssistantMessage { text })
| EntryContent::Item(ItemContent::Reasoning { text }) => {
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
content => {
let Some((text, _, context_len, _)) = user_content(content) else {
continue;
};
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
}
}
if let Some(plan) = &timeline.proposed_plan {
let id = format!("plan:{}", plan.item_id);
if keep_turns.contains(&plan.turn) || pinned(plan.turn) {
keep_ids.insert(id.clone());
}
if build_turns.contains(&plan.turn) || pinned(plan.turn) {
if decisions.build.contains(&id) {
texts.push((plan.turn, id, plan.markdown.clone()));
}
}
(texts, keep_ids)
(texts, decisions)
})
.unwrap_or_default();

// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let selection_drag_active = self.md_states.values().any(|md| {
md.state
.read(cx)
.selection_handle()
.snapshot(cx)
.is_some_and(|selection| selection.is_selecting())
});
if !selection_drag_active {
self.md_states.retain(|id, md| {
if keep_ids.contains(id) {
return true;
}
let state = md.state.read(cx);
let selection = state.selection_handle();
selection.snapshot(cx).is_some() || selection.has_local_selection(cx)
});
}
self.md_states.retain(|id, _| !decisions.evict.contains(id));

let mut rebuilt_turns = HashSet::new();
for (turn, id, text) in texts {
Expand DownExpand Up@@ -389,7 +386,7 @@ impl ChatView {
return;
}
self.markdown_visible_turns = visible_turns;
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
cx.notify();
}

Expand All@@ -403,9 +400,9 @@ impl ChatView {
self.markdown_visible_turns = if scroll_top == turn_count {
tail_turn_window(turn_count)
} else {
scroll_top..(scroll_top + MARKDOWN_VIEWPORT_TURN_HINT).min(turn_count)
viewport_turn_window(scroll_top, turn_count)
};
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
}

#[cfg(test)]
Expand DownExpand Up@@ -1873,19 +1870,6 @@ impl Render for ChatView {
// Helpers
// ---------------------------------------------------------------------------

fn tail_turn_window(turn_count: usize) -> Range<usize> {
turn_count.saturating_sub(MARKDOWN_VIEWPORT_TURN_HINT)..turn_count
}

fn expand_turn_window(window: Range<usize>, margin: usize, turn_count: usize) -> Range<usize> {
window.start.min(turn_count).saturating_sub(margin)
..window
.end
.min(turn_count)
.saturating_add(margin)
.min(turn_count)
}

/// Launch `zed <cwd>` detached; surface a notification if the CLI is missing.
/// The leading icon for a git quick-action.
fn git_action_icon(action: GitAction) -> Icon {
Expand DownExpand Up@@ -1958,29 +1942,7 @@ mod tests {
static NEXT_RESIDENCY_TEST_ID: AtomicU64 = AtomicU64::new(0);

#[gpui::test]
fn long_session_keeps_tail_markdown_residency_bounded(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, _) = seed_chat(cx, timeline);
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});

let resident = view.read_with(cx, |chat, _| chat.resident_markdown_state_count());
assert_eq!(resident, 48);
assert!(
resident <= 96,
"tail window retained {resident} MarkdownStates; expected at most 96"
);
}

#[gpui::test]
fn scrolling_to_old_turn_rebuilds_markdown_and_evicts_distant_tail(cx: &mut TestAppContext) {
fn chat_view_applies_markdown_residency_decisions(cx: &mut TestAppContext) {
use gpui::{FollowMode, ListOffset, VisualTestContext, px, size};

const TARGET: usize = 40;
Expand All@@ -1993,6 +1955,10 @@ mod tests {
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert_eq!(
view.read_with(cx, |chat, _| chat.resident_markdown_state_count()),
48
);
let list_state = view.read_with(cx, |chat, _| chat.list_state.clone());
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-40")
Expand DownExpand Up@@ -2044,57 +2010,6 @@ mod tests {
);
}

#[gpui::test]
fn turn_target_jump_rebuilds_an_evicted_region(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

const TARGET: usize = 20;
let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, session_id) = seed_chat(cx, timeline);
let target_store = workspace_store.clone();
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-20")
}));

target_store.update(cx, |store, cx| {
store.select_session_at_turn(session_id.clone(), TARGET);
cx.notify();
});
cx.run_until_parked();

let (highlighted, resident, target_resident, distant_tail_evicted, scroll_top) = view
.read_with(cx, |chat, _| {
(
chat.highlighted_turn,
chat.resident_markdown_state_count(),
chat.has_resident_markdown_state("assistant-20"),
!chat.has_resident_markdown_state("assistant-230"),
chat.list_state.logical_scroll_top(),
)
});
assert_eq!(highlighted, Some(TARGET));
assert_eq!(resident, 78);
assert!(target_resident, "the targeted turn was not rebuilt");
assert!(
distant_tail_evicted,
"the target jump retained a tail state beyond the hysteresis band"
);
assert_eq!(scroll_top.item_ix, TARGET);
assert_eq!(scroll_top.offset_in_item, px(0.));
assert_eq!(
target_store.read_with(cx, |store, _| store.pending_chat_turn(&session_id)),
None,
"the one-shot target was not consumed"
);
}

#[gpui::test]
fn long_markdown_paints_middle_blocks_when_scrolled_in_chat_outer_list(
cx: &mut TestAppContext,
Expand Down
Loading
, '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
255 changes: 85 additions & 170 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};

pub(crate) mod components;
mod model;
mod residency;

use crate::overlay::{Notification, OverlayExt as _};
use crate::theme::ActiveTheme as _;
Expand DownExpand Up@@ -51,6 +52,9 @@ use self::model::{
user_visible_text, work_log_auto_expands, work_log_capsule_label, work_log_counts,
work_log_outcome, work_log_row_entries,
};
use self::residency::{
MarkdownEntry, ResidencyInput, decide, tail_turn_window, viewport_turn_window,
};
pub(crate) use crate::material::{
CHAT_CONTENT_MAX_WIDTH as CONTENT_MAX_WIDTH, CHAT_CONTENT_MIN_PADDING as CONTENT_MIN_PADDING,
};
Expand All@@ -61,18 +65,6 @@ const TRAFFIC_LIGHT_INSET: f32 = 80.;
/// Vertical rhythm between turns. Turns are separated by space and typographic
/// hierarchy alone — there is deliberately no rule/divider under the user bubble.
const TURN_GAP: f32 = 32.;
/// Before GPUI reports its exact visible range, eight turns is comfortably
/// more than a typical chat viewport. Its list also pre-measures four viewport
/// heights, so the wider eviction band keeps warm rows resident without tying
/// Markdown lifetime to measurement.
const MARKDOWN_VIEWPORT_TURN_HINT: usize = 8;
const MARKDOWN_BUILD_MARGIN_TURNS: usize = 8;
/// Three build margins prevent back-and-forth scrolling from rebuilding the
/// same parsed documents at the edge of the warm window.
const MARKDOWN_EVICT_MARGIN_TURNS: usize = 24;
/// The composer-adjacent tail stays ready even while inspecting old turns.
const MARKDOWN_TAIL_PIN_TURNS: usize = 2;

pub struct ChatView {
workspace_store: Entity<WorkspaceStore>,
window_state: Entity<WindowState>,
Expand DownExpand Up@@ -228,8 +220,7 @@ impl ChatView {
offset_in_item: px(0.),
});
self.highlighted_turn = Some(turn);
self.markdown_visible_turns =
turn..(turn + MARKDOWN_VIEWPORT_TURN_HINT).min(self.turn_items.len());
self.markdown_visible_turns = viewport_turn_window(turn, self.turn_items.len());
self.markdown_scroll_top = Some(turn);
if let Some(session_id) = self.session_key.as_deref() {
self.workspace_store.update(cx, |store, _cx| {
Expand All@@ -238,7 +229,7 @@ impl ChatView {
}
}

self.sync_markdown_residency(cx);
self.sync_markdown_residency(requested_turn, cx);

// Keep a 100ms ticker alive while a turn runs so the live elapsed timer
// advances at decisecond precision; dropping it cancels the task.
Expand All@@ -256,97 +247,103 @@ impl ChatView {
}
}

fn sync_markdown_residency(&mut self, cx: &mut Context<Self>) {
fn sync_markdown_residency(
&mut self,
one_shot_turn_target: Option<usize>,
cx: &mut Context<Self>,
) {
let turn_count = self.turn_items.len();
let build_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_BUILD_MARGIN_TURNS,
turn_count,
);
let keep_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_EVICT_MARGIN_TURNS,
turn_count,
);
let tail_start = turn_count.saturating_sub(MARKDOWN_TAIL_PIN_TURNS);
let (texts, keep_ids) = self
// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let mut selection_drag_active = false;
let mut selection_participants = HashSet::new();
for (id, md) in &self.md_states {
let state = md.state.read(cx);
let selection = state.selection_handle();
let snapshot = selection.snapshot(cx);
selection_drag_active |= snapshot
.as_ref()
.is_some_and(|selection| selection.is_selecting());
if snapshot.is_some() || selection.has_local_selection(cx) {
selection_participants.insert(id.clone());
}
}
let resident_ids = self.md_states.keys().cloned().collect();
let (texts, decisions) = self
.workspace_store
.read(cx)
.with_active_timeline(|timeline| {
let pinned = |turn: usize| {
(turn_count > 0 && turn >= tail_start)
|| timeline.turns.get(turn).is_some_and(|turn| turn.running)
|| (timeline.turn_running
&& turn_count.checked_sub(1).is_some_and(|last| turn == last))
};
let mut entries = Vec::new();
for entry in &timeline.entries {
let markdown_bearing = matches!(
entry.content,
EntryContent::Item(ItemContent::AssistantMessage { .. })
| EntryContent::Item(ItemContent::Reasoning { .. })
) || user_content(&entry.content).is_some();
if markdown_bearing {
entries.push(MarkdownEntry {
id: entry.id.clone(),
turn: entry.turn,
turn_running: timeline
.turns
.get(entry.turn)
.is_some_and(|turn| turn.running),
});
}
}
if let Some(plan) = &timeline.proposed_plan {
entries.push(MarkdownEntry {
id: format!("plan:{}", plan.item_id),
turn: plan.turn,
turn_running: timeline
.turns
.get(plan.turn)
.is_some_and(|turn| turn.running),
});
}
let decisions = decide(ResidencyInput {
turn_count,
visible_turns: self.markdown_visible_turns.clone(),
one_shot_turn_target,
entries: &entries,
stream_running: timeline.turn_running,
resident_ids: &resident_ids,
selection_participants: &selection_participants,
selection_drag_active,
});
let mut texts = Vec::new();
let mut keep_ids = HashSet::new();
for entry in &timeline.entries {
let keep = keep_turns.contains(&entry.turn) || pinned(entry.turn);
let build = build_turns.contains(&entry.turn) || pinned(entry.turn);
if !keep && !build {
if !decisions.build.contains(&entry.id) {
continue;
}
match &entry.content {
EntryContent::Item(ItemContent::AssistantMessage { text })
| EntryContent::Item(ItemContent::Reasoning { text }) => {
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
content => {
let Some((text, _, context_len, _)) = user_content(content) else {
continue;
};
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
}
}
if let Some(plan) = &timeline.proposed_plan {
let id = format!("plan:{}", plan.item_id);
if keep_turns.contains(&plan.turn) || pinned(plan.turn) {
keep_ids.insert(id.clone());
}
if build_turns.contains(&plan.turn) || pinned(plan.turn) {
if decisions.build.contains(&id) {
texts.push((plan.turn, id, plan.markdown.clone()));
}
}
(texts, keep_ids)
(texts, decisions)
})
.unwrap_or_default();

// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let selection_drag_active = self.md_states.values().any(|md| {
md.state
.read(cx)
.selection_handle()
.snapshot(cx)
.is_some_and(|selection| selection.is_selecting())
});
if !selection_drag_active {
self.md_states.retain(|id, md| {
if keep_ids.contains(id) {
return true;
}
let state = md.state.read(cx);
let selection = state.selection_handle();
selection.snapshot(cx).is_some() || selection.has_local_selection(cx)
});
}
self.md_states.retain(|id, _| !decisions.evict.contains(id));

let mut rebuilt_turns = HashSet::new();
for (turn, id, text) in texts {
Expand DownExpand Up@@ -389,7 +386,7 @@ impl ChatView {
return;
}
self.markdown_visible_turns = visible_turns;
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
cx.notify();
}

Expand All@@ -403,9 +400,9 @@ impl ChatView {
self.markdown_visible_turns = if scroll_top == turn_count {
tail_turn_window(turn_count)
} else {
scroll_top..(scroll_top + MARKDOWN_VIEWPORT_TURN_HINT).min(turn_count)
viewport_turn_window(scroll_top, turn_count)
};
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
}

#[cfg(test)]
Expand DownExpand Up@@ -1873,19 +1870,6 @@ impl Render for ChatView {
// Helpers
// ---------------------------------------------------------------------------

fn tail_turn_window(turn_count: usize) -> Range<usize> {
turn_count.saturating_sub(MARKDOWN_VIEWPORT_TURN_HINT)..turn_count
}

fn expand_turn_window(window: Range<usize>, margin: usize, turn_count: usize) -> Range<usize> {
window.start.min(turn_count).saturating_sub(margin)
..window
.end
.min(turn_count)
.saturating_add(margin)
.min(turn_count)
}

/// Launch `zed <cwd>` detached; surface a notification if the CLI is missing.
/// The leading icon for a git quick-action.
fn git_action_icon(action: GitAction) -> Icon {
Expand DownExpand Up@@ -1958,29 +1942,7 @@ mod tests {
static NEXT_RESIDENCY_TEST_ID: AtomicU64 = AtomicU64::new(0);

#[gpui::test]
fn long_session_keeps_tail_markdown_residency_bounded(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, _) = seed_chat(cx, timeline);
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});

let resident = view.read_with(cx, |chat, _| chat.resident_markdown_state_count());
assert_eq!(resident, 48);
assert!(
resident <= 96,
"tail window retained {resident} MarkdownStates; expected at most 96"
);
}

#[gpui::test]
fn scrolling_to_old_turn_rebuilds_markdown_and_evicts_distant_tail(cx: &mut TestAppContext) {
fn chat_view_applies_markdown_residency_decisions(cx: &mut TestAppContext) {
use gpui::{FollowMode, ListOffset, VisualTestContext, px, size};

const TARGET: usize = 40;
Expand All@@ -1993,6 +1955,10 @@ mod tests {
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert_eq!(
view.read_with(cx, |chat, _| chat.resident_markdown_state_count()),
48
);
let list_state = view.read_with(cx, |chat, _| chat.list_state.clone());
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-40")
Expand DownExpand Up@@ -2044,57 +2010,6 @@ mod tests {
);
}

#[gpui::test]
fn turn_target_jump_rebuilds_an_evicted_region(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

const TARGET: usize = 20;
let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, session_id) = seed_chat(cx, timeline);
let target_store = workspace_store.clone();
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-20")
}));

target_store.update(cx, |store, cx| {
store.select_session_at_turn(session_id.clone(), TARGET);
cx.notify();
});
cx.run_until_parked();

let (highlighted, resident, target_resident, distant_tail_evicted, scroll_top) = view
.read_with(cx, |chat, _| {
(
chat.highlighted_turn,
chat.resident_markdown_state_count(),
chat.has_resident_markdown_state("assistant-20"),
!chat.has_resident_markdown_state("assistant-230"),
chat.list_state.logical_scroll_top(),
)
});
assert_eq!(highlighted, Some(TARGET));
assert_eq!(resident, 78);
assert!(target_resident, "the targeted turn was not rebuilt");
assert!(
distant_tail_evicted,
"the target jump retained a tail state beyond the hysteresis band"
);
assert_eq!(scroll_top.item_ix, TARGET);
assert_eq!(scroll_top.offset_in_item, px(0.));
assert_eq!(
target_store.read_with(cx, |store, _| store.pending_chat_turn(&session_id)),
None,
"the one-shot target was not consumed"
);
}

#[gpui::test]
fn long_markdown_paints_middle_blocks_when_scrolled_in_chat_outer_list(
cx: &mut TestAppContext,
Expand Down
Loading
, '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
255 changes: 85 additions & 170 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};

pub(crate) mod components;
mod model;
mod residency;

use crate::overlay::{Notification, OverlayExt as _};
use crate::theme::ActiveTheme as _;
Expand DownExpand Up@@ -51,6 +52,9 @@ use self::model::{
user_visible_text, work_log_auto_expands, work_log_capsule_label, work_log_counts,
work_log_outcome, work_log_row_entries,
};
use self::residency::{
MarkdownEntry, ResidencyInput, decide, tail_turn_window, viewport_turn_window,
};
pub(crate) use crate::material::{
CHAT_CONTENT_MAX_WIDTH as CONTENT_MAX_WIDTH, CHAT_CONTENT_MIN_PADDING as CONTENT_MIN_PADDING,
};
Expand All@@ -61,18 +65,6 @@ const TRAFFIC_LIGHT_INSET: f32 = 80.;
/// Vertical rhythm between turns. Turns are separated by space and typographic
/// hierarchy alone — there is deliberately no rule/divider under the user bubble.
const TURN_GAP: f32 = 32.;
/// Before GPUI reports its exact visible range, eight turns is comfortably
/// more than a typical chat viewport. Its list also pre-measures four viewport
/// heights, so the wider eviction band keeps warm rows resident without tying
/// Markdown lifetime to measurement.
const MARKDOWN_VIEWPORT_TURN_HINT: usize = 8;
const MARKDOWN_BUILD_MARGIN_TURNS: usize = 8;
/// Three build margins prevent back-and-forth scrolling from rebuilding the
/// same parsed documents at the edge of the warm window.
const MARKDOWN_EVICT_MARGIN_TURNS: usize = 24;
/// The composer-adjacent tail stays ready even while inspecting old turns.
const MARKDOWN_TAIL_PIN_TURNS: usize = 2;

pub struct ChatView {
workspace_store: Entity<WorkspaceStore>,
window_state: Entity<WindowState>,
Expand DownExpand Up@@ -228,8 +220,7 @@ impl ChatView {
offset_in_item: px(0.),
});
self.highlighted_turn = Some(turn);
self.markdown_visible_turns =
turn..(turn + MARKDOWN_VIEWPORT_TURN_HINT).min(self.turn_items.len());
self.markdown_visible_turns = viewport_turn_window(turn, self.turn_items.len());
self.markdown_scroll_top = Some(turn);
if let Some(session_id) = self.session_key.as_deref() {
self.workspace_store.update(cx, |store, _cx| {
Expand All@@ -238,7 +229,7 @@ impl ChatView {
}
}

self.sync_markdown_residency(cx);
self.sync_markdown_residency(requested_turn, cx);

// Keep a 100ms ticker alive while a turn runs so the live elapsed timer
// advances at decisecond precision; dropping it cancels the task.
Expand All@@ -256,97 +247,103 @@ impl ChatView {
}
}

fn sync_markdown_residency(&mut self, cx: &mut Context<Self>) {
fn sync_markdown_residency(
&mut self,
one_shot_turn_target: Option<usize>,
cx: &mut Context<Self>,
) {
let turn_count = self.turn_items.len();
let build_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_BUILD_MARGIN_TURNS,
turn_count,
);
let keep_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_EVICT_MARGIN_TURNS,
turn_count,
);
let tail_start = turn_count.saturating_sub(MARKDOWN_TAIL_PIN_TURNS);
let (texts, keep_ids) = self
// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let mut selection_drag_active = false;
let mut selection_participants = HashSet::new();
for (id, md) in &self.md_states {
let state = md.state.read(cx);
let selection = state.selection_handle();
let snapshot = selection.snapshot(cx);
selection_drag_active |= snapshot
.as_ref()
.is_some_and(|selection| selection.is_selecting());
if snapshot.is_some() || selection.has_local_selection(cx) {
selection_participants.insert(id.clone());
}
}
let resident_ids = self.md_states.keys().cloned().collect();
let (texts, decisions) = self
.workspace_store
.read(cx)
.with_active_timeline(|timeline| {
let pinned = |turn: usize| {
(turn_count > 0 && turn >= tail_start)
|| timeline.turns.get(turn).is_some_and(|turn| turn.running)
|| (timeline.turn_running
&& turn_count.checked_sub(1).is_some_and(|last| turn == last))
};
let mut entries = Vec::new();
for entry in &timeline.entries {
let markdown_bearing = matches!(
entry.content,
EntryContent::Item(ItemContent::AssistantMessage { .. })
| EntryContent::Item(ItemContent::Reasoning { .. })
) || user_content(&entry.content).is_some();
if markdown_bearing {
entries.push(MarkdownEntry {
id: entry.id.clone(),
turn: entry.turn,
turn_running: timeline
.turns
.get(entry.turn)
.is_some_and(|turn| turn.running),
});
}
}
if let Some(plan) = &timeline.proposed_plan {
entries.push(MarkdownEntry {
id: format!("plan:{}", plan.item_id),
turn: plan.turn,
turn_running: timeline
.turns
.get(plan.turn)
.is_some_and(|turn| turn.running),
});
}
let decisions = decide(ResidencyInput {
turn_count,
visible_turns: self.markdown_visible_turns.clone(),
one_shot_turn_target,
entries: &entries,
stream_running: timeline.turn_running,
resident_ids: &resident_ids,
selection_participants: &selection_participants,
selection_drag_active,
});
let mut texts = Vec::new();
let mut keep_ids = HashSet::new();
for entry in &timeline.entries {
let keep = keep_turns.contains(&entry.turn) || pinned(entry.turn);
let build = build_turns.contains(&entry.turn) || pinned(entry.turn);
if !keep && !build {
if !decisions.build.contains(&entry.id) {
continue;
}
match &entry.content {
EntryContent::Item(ItemContent::AssistantMessage { text })
| EntryContent::Item(ItemContent::Reasoning { text }) => {
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
content => {
let Some((text, _, context_len, _)) = user_content(content) else {
continue;
};
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
}
}
if let Some(plan) = &timeline.proposed_plan {
let id = format!("plan:{}", plan.item_id);
if keep_turns.contains(&plan.turn) || pinned(plan.turn) {
keep_ids.insert(id.clone());
}
if build_turns.contains(&plan.turn) || pinned(plan.turn) {
if decisions.build.contains(&id) {
texts.push((plan.turn, id, plan.markdown.clone()));
}
}
(texts, keep_ids)
(texts, decisions)
})
.unwrap_or_default();

// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let selection_drag_active = self.md_states.values().any(|md| {
md.state
.read(cx)
.selection_handle()
.snapshot(cx)
.is_some_and(|selection| selection.is_selecting())
});
if !selection_drag_active {
self.md_states.retain(|id, md| {
if keep_ids.contains(id) {
return true;
}
let state = md.state.read(cx);
let selection = state.selection_handle();
selection.snapshot(cx).is_some() || selection.has_local_selection(cx)
});
}
self.md_states.retain(|id, _| !decisions.evict.contains(id));

let mut rebuilt_turns = HashSet::new();
for (turn, id, text) in texts {
Expand DownExpand Up@@ -389,7 +386,7 @@ impl ChatView {
return;
}
self.markdown_visible_turns = visible_turns;
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
cx.notify();
}

Expand All@@ -403,9 +400,9 @@ impl ChatView {
self.markdown_visible_turns = if scroll_top == turn_count {
tail_turn_window(turn_count)
} else {
scroll_top..(scroll_top + MARKDOWN_VIEWPORT_TURN_HINT).min(turn_count)
viewport_turn_window(scroll_top, turn_count)
};
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
}

#[cfg(test)]
Expand DownExpand Up@@ -1873,19 +1870,6 @@ impl Render for ChatView {
// Helpers
// ---------------------------------------------------------------------------

fn tail_turn_window(turn_count: usize) -> Range<usize> {
turn_count.saturating_sub(MARKDOWN_VIEWPORT_TURN_HINT)..turn_count
}

fn expand_turn_window(window: Range<usize>, margin: usize, turn_count: usize) -> Range<usize> {
window.start.min(turn_count).saturating_sub(margin)
..window
.end
.min(turn_count)
.saturating_add(margin)
.min(turn_count)
}

/// Launch `zed <cwd>` detached; surface a notification if the CLI is missing.
/// The leading icon for a git quick-action.
fn git_action_icon(action: GitAction) -> Icon {
Expand DownExpand Up@@ -1958,29 +1942,7 @@ mod tests {
static NEXT_RESIDENCY_TEST_ID: AtomicU64 = AtomicU64::new(0);

#[gpui::test]
fn long_session_keeps_tail_markdown_residency_bounded(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, _) = seed_chat(cx, timeline);
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});

let resident = view.read_with(cx, |chat, _| chat.resident_markdown_state_count());
assert_eq!(resident, 48);
assert!(
resident <= 96,
"tail window retained {resident} MarkdownStates; expected at most 96"
);
}

#[gpui::test]
fn scrolling_to_old_turn_rebuilds_markdown_and_evicts_distant_tail(cx: &mut TestAppContext) {
fn chat_view_applies_markdown_residency_decisions(cx: &mut TestAppContext) {
use gpui::{FollowMode, ListOffset, VisualTestContext, px, size};

const TARGET: usize = 40;
Expand All@@ -1993,6 +1955,10 @@ mod tests {
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert_eq!(
view.read_with(cx, |chat, _| chat.resident_markdown_state_count()),
48
);
let list_state = view.read_with(cx, |chat, _| chat.list_state.clone());
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-40")
Expand DownExpand Up@@ -2044,57 +2010,6 @@ mod tests {
);
}

#[gpui::test]
fn turn_target_jump_rebuilds_an_evicted_region(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

const TARGET: usize = 20;
let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, session_id) = seed_chat(cx, timeline);
let target_store = workspace_store.clone();
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-20")
}));

target_store.update(cx, |store, cx| {
store.select_session_at_turn(session_id.clone(), TARGET);
cx.notify();
});
cx.run_until_parked();

let (highlighted, resident, target_resident, distant_tail_evicted, scroll_top) = view
.read_with(cx, |chat, _| {
(
chat.highlighted_turn,
chat.resident_markdown_state_count(),
chat.has_resident_markdown_state("assistant-20"),
!chat.has_resident_markdown_state("assistant-230"),
chat.list_state.logical_scroll_top(),
)
});
assert_eq!(highlighted, Some(TARGET));
assert_eq!(resident, 78);
assert!(target_resident, "the targeted turn was not rebuilt");
assert!(
distant_tail_evicted,
"the target jump retained a tail state beyond the hysteresis band"
);
assert_eq!(scroll_top.item_ix, TARGET);
assert_eq!(scroll_top.offset_in_item, px(0.));
assert_eq!(
target_store.read_with(cx, |store, _| store.pending_chat_turn(&session_id)),
None,
"the one-shot target was not consumed"
);
}

#[gpui::test]
fn long_markdown_paints_middle_blocks_when_scrolled_in_chat_outer_list(
cx: &mut TestAppContext,
Expand Down
Loading
, '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
255 changes: 85 additions & 170 deletions crates/ui/src/chat/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};

pub(crate) mod components;
mod model;
mod residency;

use crate::overlay::{Notification, OverlayExt as _};
use crate::theme::ActiveTheme as _;
Expand DownExpand Up@@ -51,6 +52,9 @@ use self::model::{
user_visible_text, work_log_auto_expands, work_log_capsule_label, work_log_counts,
work_log_outcome, work_log_row_entries,
};
use self::residency::{
MarkdownEntry, ResidencyInput, decide, tail_turn_window, viewport_turn_window,
};
pub(crate) use crate::material::{
CHAT_CONTENT_MAX_WIDTH as CONTENT_MAX_WIDTH, CHAT_CONTENT_MIN_PADDING as CONTENT_MIN_PADDING,
};
Expand All@@ -61,18 +65,6 @@ const TRAFFIC_LIGHT_INSET: f32 = 80.;
/// Vertical rhythm between turns. Turns are separated by space and typographic
/// hierarchy alone — there is deliberately no rule/divider under the user bubble.
const TURN_GAP: f32 = 32.;
/// Before GPUI reports its exact visible range, eight turns is comfortably
/// more than a typical chat viewport. Its list also pre-measures four viewport
/// heights, so the wider eviction band keeps warm rows resident without tying
/// Markdown lifetime to measurement.
const MARKDOWN_VIEWPORT_TURN_HINT: usize = 8;
const MARKDOWN_BUILD_MARGIN_TURNS: usize = 8;
/// Three build margins prevent back-and-forth scrolling from rebuilding the
/// same parsed documents at the edge of the warm window.
const MARKDOWN_EVICT_MARGIN_TURNS: usize = 24;
/// The composer-adjacent tail stays ready even while inspecting old turns.
const MARKDOWN_TAIL_PIN_TURNS: usize = 2;

pub struct ChatView {
workspace_store: Entity<WorkspaceStore>,
window_state: Entity<WindowState>,
Expand DownExpand Up@@ -228,8 +220,7 @@ impl ChatView {
offset_in_item: px(0.),
});
self.highlighted_turn = Some(turn);
self.markdown_visible_turns =
turn..(turn + MARKDOWN_VIEWPORT_TURN_HINT).min(self.turn_items.len());
self.markdown_visible_turns = viewport_turn_window(turn, self.turn_items.len());
self.markdown_scroll_top = Some(turn);
if let Some(session_id) = self.session_key.as_deref() {
self.workspace_store.update(cx, |store, _cx| {
Expand All@@ -238,7 +229,7 @@ impl ChatView {
}
}

self.sync_markdown_residency(cx);
self.sync_markdown_residency(requested_turn, cx);

// Keep a 100ms ticker alive while a turn runs so the live elapsed timer
// advances at decisecond precision; dropping it cancels the task.
Expand All@@ -256,97 +247,103 @@ impl ChatView {
}
}

fn sync_markdown_residency(&mut self, cx: &mut Context<Self>) {
fn sync_markdown_residency(
&mut self,
one_shot_turn_target: Option<usize>,
cx: &mut Context<Self>,
) {
let turn_count = self.turn_items.len();
let build_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_BUILD_MARGIN_TURNS,
turn_count,
);
let keep_turns = expand_turn_window(
self.markdown_visible_turns.clone(),
MARKDOWN_EVICT_MARGIN_TURNS,
turn_count,
);
let tail_start = turn_count.saturating_sub(MARKDOWN_TAIL_PIN_TURNS);
let (texts, keep_ids) = self
// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let mut selection_drag_active = false;
let mut selection_participants = HashSet::new();
for (id, md) in &self.md_states {
let state = md.state.read(cx);
let selection = state.selection_handle();
let snapshot = selection.snapshot(cx);
selection_drag_active |= snapshot
.as_ref()
.is_some_and(|selection| selection.is_selecting());
if snapshot.is_some() || selection.has_local_selection(cx) {
selection_participants.insert(id.clone());
}
}
let resident_ids = self.md_states.keys().cloned().collect();
let (texts, decisions) = self
.workspace_store
.read(cx)
.with_active_timeline(|timeline| {
let pinned = |turn: usize| {
(turn_count > 0 && turn >= tail_start)
|| timeline.turns.get(turn).is_some_and(|turn| turn.running)
|| (timeline.turn_running
&& turn_count.checked_sub(1).is_some_and(|last| turn == last))
};
let mut entries = Vec::new();
for entry in &timeline.entries {
let markdown_bearing = matches!(
entry.content,
EntryContent::Item(ItemContent::AssistantMessage { .. })
| EntryContent::Item(ItemContent::Reasoning { .. })
) || user_content(&entry.content).is_some();
if markdown_bearing {
entries.push(MarkdownEntry {
id: entry.id.clone(),
turn: entry.turn,
turn_running: timeline
.turns
.get(entry.turn)
.is_some_and(|turn| turn.running),
});
}
}
if let Some(plan) = &timeline.proposed_plan {
entries.push(MarkdownEntry {
id: format!("plan:{}", plan.item_id),
turn: plan.turn,
turn_running: timeline
.turns
.get(plan.turn)
.is_some_and(|turn| turn.running),
});
}
let decisions = decide(ResidencyInput {
turn_count,
visible_turns: self.markdown_visible_turns.clone(),
one_shot_turn_target,
entries: &entries,
stream_running: timeline.turn_running,
resident_ids: &resident_ids,
selection_participants: &selection_participants,
selection_drag_active,
});
let mut texts = Vec::new();
let mut keep_ids = HashSet::new();
for entry in &timeline.entries {
let keep = keep_turns.contains(&entry.turn) || pinned(entry.turn);
let build = build_turns.contains(&entry.turn) || pinned(entry.turn);
if !keep && !build {
if !decisions.build.contains(&entry.id) {
continue;
}
match &entry.content {
EntryContent::Item(ItemContent::AssistantMessage { text })
| EntryContent::Item(ItemContent::Reasoning { text }) => {
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
texts.push((entry.turn, entry.id.clone(), text.clone()));
}
content => {
let Some((text, _, context_len, _)) = user_content(content) else {
continue;
};
if keep {
keep_ids.insert(entry.id.clone());
}
if build {
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
texts.push((
entry.turn,
entry.id.clone(),
plain_text_as_markdown(user_visible_text(text, context_len)),
));
}
}
}
if let Some(plan) = &timeline.proposed_plan {
let id = format!("plan:{}", plan.item_id);
if keep_turns.contains(&plan.turn) || pinned(plan.turn) {
keep_ids.insert(id.clone());
}
if build_turns.contains(&plan.turn) || pinned(plan.turn) {
if decisions.build.contains(&id) {
texts.push((plan.turn, id, plan.markdown.clone()));
}
}
(texts, keep_ids)
(texts, decisions)
})
.unwrap_or_default();

// Auto-scroll can move many rows during a drag. Do not retire any
// participant until mouse-up; completed-selection participants remain
// pinned individually below so copy keeps its full projection.
let selection_drag_active = self.md_states.values().any(|md| {
md.state
.read(cx)
.selection_handle()
.snapshot(cx)
.is_some_and(|selection| selection.is_selecting())
});
if !selection_drag_active {
self.md_states.retain(|id, md| {
if keep_ids.contains(id) {
return true;
}
let state = md.state.read(cx);
let selection = state.selection_handle();
selection.snapshot(cx).is_some() || selection.has_local_selection(cx)
});
}
self.md_states.retain(|id, _| !decisions.evict.contains(id));

let mut rebuilt_turns = HashSet::new();
for (turn, id, text) in texts {
Expand DownExpand Up@@ -389,7 +386,7 @@ impl ChatView {
return;
}
self.markdown_visible_turns = visible_turns;
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
cx.notify();
}

Expand All@@ -403,9 +400,9 @@ impl ChatView {
self.markdown_visible_turns = if scroll_top == turn_count {
tail_turn_window(turn_count)
} else {
scroll_top..(scroll_top + MARKDOWN_VIEWPORT_TURN_HINT).min(turn_count)
viewport_turn_window(scroll_top, turn_count)
};
self.sync_markdown_residency(cx);
self.sync_markdown_residency(None, cx);
}

#[cfg(test)]
Expand DownExpand Up@@ -1873,19 +1870,6 @@ impl Render for ChatView {
// Helpers
// ---------------------------------------------------------------------------

fn tail_turn_window(turn_count: usize) -> Range<usize> {
turn_count.saturating_sub(MARKDOWN_VIEWPORT_TURN_HINT)..turn_count
}

fn expand_turn_window(window: Range<usize>, margin: usize, turn_count: usize) -> Range<usize> {
window.start.min(turn_count).saturating_sub(margin)
..window
.end
.min(turn_count)
.saturating_add(margin)
.min(turn_count)
}

/// Launch `zed <cwd>` detached; surface a notification if the CLI is missing.
/// The leading icon for a git quick-action.
fn git_action_icon(action: GitAction) -> Icon {
Expand DownExpand Up@@ -1958,29 +1942,7 @@ mod tests {
static NEXT_RESIDENCY_TEST_ID: AtomicU64 = AtomicU64::new(0);

#[gpui::test]
fn long_session_keeps_tail_markdown_residency_bounded(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, _) = seed_chat(cx, timeline);
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});

let resident = view.read_with(cx, |chat, _| chat.resident_markdown_state_count());
assert_eq!(resident, 48);
assert!(
resident <= 96,
"tail window retained {resident} MarkdownStates; expected at most 96"
);
}

#[gpui::test]
fn scrolling_to_old_turn_rebuilds_markdown_and_evicts_distant_tail(cx: &mut TestAppContext) {
fn chat_view_applies_markdown_residency_decisions(cx: &mut TestAppContext) {
use gpui::{FollowMode, ListOffset, VisualTestContext, px, size};

const TARGET: usize = 40;
Expand All@@ -1993,6 +1955,10 @@ mod tests {
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert_eq!(
view.read_with(cx, |chat, _| chat.resident_markdown_state_count()),
48
);
let list_state = view.read_with(cx, |chat, _| chat.list_state.clone());
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-40")
Expand DownExpand Up@@ -2044,57 +2010,6 @@ mod tests {
);
}

#[gpui::test]
fn turn_target_jump_rebuilds_an_evicted_region(cx: &mut TestAppContext) {
use gpui::{VisualTestContext, px, size};

const TARGET: usize = 20;
let timeline = synthetic_markdown_timeline(240);
let (workspace_store, window_state, session_id) = seed_chat(cx, timeline);
let target_store = workspace_store.clone();
let (view, cx) = cx
.add_window_view(|window, cx| ChatView::new(workspace_store, window_state, window, cx));
let cx: &mut VisualTestContext = cx;
cx.simulate_resize(size(px(1_024.), px(700.)));
cx.update(|window, cx| {
let _ = window.draw(cx);
});
assert!(!view.read_with(cx, |chat, _| {
chat.has_resident_markdown_state("assistant-20")
}));

target_store.update(cx, |store, cx| {
store.select_session_at_turn(session_id.clone(), TARGET);
cx.notify();
});
cx.run_until_parked();

let (highlighted, resident, target_resident, distant_tail_evicted, scroll_top) = view
.read_with(cx, |chat, _| {
(
chat.highlighted_turn,
chat.resident_markdown_state_count(),
chat.has_resident_markdown_state("assistant-20"),
!chat.has_resident_markdown_state("assistant-230"),
chat.list_state.logical_scroll_top(),
)
});
assert_eq!(highlighted, Some(TARGET));
assert_eq!(resident, 78);
assert!(target_resident, "the targeted turn was not rebuilt");
assert!(
distant_tail_evicted,
"the target jump retained a tail state beyond the hysteresis band"
);
assert_eq!(scroll_top.item_ix, TARGET);
assert_eq!(scroll_top.offset_in_item, px(0.));
assert_eq!(
target_store.read_with(cx, |store, _| store.pending_chat_turn(&session_id)),
None,
"the one-shot target was not consumed"
);
}

#[gpui::test]
fn long_markdown_paints_middle_blocks_when_scrolled_in_chat_outer_list(
cx: &mut TestAppContext,
Expand Down
Loading