mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b066bff0 | ||
|
|
4d3e466c33 | ||
|
|
be367c6341 | ||
|
|
c0179d3309 | ||
|
|
1117dc4907 | ||
|
|
8ba6941016 |
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -96,6 +96,15 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The conversation with its find bar. */
|
||||
.streamArea {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stream {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -105,6 +114,60 @@
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.stream:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Your 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;
|
||||
}
|
||||
.message:hover .messageActions,
|
||||
.messageActions:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.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 +204,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;
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
/** 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 { 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,
|
||||
copyMenuFor,
|
||||
copyText,
|
||||
fileIconName,
|
||||
selectableText,
|
||||
showContextMenu,
|
||||
} 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 +35,11 @@ import {
|
||||
loadEntries,
|
||||
markVisible,
|
||||
refreshChanges,
|
||||
editMessage,
|
||||
editQueued,
|
||||
quoteInComposer,
|
||||
removeQueued,
|
||||
resendMessage,
|
||||
respond,
|
||||
sendMessage,
|
||||
sendQueuedNow,
|
||||
@@ -28,6 +47,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 +60,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,7 +154,16 @@ 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 ref = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
@@ -114,26 +171,207 @@ function Stream({ task, entries }: { task: AgentTask; entries: AgentEntry[] | nu
|
||||
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");
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.stream}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<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;
|
||||
}}
|
||||
// Copy, plus Quote for a selection: replies to it in the composer.
|
||||
onContextMenu={(e) => {
|
||||
const text = selectionIn(e.currentTarget);
|
||||
const items = copyMenuFor(e.target) ?? [];
|
||||
if (text.trim() && task.status !== "archived") {
|
||||
items.push({ id: "quote", label: "Quote", icon: "edit", run: () => quoteInComposer(task.id, text) });
|
||||
}
|
||||
if (!items.length) return;
|
||||
e.preventDefault();
|
||||
showContextMenu(e, items);
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</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>;
|
||||
}
|
||||
|
||||
/** Text selected in `stream`, or "" when the selection is elsewhere. */
|
||||
function selectionIn(stream: HTMLElement): string {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || !sel.rangeCount) return "";
|
||||
return stream.contains(sel.getRangeAt(0).commonAncestorContainer) ? sel.toString() : "";
|
||||
}
|
||||
|
||||
interface RangeSet {
|
||||
clear(): void;
|
||||
add(range: Range): void;
|
||||
}
|
||||
const highlights = (globalThis as { CSS?: { highlights?: Map<string, RangeSet> } }).CSS?.highlights;
|
||||
const HighlightCtor = (globalThis as { Highlight?: new () => RangeSet }).Highlight;
|
||||
|
||||
/**
|
||||
* Sets the ranges of highlight `name`. One registered highlight per name, changed in
|
||||
* place: WebKit doesn't repaint where a replaced highlight object was.
|
||||
*/
|
||||
function setHighlight(name: string, ranges: Range[]) {
|
||||
if (!highlights || !HighlightCtor) return;
|
||||
let set = highlights.get(name);
|
||||
if (!set) {
|
||||
set = new HighlightCtor();
|
||||
highlights.set(name, set);
|
||||
}
|
||||
set.clear();
|
||||
for (const r of ranges) set.add(r);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
// Expanding or collapsing a tool output shows or hides its matches.
|
||||
const [shown, setShown] = useState(0);
|
||||
useEffect(() => {
|
||||
const stream = streamRef.current;
|
||||
const toggled = () => setShown((n) => n + 1);
|
||||
stream?.addEventListener("toggle", toggled, true);
|
||||
return () => stream?.removeEventListener("toggle", toggled, true);
|
||||
}, [streamRef]);
|
||||
useEffect(() => {
|
||||
// Only rendered matches: WebKit paints those in collapsed <details> over the text next to them.
|
||||
const visible = (r: Range) => r.getClientRects().length > 0;
|
||||
const current = index >= 0 ? ranges[index]! : null;
|
||||
setHighlight("agent-find", ranges.filter(visible));
|
||||
setHighlight("agent-find-current", current && visible(current) ? [current] : []);
|
||||
}, [ranges, index, shown]);
|
||||
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 +390,9 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
function Entry({ task, entry }: { task: AgentTask; entry: AgentEntry }) {
|
||||
switch (entry.type) {
|
||||
case "user":
|
||||
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>
|
||||
);
|
||||
return <UserMessage task={task} entry={entry} />;
|
||||
case "agent":
|
||||
return <Markdown text={entry.text} />;
|
||||
return <Markdown text={entry.text} cwd={task.cwd} />;
|
||||
case "thought":
|
||||
return (
|
||||
<details className={styles.thought}>
|
||||
@@ -206,6 +430,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 = (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -4190,5 +4190,13 @@
|
||||
"Starting the IDE backend…": "正在启动 IDE 后端…",
|
||||
"Opening the project…": "正在打开项目…",
|
||||
"Cannot load agent tasks": "无法加载智能体任务",
|
||||
"Opening {name}…": "正在打开 {name}…"
|
||||
"Opening {name}…": "正在打开 {name}…",
|
||||
"Copy Code": "复制代码",
|
||||
"Quote": "引用",
|
||||
"Find in Conversation": "在对话中查找",
|
||||
"Find in conversation": "在对话中查找",
|
||||
"0 results": "0 个结果",
|
||||
"Time the agent took to answer": "智能体回答所用的时间",
|
||||
"Edit and Resend": "编辑后重新发送",
|
||||
"Send Again": "再次发送"
|
||||
}
|
||||
Reference in new issue
Block a user