feat(agents): Select, copy, quote and search the conversation

- 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.
Цей коміт міститься в:
l0ng-ai committed 2026-10-28 09:36:40 +08:00
1 джерело 357b728f1d
коміт 8ba6941016
18 files changed
+867 -49

No files matched your search

+8 -3
Переглянути файл
@@ -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);
+6
Переглянути файл
@@ -194,6 +194,12 @@ pub enum AgentEntry {
text: String,
/// Names of the files and images sent with it.
attachments: Vec<String>,
/// When it was sent (ms since the epoch); `None` in tasks saved before this was kept.
#[serde(default)]
sent_ms: Option<f64>,
/// When the turn it started ended; `None` while it runs or when it was cut off.
#[serde(default)]
done_ms: Option<f64>,
},
Agent {
text: String,
+8 -1
Переглянути файл
@@ -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();
+24 -3
Переглянути файл
@@ -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<usize> {
/// 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<f64>) -> Vec<usize> {
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::<usize>::new());
}
#[test]
fn long_output_keeps_its_tail() {
let s = tail("é".repeat(MAX_OUTPUT));
+5 -1
Переглянути файл
@@ -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. */
+9
Переглянути файл
@@ -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;
+86 -4
Переглянути файл
@@ -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<string, Promise<boolean>>();
function fileExists(path: string): Promise<boolean> {
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<HTMLElement>("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<HTMLElement>("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<HTMLDivElement>(null);
useLayoutEffect(() => (ref.current ? enhance(ref.current, cwd) : undefined), [html, cwd]);
return (
<div
ref={ref}
className={styles.markdown}
onClick={(e) => {
const a = (e.target as HTMLElement).closest("a");
const target = e.target as HTMLElement;
const copy = target.closest<HTMLElement>("button[data-copy]");
if (copy) {
void copyText(copy.dataset.copy ?? "");
return;
}
const file = target.closest<HTMLElement>("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);
+177
Переглянути файл
@@ -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;
+343 -36
Переглянути файл
@@ -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<FocusedSession>(() => ({
openFind: () => {
setFinding(true);
setFindFocus((n) => n + 1);
},
}));
useEffect(
() => () => {
if (focusedSession() === session) setFocusedSession(null);
},
[session],
);
if (!task) return <EmptyState title="This agent task no longer exists" />;
return (
<div className={styles.session}>
<div
className={styles.session}
onFocusCapture={() => setFocusedSession(session)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedSession() === session) {
setFocusedSession(null);
}
}}
>
<div className={styles.conversation}>
<Header task={task} />
<PermissionBanner task={task} entries={entries ?? null} />
<Stream task={task} entries={entries ?? null} />
<Stream
task={task}
entries={entries ?? null}
finding={finding}
findFocus={findFocus}
onCloseFind={() => setFinding(false)}
/>
<SessionComposer task={task} entries={entries ?? null} />
</div>
<ChangesPane task={task} changes={changes ?? null} />
@@ -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<HTMLDivElement>(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 (
<div
ref={ref}
className={styles.stream}
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
<div className={styles.streamArea}>
{finding && <FindBar streamRef={ref} focus={findFocus} onClose={onCloseFind} />}
<div
ref={ref}
// The conversation is read and copied like a document; focusable so ⌘F reaches it.
className={clsx(styles.stream, selectableText)}
tabIndex={-1}
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
setAtBottom(stick.current);
}}
>
{entries === null ? (
<Spinner />
) : (
entries.map((entry, i) => <Entry key={i} task={task} entry={entry} />)
)}
{(task.status === "running" || task.status === "starting") && !waiting && (
<div className={styles.working}>
<Spinner size={12} />
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
{lastUser?.sentMs != null && !lastUser.doneMs && <Elapsed since={lastUser.sentMs} />}
</div>
)}
</div>
<QuoteSelection task={task} streamRef={ref} />
{!atBottom && (
<IconButton
icon="arrow-down"
title={tx("Jump to Bottom")}
className={styles.jumpToBottom}
onClick={toBottom}
/>
)}
</div>
);
}
type UserEntry = Extract<AgentEntry, { type: "user" }>;
/** 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 <span className={styles.muted}>{formatDuration(now - since)}</span>;
}
/** A Quote button next to text selected in the conversation: replies to it in the composer. */
function QuoteSelection({ task, streamRef }: { task: AgentTask; streamRef: RefObject<HTMLDivElement | null> }) {
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 (
<button
type="button"
className={styles.quoteButton}
style={{ left: at.left, top: at.top }}
// Keep the selection: it is what gets quoted.
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const text = window.getSelection()?.toString() ?? "";
window.getSelection()?.removeAllRanges();
setAt(null);
if (text.trim()) quoteInComposer(task.id, text);
}}
>
{entries === null ? (
<Spinner />
) : (
entries.map((entry, i) => <Entry key={i} task={task} entry={entry} />)
)}
{(task.status === "running" || task.status === "starting") && !waiting && (
<div className={styles.working}>
<Spinner size={12} />
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
</div>
)}
<T>Quote</T>
</button>
);
}
const highlights = (globalThis as { CSS?: { highlights?: Map<string, unknown> } }).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<HTMLDivElement | null>;
focus: number;
onClose: () => void;
}) {
const tx = useTx();
const input = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [ranges, setRanges] = useState<Range[]>([]);
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 (
<div className={styles.findBar} role="search">
<SearchField
ref={input}
value={query}
onChange={(v) => {
setQuery(v);
setCurrent(0);
}}
aria-label={tx("Find in Conversation")}
placeholder={tx("Find in conversation")}
className={styles.findField}
trailing={
query.trim() ? (
<span className={styles.findCount}>{ranges.length ? `${index + 1}/${ranges.length}` : tx("0 results")}</span>
) : null
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
step(e.shiftKey ? -1 : 1);
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
close();
}
}}
/>
<IconButton icon="arrow-up" title={tx("Previous Occurrence")} size="small" disabled={!ranges.length} onClick={() => step(-1)} />
<IconButton icon="arrow-down" title={tx("Next Occurrence")} size="small" disabled={!ranges.length} onClick={() => step(1)} />
<IconButton icon="close" title={tx("Close")} size="small" onClick={close} />
</div>
);
}
@@ -152,23 +412,16 @@ const TOOL_ICONS: Record<string, string> = {
function Entry({ task, entry }: { task: AgentTask; entry: AgentEntry }) {
switch (entry.type) {
case "user":
return <UserMessage task={task} entry={entry} />;
case "agent":
return (
<div className={styles.user}>
{entry.text}
{entry.attachments.length > 0 && (
<div className={styles.attachments}>
{entry.attachments.map((a, i) => (
<span key={i} className={styles.attachment}>
<Icon name={fileIconName(a)} size={12} />
{a}
</span>
))}
</div>
)}
<div className={styles.message}>
<Markdown text={entry.text} cwd={task.cwd} />
<div className={clsx(styles.messageActions, styles.floatingActions)}>
<IconButton icon="copy" title="Copy" size="small" onClick={() => void copyText(entry.text)} />
</div>
</div>
);
case "agent":
return <Markdown text={entry.text} />;
case "thought":
return (
<details className={styles.thought}>
@@ -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 (
<div className={styles.message}>
<div className={styles.user}>
{entry.text}
{entry.attachments.length > 0 && (
<div className={styles.attachments}>
{entry.attachments.map((a, i) => (
<span key={i} className={styles.attachment}>
<Icon name={fileIconName(a)} size={12} />
{a}
</span>
))}
</div>
)}
</div>
<div className={styles.messageMeta}>
{entry.sentMs != null && (
<span className={styles.muted} title={new Date(entry.sentMs).toLocaleString()}>
{formatSentAt(entry.sentMs)}
</span>
)}
{entry.sentMs != null && entry.doneMs != null && (
<span className={styles.muted} title={tx("Time the agent took to answer")}>
{`\u00a0· ${formatDuration(entry.doneMs - entry.sentMs)}`}
</span>
)}
<span className={styles.messageActions}>
<IconButton icon="copy" title="Copy" size="small" onClick={() => void copyText(entry.text)} />
{!archived && entry.text && (
<IconButton
icon="edit"
title="Edit and Resend"
size="small"
onClick={() => editMessage(task.id, entry.text)}
/>
)}
{!archived && entry.text && entry.attachments.length === 0 && (
<IconButton
icon="refresh"
title="Send Again"
size="small"
onClick={() => void resendMessage(task.id, entry.text).catch(fail)}
/>
)}
</span>
</div>
</div>
);
}
function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry, { type: "tool" }> }) {
const head = (
<>
+50
Переглянути файл
@@ -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 = "<p>one <code>two</code> one</p>";
expect(findRanges(root, "one").map((r) => r.toString())).toEqual(["one", "one"]);
expect(findRanges(root, "TWO")).toHaveLength(1);
});
});
+88
Переглянути файл
@@ -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;
}
+9
Переглянути файл
@@ -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",
+15
Переглянути файл
@@ -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;
+26
Переглянути файл
@@ -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<AgentsState>(() => ({
composers: new Map(),
lastComposer: HOME,
history: load<string[]>(HISTORY_KEY, []),
focus: null,
}));
export function updateDraft(patch: Partial<Draft>) {
@@ -307,6 +311,28 @@ export async function sendMessage(task: string, now: boolean): Promise<void> {
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<void> {
const item = await call(commands.agentUnqueue(task, queued));
+1
Переглянути файл
@@ -61,6 +61,7 @@ export const intellijMacos: Record<string, string[]> = {
// Find
"find.inFile": ["Meta+F"],
"agents.findInConversation": ["Meta+F"],
"find.replaceInFile": ["Meta+R"],
"find.next": ["Meta+G"],
"find.previous": ["Meta+Shift+G"],
+1
Переглянути файл
@@ -62,6 +62,7 @@ export const intellijWindows: Record<string, string[]> = {
// 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"],
+1
Переглянути файл
@@ -62,6 +62,7 @@ export const vscodeMacos: Record<string, string[]> = {
// Find
"find.inFile": ["Meta+F"],
"agents.findInConversation": ["Meta+F"],
"find.replaceInFile": ["Meta+Alt+F"],
"find.next": ["Meta+G"],
"find.previous": ["Meta+Shift+G"],
+10 -1
Переглянути файл
@@ -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": "再次发送"
}