From 8ba694101639dfed677b91b51bf360268c238e63 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:36:40 +0800 Subject: [PATCH] feat(agents): Select, copy, quote and search the conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Conversation text can be selected and copied. - Selected text gets a Quote button that replies to it in the composer. - Messages have Copy on hover; your own messages also have Edit and Resend and Send Again. - Code blocks are highlighted and have a Copy button. - File paths in replies (`src/lib.rs:12`) open in the editor when the file exists. - Find in Conversation (⌘F / Ctrl+F) highlights every match and steps through them, opening collapsed tool output. - Jump to Bottom appears after scrolling up. - Your messages show when they were sent and how long the agent took; a running turn shows its elapsed time. --- crates/ide-agent/src/lib.rs | 11 +- crates/ide-agent/src/model.rs | 6 + crates/ide-agent/src/tests.rs | 9 +- crates/ide-agent/src/timeline.rs | 27 +- src/bindings.ts | 6 +- src/features/agents/Composer.tsx | 9 + src/features/agents/Markdown.tsx | 90 +++++- src/features/agents/Session.module.css | 177 +++++++++++ src/features/agents/SessionEditor.tsx | 379 ++++++++++++++++++++--- src/features/agents/conversation.test.ts | 50 +++ src/features/agents/conversation.ts | 88 ++++++ src/features/agents/index.ts | 9 + src/features/agents/sessionFocus.ts | 15 + src/features/agents/store.ts | 26 ++ src/platform/keymaps/intellij-macos.ts | 1 + src/platform/keymaps/intellij-windows.ts | 1 + src/platform/keymaps/vscode-macos.ts | 1 + src/platform/locales/text/zh.json | 11 +- 18 files changed, 867 insertions(+), 49 deletions(-) create mode 100644 src/features/agents/conversation.test.ts create mode 100644 src/features/agents/conversation.ts create mode 100644 src/features/agents/sessionFocus.ts diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index ca7ed203c..b73196c05 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -232,7 +232,7 @@ impl AgentManager { if matches!(f.task.status, AgentStatus::Running | AgentStatus::NeedsInput | AgentStatus::Starting) { f.task.status = AgentStatus::Ready; let mut timeline = Timeline::from_entries(std::mem::take(&mut f.entries)); - timeline.end_turn(); + timeline.end_turn(None); f.entries = timeline.entries; } let task = Task::restore(f, shared.clone()); @@ -658,7 +658,7 @@ impl Task { loop { let result = self.turn(&prompt).await; tokio::time::sleep(TURN_GRACE).await; - let changed = self.state.lock().timeline.end_turn(); + let changed = self.state.lock().timeline.end_turn(Some(now_ms())); self.mark_dirty(changed); let send_now = self.send_now.swap(false, Ordering::AcqRel); let next = { @@ -702,7 +702,12 @@ impl Task { .map(|f| file_name(&f.0)) .chain(prompt.images.iter().map(|i| i.name.clone().unwrap_or_else(|| "image".into()))) .collect(); - self.push_entry(AgentEntry::User { text: prompt.text.clone(), attachments }); + self.push_entry(AgentEntry::User { + text: prompt.text.clone(), + attachments, + sent_ms: Some(now_ms()), + done_ms: None, + }); // Launching the agent can take a while (`npx` fetches its adapter on first use). let starting = self.live.lock().as_ref().is_none_or(|l| l.peer.is_closed()); self.set_status(if starting { AgentStatus::Starting } else { AgentStatus::Running }, None); diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index 17ed1b890..50276ca67 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -194,6 +194,12 @@ pub enum AgentEntry { text: String, /// Names of the files and images sent with it. attachments: Vec, + /// When it was sent (ms since the epoch); `None` in tasks saved before this was kept. + #[serde(default)] + sent_ms: Option, + /// When the turn it started ended; `None` while it runs or when it was cut off. + #[serde(default)] + done_ms: Option, }, Agent { text: String, diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index b98428bca..5075cb283 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -238,10 +238,17 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() { events.iter().any(|e| matches!(e, AgentEvent::Task { task } if task.status == AgentStatus::NeedsInput)) ); let last_entry = events.iter().rev().find_map(|e| match e { - AgentEvent::Entry { index, entry, .. } => Some((*index, entry.clone())), + AgentEvent::Entry { index, entry, .. } if !matches!(entry, AgentEntry::User { .. }) => { + Some((*index, entry.clone())) + } _ => None, }); assert_eq!(last_entry, Some((4, AgentEntry::Agent { text: "chose yes".into() }))); + // The turn's end is recorded on the message that started it. + assert!(events.iter().any(|e| matches!( + e, + AgentEvent::Entry { index: 0, entry: AgentEntry::User { sent_ms: Some(_), done_ms: Some(_), .. }, .. } + ))); } let providers = mgr.providers(); let claude = providers.iter().find(|p| p.id == "claude").unwrap(); diff --git a/crates/ide-agent/src/timeline.rs b/crates/ide-agent/src/timeline.rs index e57012359..1ab7c96f1 100644 --- a/crates/ide-agent/src/timeline.rs +++ b/crates/ide-agent/src/timeline.rs @@ -166,11 +166,17 @@ impl Timeline { } } - /// Settles what an ended turn left open: running tools and pending permissions. - pub fn end_turn(&mut self) -> Vec { + /// Settles what an ended turn left open: running tools and pending permissions, + /// and records when the turn ended (`done`) on the message that started it. + pub fn end_turn(&mut self, done: Option) -> Vec { let mut changed = Vec::new(); + let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { .. })); for (i, e) in self.entries.iter_mut().enumerate() { match e { + AgentEntry::User { done_ms, .. } if Some(i) == started && done.is_some() && done_ms.is_none() => { + *done_ms = done; + changed.push(i); + } AgentEntry::Tool { status, .. } if matches!(status, AgentToolStatus::Pending | AgentToolStatus::Running) => { @@ -286,12 +292,27 @@ mod tests { assert_eq!(t.apply(&plan("completed")).changed, vec![0]); t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"})); t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None }); - assert_eq!(t.end_turn(), vec![2, 3]); + assert_eq!(t.end_turn(None), vec![2, 3]); let again = Timeline::from_entries(t.entries.clone()); assert_eq!(again.plan, Some(0)); assert_eq!(again.tools.get("c"), Some(&2)); } + #[test] + fn end_turn_records_when_the_last_message_was_answered() { + let user = + |sent: f64| AgentEntry::User { text: "q".into(), attachments: vec![], sent_ms: Some(sent), done_ms: None }; + let mut t = Timeline::default(); + t.push(user(1.0)); + t.push(AgentEntry::Agent { text: "a".into() }); + t.push(user(2.0)); + assert_eq!(t.end_turn(Some(5.0)), vec![2]); + assert!(matches!(t.entries[0], AgentEntry::User { done_ms: None, .. })); + assert!(matches!(t.entries[2], AgentEntry::User { done_ms: Some(5.0), .. })); + // Settled once: a later end (a restart) doesn't move it. + assert_eq!(t.end_turn(Some(9.0)), Vec::::new()); + } + #[test] fn long_output_keeps_its_tail() { let s = tail("é".repeat(MAX_OUTPUT)); diff --git a/src/bindings.ts b/src/bindings.ts index ab7bb6fba..fd5f5b6e0 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -986,7 +986,11 @@ export type AgentConfigVia = /** One item of a task's conversation. */ export type AgentEntry = { type: "user"; text: string; /** Names of the files and images sent with it. */ -attachments: string[] } | { type: "agent"; text: string } | { type: "thought"; text: string } | { type: "tool"; id: string; title: string; +attachments: string[]; +/** When it was sent (ms since the epoch); `None` in tasks saved before this was kept. */ +sentMs?: number | null; +/** When the turn it started ended; `None` while it runs or when it was cut off. */ +doneMs?: number | null } | { type: "agent"; text: string } | { type: "thought"; text: string } | { type: "tool"; id: string; title: string; /** ACP tool kind: `read`, `edit`, `execute`, `search`, `fetch`, `think`… */ kind: string; status: AgentToolStatus; locations: IpcPath[]; /** Text the tool reported (command output…), truncated. */ diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx index 710ab877e..ec6e14520 100644 --- a/src/features/agents/Composer.tsx +++ b/src/features/agents/Composer.tsx @@ -80,6 +80,15 @@ export function Composer(props: ComposerProps) { if (props.autoFocus) ref.current?.focus(); }, [props.autoFocus]); + const focus = useAgents((s) => (s.focus?.key === id ? s.focus.seq : 0)); + useEffect(() => { + const el = ref.current; + if (!focus || !el) return; + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + el.scrollTop = el.scrollHeight; + }, [focus]); + // Grow with the text, up to a limit. useLayoutEffect(() => { const el = ref.current; diff --git a/src/features/agents/Markdown.tsx b/src/features/agents/Markdown.tsx index c9dabd605..61529c506 100644 --- a/src/features/agents/Markdown.tsx +++ b/src/features/agents/Markdown.tsx @@ -1,8 +1,18 @@ -/** Agent messages: Markdown → sanitized HTML (marked + DOMPurify). */ -import { useMemo } from "react"; +/** + * Agent messages: Markdown → sanitized HTML (marked + DOMPurify). Fenced code is + * highlighted and gets a Copy button; inline code naming a file of the task opens it. + */ +import { useLayoutEffect, useMemo, useRef } from "react"; import { Marked } from "marked"; import DOMPurify from "dompurify"; +import { highlightInto } from "@/editor/lsp/markdown"; +import { iconNode } from "@/editor/lsp/iconMarkup"; +import { call, commands } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; import { openExternalUrl } from "@/platform/openUrl"; +import { copyText } from "@/ui"; +import { editorService } from "@/workbench/contributions"; +import { pathLink, type PathLink } from "./conversation"; import styles from "./Session.module.css"; const marked = new Marked({ gfm: true, breaks: true }); @@ -12,13 +22,85 @@ export function markdownHtml(text: string): string { return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }); } -export function Markdown({ text }: { text: string }) { +/** Whether a path names an existing file, per path (agents name the same files again and again). */ +const existing = new Map>(); +function fileExists(path: string): Promise { + let known = existing.get(path); + if (!known) { + known = call(commands.fsProbe(path)).then( + () => true, + () => false, + ); + existing.set(path, known); + } + return known; +} + +export function openPathLink(link: PathLink) { + const selection = link.line === undefined ? undefined : { line: link.line, col: link.col ?? 0 }; + void editorService().openFile(link.path, { selection, focus: true }); +} + +/** Highlighting, Copy buttons and file links over freshly rendered `root`. */ +function enhance(root: HTMLElement, cwd: string): () => void { + let alive = true; + root.querySelectorAll("pre > code").forEach((code) => { + const pre = code.parentElement!; + const lang = [...code.classList].find((c) => c.startsWith("language-"))?.slice("language-".length) ?? null; + const text = (code.textContent ?? "").replace(/\n+$/, ""); + code.textContent = text; + void highlightInto(code, text, lang); + const copy = document.createElement("button"); + copy.type = "button"; + copy.className = styles.codeCopy!; + const label = tx("Copy Code"); + copy.title = label; + copy.setAttribute("aria-label", label); + copy.appendChild(iconNode("copy", 14)); + copy.dataset.copy = text; + pre.appendChild(copy); + }); + root.querySelectorAll("code").forEach((code) => { + if (code.parentElement?.tagName === "PRE" || code.closest("a")) return; + const link = pathLink(code.textContent ?? "", cwd); + if (!link) return; + void fileExists(link.path).then((exists) => { + if (!alive || !exists) return; + code.classList.add(styles.fileLink!); + code.dataset.path = link.path; + if (link.line !== undefined) code.dataset.line = String(link.line); + if (link.col !== undefined) code.dataset.col = String(link.col); + code.title = tx`Open ${link.path}`; + }); + }); + return () => { + alive = false; + }; +} + +export function Markdown({ text, cwd }: { text: string; cwd: string }) { const html = useMemo(() => markdownHtml(text), [text]); + const ref = useRef(null); + useLayoutEffect(() => (ref.current ? enhance(ref.current, cwd) : undefined), [html, cwd]); return (
{ - const a = (e.target as HTMLElement).closest("a"); + const target = e.target as HTMLElement; + const copy = target.closest("button[data-copy]"); + if (copy) { + void copyText(copy.dataset.copy ?? ""); + return; + } + const file = target.closest("code[data-path]"); + if (file && !window.getSelection()?.toString()) { + const line = file.dataset.line === undefined ? undefined : Number(file.dataset.line); + const col = file.dataset.col === undefined ? undefined : Number(file.dataset.col); + openPathLink({ path: file.dataset.path!, line, col }); + return; + } + const a = target.closest("a"); if (!a) return; e.preventDefault(); if (/^https?:/i.test(a.href)) void openExternalUrl(a.href); diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index ef518cfb6..69b301e12 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -96,6 +96,15 @@ cursor: default; } +/* The conversation plus what floats over it (find bar, Quote, Jump to Bottom). */ +.streamArea { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + .stream { flex: 1; min-height: 0; @@ -105,6 +114,101 @@ flex-direction: column; gap: 10px; } +.stream:focus { + outline: none; +} + +/* A message with its actions, shown on hover. */ +.message { + position: relative; +} +.messageMeta { + display: flex; + align-items: center; + min-height: 20px; + padding-left: 12px; + font-size: var(--fs-small); +} +.messageActions { + display: inline-flex; + gap: 2px; + margin-left: 6px; + opacity: 0; + pointer-events: none; + -webkit-user-select: none; + user-select: none; +} +/* Over the bottom-right corner of an agent message (code blocks keep the top one), taking no room of its own. */ +.floatingActions { + position: absolute; + z-index: 1; + bottom: -14px; + right: 0; + margin: 0; + padding: 1px; + border: 1px solid var(--border); + border-radius: var(--radius-small); + background: var(--bg-popup); + box-shadow: var(--shadow-tooltip); +} +.message:hover .messageActions, +.messageActions:focus-within { + opacity: 1; + pointer-events: auto; +} + +.jumpToBottom { + position: absolute; + right: 16px; + bottom: 12px; + background: var(--bg-popup); + border: 1px solid var(--border); + border-radius: 50%; + box-shadow: var(--shadow-tooltip); +} + +.quoteButton { + position: absolute; + z-index: 2; + height: 24px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: var(--radius-small); + background: var(--bg-popup); + color: var(--fg); + box-shadow: var(--shadow-tooltip); + cursor: pointer; + -webkit-user-select: none; + user-select: none; +} +.quoteButton:hover { + background: var(--bg-hover); +} + +.findBar { + flex: none; + display: flex; + align-items: center; + gap: 2px; + padding: 4px 8px; + border-bottom: 1px solid var(--border); +} +.findField { + flex: 1; + min-width: 0; +} +.findCount { + color: var(--fg-subtle); + font-size: var(--fs-small); + white-space: nowrap; +} + +::highlight(agent-find) { + background-color: var(--bg-text-search-result); +} +::highlight(agent-find-current) { + background-color: var(--bg-text-search-result-current); +} .user { padding: 2px 0 2px 10px; @@ -141,6 +245,79 @@ padding: 0; background: none; } +.markdown :global(pre) { + position: relative; +} +.codeCopy { + position: absolute; + top: 4px; + right: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: var(--radius-small); + background: var(--bg-elev); + color: var(--fg-secondary); + cursor: pointer; + opacity: 0; +} +.markdown :global(pre:hover) .codeCopy, +.codeCopy:focus-visible { + opacity: 1; +} +.codeCopy:hover { + background: var(--bg-hover); +} +.fileLink { + color: var(--link); + cursor: pointer; +} +.fileLink:hover { + text-decoration: underline; +} + +/* Lezer classHighlighter tokens in fenced code (same colours as the Markdown preview). */ +.markdown :global(.tok-keyword), +.markdown :global(.tok-modifier), +.markdown :global(.tok-controlKeyword), +.markdown :global(.tok-operatorKeyword) { + color: var(--syn-keyword); +} +.markdown :global(.tok-string), +.markdown :global(.tok-string2) { + color: var(--syn-string); +} +.markdown :global(.tok-number), +.markdown :global(.tok-bool), +.markdown :global(.tok-atom) { + color: var(--syn-number); +} +.markdown :global(.tok-typeName), +.markdown :global(.tok-className), +.markdown :global(.tok-namespace) { + color: var(--syn-type); +} +.markdown :global(.tok-comment) { + color: var(--syn-comment); +} +.markdown :global(.tok-meta), +.markdown :global(.tok-macroName) { + color: var(--syn-annotation); +} +.markdown :global(.tok-propertyName) { + color: var(--syn-property); +} +.markdown :global(.tok-variableName.tok-definition) { + color: var(--syn-function); +} +.markdown :global(.tok-punctuation), +.markdown :global(.tok-operator) { + color: var(--syn-punct); +} .markdown :global(ul), .markdown :global(ol) { margin: 0 0 8px; diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 588484148..3d137a1b9 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -1,16 +1,30 @@ /** An agent task as an editor tab: the conversation, a composer and the task's changed files. */ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; import clsx from "clsx"; import type { AgentChange, AgentEntry, AgentTask } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands } from "@/platform/ipc"; import { basename, relativeDir, useProject } from "@/platform/project"; import { editorService, notifications, type EditorInput } from "@/workbench/contributions"; -import { Button, EmptyState, Icon, IconButton, Spinner, T, Tree, fileIconName } from "@/ui"; +import { + Button, + EmptyState, + Icon, + IconButton, + SearchField, + Spinner, + T, + Tree, + copyText, + fileIconName, + selectableText, +} from "@/ui"; +import { useTx } from "@/platform/i18n"; import { TaskStatusIcon } from "./AgentsView"; import { Composer } from "./Composer"; import { canSend, emptyDraft, usageLabel } from "./composerModel"; import { ConfigPickers } from "./ConfigPickers"; +import { findRanges, formatDuration, formatSentAt } from "./conversation"; import { Markdown } from "./Markdown"; import { branchLabel, isBusy, providerIcon, statusLabel } from "./model"; import { @@ -19,8 +33,11 @@ import { loadEntries, markVisible, refreshChanges, + editMessage, editQueued, + quoteInComposer, removeQueued, + resendMessage, respond, sendMessage, sendQueuedNow, @@ -28,6 +45,7 @@ import { useAgentCommands, useAgents, } from "./store"; +import { setFocusedSession, focusedSession, type FocusedSession } from "./sessionFocus"; import styles from "./Session.module.css"; export function SessionEditor({ input }: { input: EditorInput }) { @@ -40,13 +58,41 @@ export function SessionEditor({ input }: { input: EditorInput }) { void refreshChanges(id); return markVisible(id); }, [id]); + const [finding, setFinding] = useState(false); + const [findFocus, setFindFocus] = useState(0); + const [session] = useState(() => ({ + openFind: () => { + setFinding(true); + setFindFocus((n) => n + 1); + }, + })); + useEffect( + () => () => { + if (focusedSession() === session) setFocusedSession(null); + }, + [session], + ); if (!task) return ; return ( -
+
setFocusedSession(session)} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedSession() === session) { + setFocusedSession(null); + } + }} + >
- + setFinding(false)} + />
@@ -106,34 +152,248 @@ function Usage({ task }: { task: AgentTask }) { ); } -function Stream({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) { +interface StreamProps { + task: AgentTask; + entries: AgentEntry[] | null; + finding: boolean; + /** Bumped by Find in Conversation: focus the find field again. */ + findFocus: number; + onCloseFind: () => void; +} + +function Stream({ task, entries, finding, findFocus, onCloseFind }: StreamProps) { + const tx = useTx(); const ref = useRef(null); const stick = useRef(true); + const [atBottom, setAtBottom] = useState(true); useLayoutEffect(() => { const el = ref.current; if (el && stick.current) el.scrollTop = el.scrollHeight; }); const waiting = entries?.some((e) => e.type === "permission" && !e.chosen); + const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user"); + const toBottom = () => { + const el = ref.current; + if (!el) return; + stick.current = true; + el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + }; return ( -
{ - const el = e.currentTarget; - stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; +
+ {finding && } +
{ + const el = e.currentTarget; + stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + setAtBottom(stick.current); + }} + > + {entries === null ? ( + + ) : ( + entries.map((entry, i) => ) + )} + {(task.status === "running" || task.status === "starting") && !waiting && ( +
+ + {task.status === "starting" ? "Starting the agent…" : "Working…"} + {lastUser?.sentMs != null && !lastUser.doneMs && } +
+ )} +
+ + {!atBottom && ( + + )} +
+ ); +} + +type UserEntry = Extract; + +/** Time since `since`, ticking every second. */ +function Elapsed({ since }: { since: number }) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, []); + return {formatDuration(now - since)}; +} + +/** A Quote button next to text selected in the conversation: replies to it in the composer. */ +function QuoteSelection({ task, streamRef }: { task: AgentTask; streamRef: RefObject }) { + const [at, setAt] = useState<{ left: number; top: number } | null>(null); + const place = useCallback(() => { + const stream = streamRef.current; + const sel = window.getSelection(); + if (!stream || !sel || sel.isCollapsed || !sel.rangeCount || !sel.toString().trim()) return setAt(null); + const range = sel.getRangeAt(0); + if (!stream.contains(range.commonAncestorContainer)) return setAt(null); + const rects = range.getClientRects(); + const last = rects[rects.length - 1] ?? range.getBoundingClientRect(); + const area = stream.parentElement!.getBoundingClientRect(); + const box = stream.getBoundingClientRect(); + if (last.bottom < box.top || last.top > box.bottom) return setAt(null); + setAt({ + left: Math.min(Math.max(last.right - area.left, 0), area.width - 80), + top: Math.min(last.bottom - area.top + 4, box.bottom - area.top - 28), + }); + }, [streamRef]); + useEffect(() => { + const stream = streamRef.current; + document.addEventListener("selectionchange", place); + stream?.addEventListener("scroll", place); + return () => { + document.removeEventListener("selectionchange", place); + stream?.removeEventListener("scroll", place); + }; + }, [place, streamRef]); + if (!at || task.status === "archived") return null; + return ( + + ); +} + +const highlights = (globalThis as { CSS?: { highlights?: Map } }).CSS?.highlights; +const HighlightCtor = (globalThis as { Highlight?: new (...ranges: Range[]) => unknown }).Highlight; + +function setHighlight(name: string, ranges: Range[]) { + if (!highlights || !HighlightCtor) return; + if (ranges.length) highlights.set(name, new HighlightCtor(...ranges)); + else highlights.delete(name); +} + +/** Scrolls `range` into view in `stream`, opening the collapsed tool output or thought holding it. */ +function revealRange(range: Range, stream: HTMLElement) { + for (let el = range.startContainer.parentElement; el && el !== stream; el = el.parentElement) { + if (el instanceof HTMLDetailsElement) el.open = true; + } + const r = range.getBoundingClientRect(); + const box = stream.getBoundingClientRect(); + if (r.top < box.top + 24 || r.bottom > box.bottom - 24) { + stream.scrollTop += r.top - box.top - box.height / 3; + } +} + +/** Find in Conversation: every match highlighted, Enter / ⇧Enter step through them. */ +function FindBar({ + streamRef, + focus, + onClose, +}: { + streamRef: RefObject; + focus: number; + onClose: () => void; +}) { + const tx = useTx(); + const input = useRef(null); + const [query, setQuery] = useState(""); + const [ranges, setRanges] = useState([]); + const [current, setCurrent] = useState(0); + + useEffect(() => { + input.current?.focus(); + input.current?.select(); + }, [focus]); + + // Matches follow the conversation as it streams in (and as code gets highlighted). + useEffect(() => { + const stream = streamRef.current; + if (!stream) return; + let frame = 0; + const update = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => setRanges(findRanges(stream, query.trim()))); + }; + update(); + const observer = new MutationObserver(update); + observer.observe(stream, { subtree: true, childList: true, characterData: true }); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [query, streamRef]); + + const index = ranges.length ? Math.min(current, ranges.length - 1) : -1; + useEffect(() => { + setHighlight("agent-find", ranges); + setHighlight("agent-find-current", index >= 0 ? [ranges[index]!] : []); + }, [ranges, index]); + useEffect( + () => () => { + setHighlight("agent-find", []); + setHighlight("agent-find-current", []); + }, + [], + ); + + const step = (dir: 1 | -1) => { + const stream = streamRef.current; + if (!ranges.length || !stream) return; + const next = (index + dir + ranges.length) % ranges.length; + setCurrent(next); + revealRange(ranges[next]!, stream); + }; + const close = () => { + onClose(); + streamRef.current?.focus(); + }; + + return ( +
+ { + setQuery(v); + setCurrent(0); + }} + aria-label={tx("Find in Conversation")} + placeholder={tx("Find in conversation")} + className={styles.findField} + trailing={ + query.trim() ? ( + {ranges.length ? `${index + 1}/${ranges.length}` : tx("0 results")} + ) : null + } + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + step(e.shiftKey ? -1 : 1); + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + close(); + } + }} + /> + step(-1)} /> + step(1)} /> +
); } @@ -152,23 +412,16 @@ const TOOL_ICONS: Record = { function Entry({ task, entry }: { task: AgentTask; entry: AgentEntry }) { switch (entry.type) { case "user": + return ; + case "agent": return ( -
- {entry.text} - {entry.attachments.length > 0 && ( -
- {entry.attachments.map((a, i) => ( - - - {a} - - ))} -
- )} +
+ +
+ void copyText(entry.text)} /> +
); - case "agent": - return ; case "thought": return (
@@ -206,6 +459,60 @@ function Entry({ task, entry }: { task: AgentTask; entry: AgentEntry }) { } } +function UserMessage({ task, entry }: { task: AgentTask; entry: UserEntry }) { + const tx = useTx(); + const fail = (e: unknown) => notifications().notify({ level: "error", title: "Couldn't send the message", body: String(e) }); + const archived = task.status === "archived"; + return ( +
+
+ {entry.text} + {entry.attachments.length > 0 && ( +
+ {entry.attachments.map((a, i) => ( + + + {a} + + ))} +
+ )} +
+
+ {entry.sentMs != null && ( + + {formatSentAt(entry.sentMs)} + + )} + {entry.sentMs != null && entry.doneMs != null && ( + + {`\u00a0· ${formatDuration(entry.doneMs - entry.sentMs)}`} + + )} + + void copyText(entry.text)} /> + {!archived && entry.text && ( + editMessage(task.id, entry.text)} + /> + )} + {!archived && entry.text && entry.attachments.length === 0 && ( + void resendMessage(task.id, entry.text).catch(fail)} + /> + )} + +
+
+ ); +} + function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract }) { const head = ( <> diff --git a/src/features/agents/conversation.test.ts b/src/features/agents/conversation.test.ts new file mode 100644 index 000000000..78d1d1e6b --- /dev/null +++ b/src/features/agents/conversation.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { findAll, findRanges, formatDuration, pathLink, quoteText, withQuote } from "./conversation"; + +describe("quoting", () => { + it("quotes each line and leaves a line to type on", () => { + expect(quoteText("one\n\ntwo\n")).toBe("> one\n>\n> two\n\n"); + }); + it("adds the quote after a draft", () => { + expect(withQuote("", "x")).toBe("> x\n\n"); + expect(withQuote("Why? ", "x")).toBe("Why?\n\n> x\n\n"); + }); +}); + +describe("pathLink", () => { + const cwd = "/repo"; + it("resolves relative paths with a line and column", () => { + expect(pathLink("src/lib.rs", cwd)).toEqual({ path: "/repo/src/lib.rs" }); + expect(pathLink("./src/main.rs:12", cwd)).toEqual({ path: "/repo/src/main.rs", line: 11 }); + expect(pathLink("a/b.ts:3:5", cwd)).toEqual({ path: "/repo/a/b.ts", line: 2, col: 4 }); + expect(pathLink("Cargo.toml", cwd)).toEqual({ path: "/repo/Cargo.toml" }); + expect(pathLink("/abs/x.py#L7", cwd)).toEqual({ path: "/abs/x.py", line: 6 }); + }); + it("ignores code that isn't a path", () => { + for (const t of ["foo()", "cargo test", "https://x.dev/a.md", "inventory", "1.5", "v2.0", "a.b()", "failing-tests", "tests/"]) { + expect(pathLink(t, cwd)).toBeNull(); + } + }); +}); + +describe("formatDuration", () => { + it("scales the unit", () => { + expect(formatDuration(850)).toBe("850ms"); + expect(formatDuration(12_400)).toBe("12s"); + expect(formatDuration(65_000)).toBe("1m 05s"); + expect(formatDuration(2 * 3600_000 + 3 * 60_000)).toBe("2h 03m"); + }); +}); + +describe("finding", () => { + it("finds every occurrence, ignoring case", () => { + expect(findAll("Abc abc ABC", "abc")).toEqual([0, 4, 8]); + expect(findAll("aaa", "")).toEqual([]); + }); + it("maps matches to DOM ranges", () => { + const root = document.createElement("div"); + root.innerHTML = "

one two one

"; + expect(findRanges(root, "one").map((r) => r.toString())).toEqual(["one", "one"]); + expect(findRanges(root, "TWO")).toHaveLength(1); + }); +}); diff --git a/src/features/agents/conversation.ts b/src/features/agents/conversation.ts new file mode 100644 index 000000000..b6e357d15 --- /dev/null +++ b/src/features/agents/conversation.ts @@ -0,0 +1,88 @@ +/** Reading an agent conversation: quoting, file links, turn times and finding text. */ + +/** `text` as a Markdown quote for the composer, followed by an empty line to type in. */ +export function quoteText(text: string): string { + const lines = text.replace(/\s+$/, "").split("\n"); + return `${lines.map((l) => (l ? `> ${l}` : ">")).join("\n")}\n\n`; +} + +/** Appends a quote to a draft, on its own paragraph. */ +export function withQuote(draft: string, text: string): string { + if (!draft.trim()) return quoteText(text); + return `${draft.replace(/\s+$/, "")}\n\n${quoteText(text)}`; +} + +export interface PathLink { + /** Absolute path. */ + path: string; + /** 0-based line and column, when the text names them. */ + line?: number; + col?: number; +} + +const PATH = /^(?:\.\/)?([\w.@-]+(?:\/[\w.@-]+)*\.[A-Za-z][A-Za-z0-9]*)(?::(\d+)(?::(\d+))?|#L(\d+))?$/; + +/** + * The file an inline code span names (`src/lib.rs`, `src/main.rs:12`, `a/b.ts:3:5`, + * `/abs/x.py#L7`), resolved against `cwd`; null for text that isn't a file path + * (`foo()`, `cargo test`, URLs, `1.5`, folders, words without an extension). + */ +export function pathLink(text: string, cwd: string): PathLink | null { + const t = text.trim(); + if (!t || /\s|:\/\//.test(t)) return null; + const absolute = t.startsWith("/"); + const m = PATH.exec(absolute ? t.slice(1) : t); + if (!m) return null; + const rel = m[1]!; + const path = absolute ? `/${rel}` : `${cwd.replace(/\/$/, "")}/${rel}`; + const line = m[2] ?? m[4]; + const out: PathLink = { path }; + if (line) out.line = Math.max(0, Number(line) - 1); + if (m[3]) out.col = Math.max(0, Number(m[3]) - 1); + return out; +} + +/** `850ms`, `12s`, `1m 05s`, `2h 03m`. */ +export function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`; + return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; +} + +/** Clock time of a message: `14:03` today, else with the date. */ +export function formatSentAt(ms: number, now = Date.now()): string { + const d = new Date(ms); + const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + return new Date(now).toDateString() === d.toDateString() + ? time + : `${d.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${time}`; +} + +/** Start offsets of `query` in `text`, ignoring case; empty for an empty query. */ +export function findAll(text: string, query: string): number[] { + if (!query) return []; + const hay = text.toLowerCase(); + const needle = query.toLowerCase(); + const out: number[] = []; + for (let i = hay.indexOf(needle); i >= 0; i = hay.indexOf(needle, i + needle.length)) out.push(i); + return out; +} + +/** DOM ranges of `query` in the text under `root` (matches within one text node). */ +export function findRanges(root: Node, query: string): Range[] { + const out: Range[] = []; + if (!query) return out; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + for (const at of findAll(node.textContent ?? "", query)) { + const range = document.createRange(); + range.setStart(node, at); + range.setEnd(node, at + query.length); + out.push(range); + } + } + return out; +} diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts index cf4911cba..cb1a5923a 100644 --- a/src/features/agents/index.ts +++ b/src/features/agents/index.ts @@ -17,6 +17,7 @@ import { import { AgentsStatus } from "./AgentsStatus"; import { HOME_KIND, SESSION_KIND, TOOL_WINDOW } from "./model"; import { sendPaths, sendSelection } from "./references"; +import { focusedSession } from "./sessionFocus"; import { connect, disconnect, openHome, useAgents } from "./store"; export function activate(): () => void { @@ -71,6 +72,14 @@ export function activate(): () => void { : [], }), registerActions([ + { + id: "agents.findInConversation", + title: "Find in Conversation", + category: "Agents", + icon: "search", + when: "agentSessionFocus", + run: () => focusedSession()?.openFind(), + }, { id: "agents.newTask", title: "New Agent Task", diff --git a/src/features/agents/sessionFocus.ts b/src/features/agents/sessionFocus.ts new file mode 100644 index 000000000..124500449 --- /dev/null +++ b/src/features/agents/sessionFocus.ts @@ -0,0 +1,15 @@ +/** The agent session tab holding focus, for actions that act on it (Find in Conversation). */ +import { setContext } from "@/platform/context"; + +export interface FocusedSession { + openFind(): void; +} + +let focused: FocusedSession | null = null; + +export function setFocusedSession(session: FocusedSession | null) { + focused = session; + setContext("agentSessionFocus", !!session); +} + +export const focusedSession = () => focused; diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index aa31e2ad3..c5d7cb5d0 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -14,6 +14,7 @@ import type { import { call, commands, isTauri } from "@/platform/ipc"; import { editorService, notifications } from "@/workbench/contributions"; import { canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel"; +import { withQuote } from "./conversation"; import { HOME_KEY, HOME_KIND, SESSION_KIND, attentionChange, isBusy, sessionKey, upsertEntry } from "./model"; /** New Task page settings (its text lives in the `HOME` composer). */ @@ -44,6 +45,8 @@ interface AgentsState { lastComposer: string; /** First messages of past tasks, newest first (New Task page ↑). */ history: string[]; + /** The composer asked to take focus (`seq` tells repeated requests apart). */ + focus: { key: string; seq: number } | null; } const DRAFT_KEY = "openidea.agents.draft.v2"; @@ -80,6 +83,7 @@ export const useAgents = create(() => ({ composers: new Map(), lastComposer: HOME, history: load(HISTORY_KEY, []), + focus: null, })); export function updateDraft(patch: Partial) { @@ -307,6 +311,28 @@ export async function sendMessage(task: string, now: boolean): Promise { updateComposer(task, emptyDraft()); } +/** Moves the caret to the end of composer `key`. */ +export function focusComposer(key: string) { + useAgents.setState((s) => ({ focus: { key, seq: (s.focus?.seq ?? 0) + 1 } })); +} + +/** Quotes `text` (a selection of the conversation) in the task's composer, to reply to it. */ +export function quoteInComposer(task: string, text: string) { + updateComposer(task, { text: withQuote(composerDraft(task).text, text) }); + focusComposer(task); +} + +/** Puts an earlier message back into the composer to edit and send again. */ +export function editMessage(task: string, text: string) { + const current = composerDraft(task).text; + updateComposer(task, { text: current.trim() ? `${text}\n${current}` : text }); + focusComposer(task); +} + +/** Sends an earlier message again (queued while the agent works). */ +export const resendMessage = (task: string, text: string) => + call(commands.agentPrompt(task, { text, files: [], images: [] }, false)); + /** Takes a queued message back into the composer to edit it. */ export async function editQueued(task: string, queued: string): Promise { const item = await call(commands.agentUnqueue(task, queued)); diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index 41d8c1db1..1cacab62a 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -61,6 +61,7 @@ export const intellijMacos: Record = { // Find "find.inFile": ["Meta+F"], + "agents.findInConversation": ["Meta+F"], "find.replaceInFile": ["Meta+R"], "find.next": ["Meta+G"], "find.previous": ["Meta+Shift+G"], diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index 341450d8f..1dfc79226 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -62,6 +62,7 @@ export const intellijWindows: Record = { // Find "find.inFile": ["Ctrl+F"], + "agents.findInConversation": ["Ctrl+F"], "find.replaceInFile": ["Ctrl+R"], "find.next": ["F3", "Ctrl+L"], "find.previous": ["Shift+F3", "Ctrl+Shift+L"], diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index f1f2b861a..3fdd15305 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -62,6 +62,7 @@ export const vscodeMacos: Record = { // Find "find.inFile": ["Meta+F"], + "agents.findInConversation": ["Meta+F"], "find.replaceInFile": ["Meta+Alt+F"], "find.next": ["Meta+G"], "find.previous": ["Meta+Shift+G"], diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c2ffd4185..0563c0033 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4190,5 +4190,14 @@ "Starting the IDE backend…": "正在启动 IDE 后端…", "Opening the project…": "正在打开项目…", "Cannot load agent tasks": "无法加载智能体任务", - "Opening {name}…": "正在打开 {name}…" + "Opening {name}…": "正在打开 {name}…", + "Copy Code": "复制代码", + "Jump to Bottom": "跳到底部", + "Quote": "引用", + "Find in Conversation": "在对话中查找", + "Find in conversation": "在对话中查找", + "0 results": "0 个结果", + "Time the agent took to answer": "智能体回答所用的时间", + "Edit and Resend": "编辑后重新发送", + "Send Again": "再次发送" }