From 0f26b72d0720f426d573ee2c23df249559e05d00 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:37:25 +0800 Subject: [PATCH 1/3] fix(agents): anchor composer completions below the caret (#278) --- src/features/agents/Composer.module.css | 27 +++--- src/features/agents/Composer.tsx | 83 +++++++++++-------- src/features/agents/composerCommands.test.tsx | 18 ++++ src/features/agents/useCompletionAnchor.ts | 69 +++++++++++++++ 4 files changed, 152 insertions(+), 45 deletions(-) create mode 100644 src/features/agents/useCompletionAnchor.ts diff --git a/src/features/agents/Composer.module.css b/src/features/agents/Composer.module.css index 5e0c3e3d0..d051b3d8f 100644 --- a/src/features/agents/Composer.module.css +++ b/src/features/agents/Composer.module.css @@ -98,21 +98,26 @@ opacity: 1; } -/* `@` / `/` completion, above the box like a code-completion popup. */ -.popup { - position: absolute; +/* `@` / `/` completion; Popup keeps the list within the viewport. */ +.caretMirror { + position: fixed; + top: 0; left: 0; - bottom: calc(100% + 4px); - z-index: 10; - width: min(560px, 100%); + box-sizing: border-box; + visibility: hidden; + pointer-events: none; + white-space: pre-wrap; + overflow-wrap: break-word; +} +.popup { + width: min(560px, calc(100vw - 12px)); + border-radius: var(--radius-popup); + cursor: default; +} +.items { max-height: 300px; overflow: auto; padding: 4px 0; - border: 1px solid var(--border-popup); - border-radius: var(--radius-popup); - background: var(--bg-popup); - box-shadow: var(--shadow-popup); - cursor: default; } .item { display: flex; diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx index a04c25099..b08558d8d 100644 --- a/src/features/agents/Composer.tsx +++ b/src/features/agents/Composer.tsx @@ -10,7 +10,7 @@ import { call, commands, isTauri } from "@/platform/ipc"; import { useTx } from "@/platform/i18n"; import { basename } from "@/platform/project"; import { notifications, OWNS_FILE_DROP } from "@/workbench/contributions"; -import { Icon, IconButton, fileIconName } from "@/ui"; +import { Icon, IconButton, Popup, fileIconName } from "@/ui"; import { IMAGE_EXTENSIONS, MAX_IMAGE_BYTES, @@ -27,6 +27,7 @@ import { type FileRef, } from "./composerModel"; import { composerDraft, updateComposer, useAgents } from "./store"; +import { useCompletionAnchor } from "./useCompletionAnchor"; import styles from "./Composer.module.css"; import { isImeKey } from "@/platform/ime"; @@ -127,13 +128,14 @@ export function Composer(props: ComposerProps) { .map((command) => ({ kind: "command", command })); }, [completion, files, props.commands]); const open = !!completion && items.length > 0; + const completionAnchor = useCompletionAnchor(ref, open ? completion.to : null, text); - const setText = (value: string, caret?: number) => { + const setText = (value: string, caret?: number, restoreSelection = true) => { updateComposer(id, { text: value }); const c = caret ?? value.length; setCompletion(completionAt(value, c)); setActive(0); - if (caret !== undefined) { + if (caret !== undefined && restoreSelection) { requestAnimationFrame(() => { ref.current?.focus(); ref.current?.setSelectionRange(caret, caret); @@ -281,36 +283,49 @@ export function Composer(props: ComposerProps) { {...OWNS_FILE_DROP} onClick={() => ref.current?.focus()} > - {open && ( -
- {items.map((item, i) => ( -
setActive(i)} - onMouseDown={(e) => { - e.preventDefault(); - accept(item); - }} - > - {item.kind === "file" ? ( - <> - - {basename(item.rel)} - {item.rel} - - ) : ( - <> - /{item.command.name} - {item.command.hint && {item.command.hint}} - {item.command.description} - - )} -
- ))} -
+ {open && completionAnchor && ( + setCompletion(null)} + className={styles.popup} + role="listbox" + aria-label={tx(completion.kind === "file" ? "Files" : "Commands")} + > +
+ {items.map((item, i) => ( +
setActive(i)} + onMouseDown={(e) => { + e.preventDefault(); + accept(item); + }} + > + {item.kind === "file" ? ( + <> + + {basename(item.rel)} + {item.rel} + + ) : ( + <> + /{item.command.name} + {item.command.hint && {item.command.hint}} + {item.command.description} + + )} +
+ ))} +
+
)} {images.length > 0 && (
e.stopPropagation()}> @@ -339,7 +354,7 @@ export function Composer(props: ComposerProps) { value={text} aria-label={tx("Message")} placeholder={tx(props.placeholder)} - onChange={(e) => setText(e.target.value, undefined)} + onChange={(e) => setText(e.target.value, e.target.selectionStart, false)} onSelect={(e) => setCompletion(completionAt(e.currentTarget.value, e.currentTarget.selectionStart))} onBlur={() => setCompletion(null)} onKeyDown={onKeyDown} diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx index 046eade7a..5b96c8595 100644 --- a/src/features/agents/composerCommands.test.tsx +++ b/src/features/agents/composerCommands.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { AgentCommand, AgentProvider } from "@/bindings"; const agentCommands = vi.fn(); @@ -86,4 +87,21 @@ describe("New Task composer commands", () => { fireEvent.change(box, { target: { value: "/com" } }); expect(await screen.findByRole("option")).toHaveTextContent("/compact"); }); + + it("completes at the caret without replacing the rest of the message", async () => { + const user = userEvent.setup(); + agentCommands.mockResolvedValue({ status: "ok", data: [command("compact", "Free up context")] }); + render(); + await waitFor(() => expect(agentCommands).toHaveBeenCalled()); + const box = screen.getByRole("textbox"); + box.focus(); + fireEvent.change(box, { target: { value: "/com keep this context", selectionStart: 4, selectionEnd: 4 } }); + expect(await screen.findByRole("option")).toHaveTextContent("/compact"); + expect(box).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(box).toHaveValue("/compact keep this context"); + await waitFor(() => expect(screen.queryByRole("listbox", { name: "Commands" })).toBeNull()); + expect(agentCreate).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/agents/useCompletionAnchor.ts b/src/features/agents/useCompletionAnchor.ts new file mode 100644 index 000000000..de96f4e68 --- /dev/null +++ b/src/features/agents/useCompletionAnchor.ts @@ -0,0 +1,69 @@ +import { useLayoutEffect, useState, type RefObject } from "react"; +import type { VirtualElement } from "@floating-ui/react-dom"; +import styles from "./Composer.module.css"; + +const TEXT_STYLES = [ + "fontFamily", + "fontSize", + "fontWeight", + "fontStyle", + "fontVariant", + "lineHeight", + "letterSpacing", + "wordSpacing", + "textIndent", + "textAlign", + "textTransform", + "tabSize", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", +] as const; + +/** A textarea has no text DOM to measure; mirror its wrapping and scroll offset. */ +function caretRect(textarea: HTMLTextAreaElement, caret: number): DOMRect { + const computed = getComputedStyle(textarea); + const mirror = document.createElement("div"); + mirror.className = styles.caretMirror ?? ""; + for (const property of TEXT_STYLES) mirror.style[property] = computed[property]; + mirror.style.width = `${textarea.clientWidth}px`; + mirror.textContent = textarea.value.slice(0, caret); + const marker = document.createElement("span"); + marker.textContent = textarea.value.slice(caret) || "\u200b"; + mirror.append(marker); + document.body.append(mirror); + try { + const box = textarea.getBoundingClientRect(); + const lineHeight = parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.5; + return new DOMRect( + box.left + textarea.clientLeft + marker.offsetLeft - textarea.scrollLeft, + box.top + textarea.clientTop + marker.offsetTop - textarea.scrollTop, + 0, + lineHeight, + ); + } finally { + mirror.remove(); + } +} + +export function useCompletionAnchor( + ref: RefObject, + caret: number | null, + text: string, +): VirtualElement | null { + const [anchor, setAnchor] = useState(null); + useLayoutEffect(() => { + const textarea = ref.current; + if (!textarea || caret === null) { + setAnchor(null); + return; + } + const update = () => + setAnchor({ contextElement: textarea, getBoundingClientRect: () => caretRect(textarea, caret) }); + update(); + textarea.addEventListener("scroll", update); + return () => textarea.removeEventListener("scroll", update); + }, [ref, caret, text]); + return anchor; +} From fc2496c3f0036b23204aafc01ff5d25c9efb2e75 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:48:02 +0800 Subject: [PATCH 2/3] feat(agents): show unread task indicators --- src/features/agents/Agents.module.css | 11 ++ src/features/agents/AgentsStatus.tsx | 12 +- src/features/agents/AgentsView.tsx | 11 +- src/features/agents/index.ts | 4 +- src/features/agents/store.ts | 65 +++++++- src/features/agents/unread.test.tsx | 224 ++++++++++++++++++++++++++ src/platform/locales/text/zh.json | 2 + 7 files changed, 317 insertions(+), 12 deletions(-) create mode 100644 src/features/agents/unread.test.tsx diff --git a/src/features/agents/Agents.module.css b/src/features/agents/Agents.module.css index b52ea5300..39722d6ff 100644 --- a/src/features/agents/Agents.module.css +++ b/src/features/agents/Agents.module.css @@ -39,6 +39,17 @@ text-overflow: ellipsis; white-space: nowrap; } +.title[data-unread] { + color: var(--fg-strong); + font-weight: 600; +} +.unreadDot { + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); +} .detail { flex: 1 1 0; min-width: 0; diff --git a/src/features/agents/AgentsStatus.tsx b/src/features/agents/AgentsStatus.tsx index 2b955f12b..b0a759c9f 100644 --- a/src/features/agents/AgentsStatus.tsx +++ b/src/features/agents/AgentsStatus.tsx @@ -5,11 +5,13 @@ import styles from "./Agents.module.css"; export function AgentsStatus() { const tasks = useAgents((s) => s.tasks); + const unread = useAgents((s) => s.unread); const list = [...tasks.values()]; + const unseen = list.filter((t) => unread.has(t.id)).sort((a, b) => (b.updatedMs ?? 0) - (a.updatedMs ?? 0)); const input = list.filter((t) => t.status === "needsInput"); const running = list.filter((t) => t.status === "running" || t.status === "starting").length; - if (!input.length && !running) return null; - const target = input[0] ?? list.find((t) => t.status === "running"); + if (!input.length && !running && !unseen.length) return null; + const target = input[0] ?? unseen[0] ?? list.find((t) => t.status === "running" || t.status === "starting"); return ( ); } diff --git a/src/features/agents/AgentsView.tsx b/src/features/agents/AgentsView.tsx index 8d9c13383..2f899c16e 100644 --- a/src/features/agents/AgentsView.tsx +++ b/src/features/agents/AgentsView.tsx @@ -8,7 +8,7 @@ import { EmptyState, Icon, IconButton, Spinner, T, Tree, showContextMenu, type M import { SESSION_KIND, age, branchLabel, groupTasks, isBusy, type TaskGroup } from "./model"; import { archiveTask, deleteTask, openHome, openTask, useAgents } from "./store"; import styles from "./Agents.module.css"; -import { tx } from "@/platform/i18n"; +import { tx, useTx } from "@/platform/i18n"; export function AgentsHeaderActions() { return ( @@ -44,9 +44,11 @@ export function TaskStatusIcon({ status }: { status: Status }) { } export function AgentsView() { + const tx = useTx(); const loaded = useAgents((s) => s.loaded); const error = useAgents((s) => s.error); const tasks = useAgents((s) => s.tasks); + const unread = useAgents((s) => s.unread); const providers = useAgents((s) => s.providers); const [collapsed, setCollapsed] = useState>(() => new Set(["g:archived"])); const [selected, setSelected] = useState>(() => { @@ -115,7 +117,12 @@ export function AgentsView() { ) : ( - {n.task.title} + + {n.task.title} + + {unread.has(n.task.id) && ( + + )} {providerName(n.task.provider)} {n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""} diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts index 180e19e69..2deed65d6 100644 --- a/src/features/agents/index.ts +++ b/src/features/agents/index.ts @@ -54,7 +54,9 @@ export function activate(): () => void { component: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsView)), headerActions: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsHeaderActions)), useBadge: () => { - const n = useAgents((s) => [...s.tasks.values()].filter((t) => t.status === "needsInput").length); + const n = useAgents( + (s) => [...s.tasks.values()].filter((t) => t.status === "needsInput" || s.unread.has(t.id)).length, + ); return n || null; }, }), diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index 06c2e836b..d284121fb 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -44,6 +44,8 @@ interface AgentsState { /** Why the tasks could not be loaded. */ error: string | null; tasks: Map; + /** Tasks with unseen replies or status changes in this project. */ + unread: Set; /** Entries of the tasks (and subagents) opened in this window, by `entriesKey`. */ entries: Map; changes: Map; @@ -85,6 +87,7 @@ export const useAgents = create(() => ({ loaded: false, error: null, tasks: new Map(), + unread: new Set(), entries: new Map(), changes: new Map(), providers: [], @@ -110,11 +113,40 @@ export function updateComposer(key: string, patch: Partial) { })); } -/** Session tabs on screen; their tasks do not notify. */ -const visible = new Set(); +/** Session tabs on screen; a task can be shown in more than one split. */ +const visible = new Map(); +const windowFocused = () => document.visibilityState !== "hidden" && document.hasFocus(); +const viewing = (task: string) => visible.has(task) && windowFocused(); + +function setUnread(task: string, value: boolean) { + const s = useAgents.getState(); + if (s.unread.has(task) === value) return; + const unread = new Set(s.unread); + if (value) unread.add(task); + else unread.delete(task); + useAgents.setState({ unread }); +} + +function markUnread(task: string) { + const current = useAgents.getState().tasks.get(task); + if (current && current.status !== "archived" && !viewing(task)) setUnread(task, true); +} + export function markVisible(task: string): () => void { - visible.add(task); - return () => visible.delete(task); + visible.set(task, (visible.get(task) ?? 0) + 1); + const read = () => { + if (viewing(task)) setUnread(task, false); + }; + read(); + window.addEventListener("focus", read); + document.addEventListener("visibilitychange", read); + return () => { + const count = (visible.get(task) ?? 1) - 1; + if (count) visible.set(task, count); + else visible.delete(task); + window.removeEventListener("focus", read); + document.removeEventListener("visibilitychange", read); + }; } let generation = 0; @@ -154,7 +186,14 @@ export async function connect(): Promise { export function disconnect() { generation++; - useAgents.setState({ loaded: false, error: null, tasks: new Map(), entries: new Map(), changes: new Map() }); + useAgents.setState({ + loaded: false, + error: null, + tasks: new Map(), + unread: new Set(), + entries: new Map(), + changes: new Map(), + }); } function onEvent(e: AgentEvent) { @@ -163,6 +202,8 @@ function onEvent(e: AgentEvent) { case "task": { const prev = s.tasks.get(e.task.id); useAgents.setState({ tasks: new Map(s.tasks).set(e.task.id, e.task) }); + if (e.task.status === "archived") setUnread(e.task.id, false); + else if (attentionChange(prev?.status, e.task.status)) markUnread(e.task.id); notifyChange(prev, e.task); if (prev?.status !== e.task.status && !isBusy(e.task.status)) { void refreshChanges(e.task.id); @@ -175,7 +216,9 @@ function onEvent(e: AgentEvent) { tasks.delete(e.id); const composers = new Map(s.composers); composers.delete(e.id); - useAgents.setState({ tasks, composers }); + const unread = new Set(s.unread); + unread.delete(e.id); + useAgents.setState({ tasks, composers, unread }); editorService().closeInput?.(sessionKey(e.id)); break; } @@ -186,6 +229,14 @@ function onEvent(e: AgentEvent) { break; } case "entry": { + if ( + !e.subagent && + ((e.entry.type === "agent" && e.entry.text.trim()) || + e.entry.type === "error" || + (e.entry.type === "permission" && e.entry.chosen === null)) + ) { + markUnread(e.task); + } const key = entriesKey(e.task, e.subagent); const current = s.entries.get(key); if (!current) return; @@ -203,7 +254,7 @@ function onEvent(e: AgentEvent) { function notifyChange(prev: AgentTask | undefined, task: AgentTask) { const change = attentionChange(prev?.status, task.status); if (!change) return; - if (visible.has(task.id) && document.hasFocus()) return; + if (viewing(task.id)) return; const body = change === "input" ? "Waiting for your answer" diff --git a/src/features/agents/unread.test.tsx b/src/features/agents/unread.test.tsx new file mode 100644 index 000000000..3c78162e1 --- /dev/null +++ b/src/features/agents/unread.test.tsx @@ -0,0 +1,224 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import type { AgentEntry, AgentEvent, AgentTask } from "@/bindings"; +import { useContributions } from "@/workbench/contributions"; +import { AgentsView } from "./AgentsView"; +import { AgentsStatus } from "./AgentsStatus"; +import { activate } from "./index"; +import { connect, disconnect, markVisible, useAgents } from "./store"; + +const backend = vi.hoisted(() => ({ + channel: null as { onmessage: (event: AgentEvent) => void } | null, + tasks: [] as AgentTask[], + notify: vi.fn(), + openInput: vi.fn(), + closeInput: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", async (original) => ({ + ...(await original()), + Channel: class { + onmessage = () => {}; + }, +})); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + isTauri: () => true, + commands: { + agentSubscribe: async (channel: typeof backend.channel) => { + backend.channel = channel; + return { status: "ok", data: backend.tasks }; + }, + agentProviders: async () => ({ status: "ok", data: [] }), + agentChanges: async () => ({ status: "ok", data: [] }), + }, +})); +vi.mock("@/workbench/contributions", async (original) => ({ + ...(await original()), + editorService: () => ({ openInput: backend.openInput, closeInput: backend.closeInput }), + notifications: () => ({ notify: backend.notify }), +})); + +const task = (id: string, status: AgentTask["status"] = "running"): AgentTask => ({ + id, + title: id, + status, + provider: "codex", + isolation: "local", + model: null, + cwd: "/project", + branch: null, + base: null, + baseCommit: null, + error: null, + createdMs: 0, + updatedMs: 0, + config: [], + commands: [], + usage: null, + queue: [], +}); + +const emit = (event: AgentEvent) => act(() => backend.channel!.onmessage(event)); +const reply = (id = "one", text = "New reply") => + emit({ type: "entry", task: id, subagent: null, index: 0, entry: { type: "agent", text } }); +const visible: Array<() => void> = []; +const show = (id = "one") => { + let hide!: () => void; + act(() => { + hide = markVisible(id); + }); + visible.push(hide); + return () => visible.splice(visible.indexOf(hide), 1)[0]!(); +}; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + disconnect(); + backend.tasks = [task("one"), task("two", "ready")]; + await connect(); +}); +afterEach(() => { + visible.splice(0).forEach((hide) => hide()); + disconnect(); + vi.restoreAllMocks(); +}); + +describe("agent unread activity", () => { + it("does not mark historical tasks unread, but tracks replies even without loaded entries", () => { + expect(useAgents.getState().unread.size).toBe(0); + reply(); + reply("one", "New reply, streamed further"); + reply("two"); + expect([...useAgents.getState().unread]).toEqual(["one", "two"]); + expect(useAgents.getState().entries.size).toBe(0); + expect(backend.notify).not.toHaveBeenCalled(); + }); + + it.each(["ready", "needsInput", "failed", "limited"] as const)("marks background %s transitions unread", (status) => { + emit({ type: "task", task: task("one", status) }); + expect(useAgents.getState().unread.has("one")).toBe(true); + expect(backend.notify).toHaveBeenCalledTimes(1); + show(); + emit({ type: "task", task: task("one", status) }); + expect(useAgents.getState().unread.size).toBe(0); + expect(backend.notify).toHaveBeenCalledTimes(1); + }); + + it("ignores user messages, thoughts, empty replies and subagent streaming", () => { + const entries: AgentEntry[] = [ + { type: "user", text: "Do this", attachments: [] }, + { type: "thought", text: "Thinking" }, + { type: "agent", text: " " }, + ]; + for (const entry of entries) emit({ type: "entry", task: "one", subagent: null, index: 0, entry }); + emit({ type: "entry", task: "one", subagent: "child", index: 0, entry: { type: "agent", text: "Working" } }); + emit({ type: "task", task: { ...task("one"), title: "Renamed" } }); + expect(useAgents.getState().unread.size).toBe(0); + }); + + it("clears only the viewed task and keeps reading while either split shows it", () => { + reply(); + reply("two"); + const hide = show(); + show(); + hide(); + reply(); + emit({ type: "task", task: task("one", "ready") }); + expect([...useAgents.getState().unread]).toEqual(["two"]); + expect(backend.notify).not.toHaveBeenCalled(); + }); + + it("keeps a visible session unread while the window is unfocused, then clears on focus", () => { + vi.mocked(document.hasFocus).mockReturnValue(false); + reply(); + show(); + expect(useAgents.getState().unread.has("one")).toBe(true); + vi.mocked(document.hasFocus).mockReturnValue(true); + fireEvent(window, new Event("focus")); + expect(useAgents.getState().unread.size).toBe(0); + reply(); + expect(useAgents.getState().unread.size).toBe(0); + }); + + it("does not read hidden windows and removes focus listeners when the session closes", () => { + vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); + const hide = show(); + reply(); + fireEvent(window, new Event("focus")); + expect(useAgents.getState().unread.has("one")).toBe(true); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + fireEvent(document, new Event("visibilitychange")); + expect(useAgents.getState().unread.size).toBe(0); + hide(); + reply(); + fireEvent(window, new Event("focus")); + expect(useAgents.getState().unread.has("one")).toBe(true); + }); + + it("cleans up archived and deleted tasks, ignores late replies, and resets on project switch", async () => { + reply(); + reply("two"); + emit({ type: "task", task: task("one", "archived") }); + reply(); + expect([...useAgents.getState().unread]).toEqual(["two"]); + emit({ type: "removed", id: "two" }); + reply("two"); + expect(useAgents.getState().unread.size).toBe(0); + emit({ type: "task", task: task("one") }); + reply(); + const oldChannel = backend.channel!; + disconnect(); + backend.tasks = [task("other")]; + await connect(); + oldChannel.onmessage({ type: "task", task: task("one", "ready") }); + expect(useAgents.getState().unread.size).toBe(0); + expect([...useAgents.getState().tasks.keys()]).toEqual(["other"]); + }); +}); + +describe("agent unread indicators", () => { + it("shows a dot on the task and a clickable status count even when all tasks are idle", () => { + emit({ type: "task", task: task("one", "ready") }); + render( + <> + + + , + ); + expect(screen.getByRole("img", { name: "Unread" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "1 unread" })); + expect(backend.openInput).toHaveBeenCalledWith(expect.objectContaining({ data: { task: "one", title: "one" } }), { + focus: true, + }); + show(); + expect(screen.queryByRole("img", { name: "Unread" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /unread/ })).not.toBeInTheDocument(); + }); + + it("counts unread and needs-input tasks once each in the sidebar badge", () => { + const dispose = activate(); + try { + const useBadge = useContributions.getState().toolWindows.get("agents")!.useBadge!; + function Badge() { + return {useBadge()}; + } + act(() => + useAgents.setState({ + tasks: new Map([task("one", "needsInput"), task("two", "ready")].map((t) => [t.id, t])), + unread: new Set(["one", "two"]), + }), + ); + render(); + expect(screen.getByTestId("badge")).toHaveTextContent("2"); + show("two"); + expect(screen.getByTestId("badge")).toHaveTextContent("1"); + show("one"); + expect(screen.getByTestId("badge")).toHaveTextContent("1"); + } finally { + act(dispose); + } + }); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index fba57530b..d9b6ffed2 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2152,6 +2152,7 @@ "Underscored": "下划线", "Underwaved": "波浪线", "Undo": "撤消", + "Unread": "未读", "Undo Commit": "撤消提交", "Undo Commit…": "撤消提交…", "Undo Move / Rename": "撤消移动/重命名", @@ -3244,6 +3245,7 @@ "Also remove its worktree folder": "同时删除它的 worktree 目录", "needs input": "等待输入", "running": "正在运行", + "unread": "未读", "This agent task no longer exists": "这个 Agent 任务已不存在", "Local checkout": "当前工作区", "Needs input": "等待输入", From b1347dd3311f3cc5f1793aa14e8d98b97cc805dd Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:49:01 +0800 Subject: [PATCH 3/3] fix(lsp): keep background usage counts from delaying navigation (#282) * fix(lsp): keep background usage counts from delaying navigation * fix(lsp): retry incomplete counts and preserve newer hints --- src/editor/lsp/codeLens.test.ts | 192 ++++++++++++++++++++++- src/editor/lsp/codeLens.ts | 65 +++++--- src/editor/lsp/hint.test.ts | 62 ++++++++ src/editor/lsp/hint.ts | 27 +++- src/editor/lsp/navigation.ts | 9 +- src/editor/lsp/navigationPending.test.ts | 62 ++++++++ src/platform/locales/text/zh.json | 1 + 7 files changed, 394 insertions(+), 24 deletions(-) create mode 100644 src/editor/lsp/hint.test.ts create mode 100644 src/editor/lsp/navigationPending.test.ts diff --git a/src/editor/lsp/codeLens.test.ts b/src/editor/lsp/codeLens.test.ts index d9f450437..69b5be68b 100644 --- a/src/editor/lsp/codeLens.test.ts +++ b/src/editor/lsp/codeLens.test.ts @@ -1,6 +1,72 @@ -import { describe, expect, it } from "vitest"; -import type { OutlineItem } from "@/platform/ipc"; -import { lensTargets } from "./codeLens"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { OutlineItem, References } from "@/platform/ipc"; +import { _addEditor, _removeEditor, codeLenses, documentSynced } from "@/editor/api"; + +const ipc = vi.hoisted(() => ({ + docOutline: vi.fn(), + lspReferences: vi.fn(), + cancelRequest: vi.fn<(id: number) => Promise>(async () => {}), +})); +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } }; +}); +const executeAction = vi.hoisted(() => vi.fn(async () => true)); +vi.mock("@/platform/actions", () => ({ executeAction })); + +const { lensTargets, usagesLensExtension } = await import("./codeLens"); +const { lspDoc } = await import("./lsp"); +const { useProject } = await import("@/platform/project"); +const { emitLocalIdeEvent } = await import("@/platform/ipc"); + +const ctx = { doc: 7, path: "/p/a.rs", language: "rust", uiMode: "Rust", readOnly: false }; +const views: EditorView[] = []; +const ok = (data: T) => ({ status: "ok" as const, data }); +const refs = (n: number, incomplete: References["incomplete"] = null) => + ok({ + locations: Array.from({ length: n }, () => ({ path: "/p/b.rs", range: item("x", "function", 0).range })), + incomplete, + }); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +function editor(count = 1, sync?: () => Promise) { + const view = new EditorView({ + state: EditorState.create({ + doc: Array.from({ length: count }, (_, i) => `fn f${i}() {}`).join("\n"), + extensions: [lspDoc.of(ctx), usagesLensExtension(), ...(sync ? [documentSynced.of(sync)] : [])], + }), + parent: document.body, + }); + views.push(view); + return view; +} + +const titles = (view: EditorView) => view.state.facet(codeLenses).map((l) => l.title); +const settle = () => vi.advanceTimersByTimeAsync(1000); + +beforeEach(() => { + vi.useFakeTimers(); + ipc.docOutline.mockReset().mockResolvedValue(ok([item("f0", "function", 0)])); + ipc.lspReferences.mockReset().mockResolvedValue(refs(2)); + ipc.cancelRequest.mockClear(); + executeAction.mockClear(); + useProject.setState({ + settings: { editor: { inlay: { codeVisionUsages: true, codeVisionAuthor: false } } } as never, + }); +}); + +afterEach(() => { + views.splice(0).forEach((v) => v.destroy()); + useProject.setState({ settings: null }); + vi.useRealTimers(); +}); const item = (name: string, kind: OutlineItem["kind"], line: number, children: OutlineItem[] = []): OutlineItem => { const r = { start: { line, col: 0 }, end: { line, col: 1 } }; @@ -18,3 +84,123 @@ describe("usages lens targets", () => { expect(lensTargets(items).map((t) => t.name)).toEqual(["Cart", "count"]); }); }); + +describe("background usage counts", () => { + it("uses the server's existing scope and leaves full searches to the explicit action", async () => { + const view = editor(); + await settle(); + expect(ipc.lspReferences).toHaveBeenCalledWith(expect.any(Number), 7, { line: 0, col: 0 }, false, false); + expect(titles(view)).toEqual(["2 usages"]); + view.state.facet(codeLenses)[0]!.run!(view, new MouseEvent("click")); + expect(executeAction).toHaveBeenCalledWith("find.showUsages"); + }); + + it("allows a full search even when the server's current scope has no usages", async () => { + ipc.lspReferences.mockResolvedValue(refs(0)); + const view = editor(); + await settle(); + expect(titles(view)).toEqual(["no usages"]); + view.state.facet(codeLenses)[0]!.run!(view, new MouseEvent("click")); + expect(executeAction).toHaveBeenCalledWith("find.showUsages"); + }); + + it("does not cache an incomplete count while indexing, and retries on server refresh", async () => { + ipc.lspReferences.mockResolvedValueOnce(refs(0, "indexing")); + const view = editor(); + await settle(); + expect(titles(view)).toEqual([]); + emitLocalIdeEvent({ type: "lspRefresh", kind: "codeLens", server: 1 }); + await settle(); + expect(ipc.lspReferences).toHaveBeenCalledTimes(2); + expect(titles(view)).toEqual(["2 usages"]); + }); + + it("cancels obsolete requests immediately on edits and ignores their late replies", async () => { + const old = deferred>(); + ipc.lspReferences.mockReturnValueOnce(old.promise); + const view = editor(); + await settle(); + const request = ipc.lspReferences.mock.calls[0]![0]; + view.dispatch({ changes: { from: view.state.doc.length, insert: " " } }); + expect(ipc.cancelRequest).toHaveBeenCalledWith(request); + await settle(); + expect(titles(view)).toEqual(["2 usages"]); + old.resolve(refs(99)); + await settle(); + expect(titles(view)).toEqual(["2 usages"]); + }); + + it("retries incomplete counts when indexing ends without a code-lens refresh", async () => { + ipc.lspReferences.mockResolvedValueOnce(refs(0, "indexing")); + const view = editor(); + emitLocalIdeEvent({ type: "languageServer", server: 1, name: "rust-analyzer", status: "busy", message: null }); + await settle(); + expect(titles(view)).toEqual([]); + await settle(); + emitLocalIdeEvent({ type: "languageServer", server: 1, name: "rust-analyzer", status: "ready", message: null }); + await settle(); + expect(ipc.lspReferences).toHaveBeenCalledTimes(2); + expect(titles(view)).toEqual(["2 usages"]); + }); + + it("does not repeat complete counts when the server finishes other work", async () => { + const view = editor(); + emitLocalIdeEvent({ type: "languageServer", server: 1, name: "rust-analyzer", status: "busy", message: null }); + await settle(); + await settle(); + emitLocalIdeEvent({ type: "languageServer", server: 1, name: "rust-analyzer", status: "ready", message: null }); + await settle(); + expect(ipc.lspReferences).toHaveBeenCalledTimes(1); + expect(titles(view)).toEqual(["2 usages"]); + }); + + it("cancels all in-flight requests on destroy without starting the next batch", async () => { + const pending = deferred>(); + ipc.docOutline.mockResolvedValue(ok(Array.from({ length: 8 }, (_, i) => item(`f${i}`, "function", i)))); + ipc.lspReferences.mockReturnValue(pending.promise); + const view = editor(8); + await settle(); + expect(ipc.lspReferences).toHaveBeenCalledTimes(4); + const requests = ipc.lspReferences.mock.calls.map(([id]) => id); + view.destroy(); + views.splice(views.indexOf(view), 1); + expect(ipc.cancelRequest.mock.calls.map(([id]) => id)).toEqual(requests); + pending.resolve(refs(1)); + await settle(); + expect(ipc.lspReferences).toHaveBeenCalledTimes(4); + }); + + it("cancels pending counts when the feature is disabled", async () => { + const pending = deferred>(); + ipc.lspReferences.mockReturnValue(pending.promise); + const view = editor(); + await settle(); + useProject.setState({ + settings: { editor: { inlay: { codeVisionUsages: false, codeVisionAuthor: false } } } as never, + }); + expect(ipc.cancelRequest).toHaveBeenCalledWith(ipc.lspReferences.mock.calls[0]![0]); + pending.resolve(refs(2)); + await settle(); + expect(titles(view)).toEqual([]); + }); + + it("does not send obsolete requests after waiting for document synchronization", async () => { + const synced = deferred(); + const view = editor(1, () => synced.promise); + const handle = { view, ctx }; + _addEditor(handle); + try { + await settle(); + expect(ipc.docOutline).not.toHaveBeenCalled(); + view.destroy(); + views.splice(views.indexOf(view), 1); + synced.resolve(); + await settle(); + expect(ipc.docOutline).not.toHaveBeenCalled(); + expect(ipc.lspReferences).not.toHaveBeenCalled(); + expect(ipc.cancelRequest).not.toHaveBeenCalled(); + } finally { + _removeEditor(handle); + } + }); +}); diff --git a/src/editor/lsp/codeLens.ts b/src/editor/lsp/codeLens.ts index 132672435..8f7d136e7 100644 --- a/src/editor/lsp/codeLens.ts +++ b/src/editor/lsp/codeLens.ts @@ -9,11 +9,11 @@ import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { codeLenses, type CodeLens } from "@/editor/api"; import { executeAction } from "@/platform/actions"; -import { commands, nextRequestId, type OutlineItem, type SymbolKind } from "@/platform/ipc"; +import { commands, nextRequestId, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { debounce, docOf, isUnavailable, lspRequest } from "./lsp"; import { posToOffset } from "./positions"; -import { featureBackoff, onLspRefresh } from "./availability"; +import { featureBackoff, onLspRefresh, onServerSettled } from "./availability"; import { editorPrefs } from "@/platform/editorSettings"; import { authorLabel, authorSummary, authorTooltip, blameFor, onBlameInvalidated } from "./authorLens"; import { tx } from "@/platform/i18n"; @@ -106,14 +106,17 @@ function openUsages(view: EditorView, offset: number) { } class UsagesLensPlugin { - private refresh = debounce(() => void this.run(), 900); + private schedule = debounce(() => void this.run(), 900); /** `${line}:${col}:${name}` → count, valid for `cacheDoc`. */ private cache = new Map(); private cacheDoc: Text | null = null; private outline: { doc: Text; targets: LensTarget[] } | null = null; private generation = 0; + private pending = new Set(); + private incomplete = false; private unsubscribe: () => void; private offReady: () => void; + private offSettled: () => void; private offBlame: () => void; constructor(private view: EditorView) { @@ -126,6 +129,9 @@ class UsagesLensPlugin { this.cache.clear(); this.refresh(); }); + this.offSettled = onServerSettled(() => { + if (this.incomplete) this.refresh(); + }); this.offBlame = onBlameInvalidated(() => this.refresh()); } @@ -133,12 +139,23 @@ class UsagesLensPlugin { if (u.docChanged || u.viewportChanged) this.refresh(); } + private cancel() { + this.generation++; + for (const id of this.pending) void commands.cancelRequest(id).catch(() => {}); + this.pending.clear(); + } + + private refresh() { + this.cancel(); + this.schedule(); + } + private async run() { const view = this.view; if (!view.dom.isConnected) return; const doc = docOf(view); if (!doc) return; - const gen = ++this.generation; + const gen = this.generation; const usages = usagesEnabled() && !usagesBackoff.active(doc.doc); const authors = authorEnabled() && !doc.virtual; if (!usages && !authors) { @@ -146,6 +163,8 @@ class UsagesLensPlugin { return; } const text = view.state.doc; + const wanted = () => gen === this.generation && view.state.doc === text; + this.incomplete = false; // Blame runs beside the reference requests; it never holds up the counts. const blame = authors ? blameFor(doc.path, text) : Promise.resolve(null); if (this.cacheDoc !== text) { @@ -154,8 +173,8 @@ class UsagesLensPlugin { } try { if (this.outline?.doc !== text) { - const items = await lspRequest(() => commands.docOutline(doc.doc)); - if (gen !== this.generation || view.state.doc !== text) return; + const items = await lspRequest(() => commands.docOutline(doc.doc), wanted); + if (!wanted()) return; this.outline = { doc: text, targets: lensTargets(items) }; } const { from, to } = view.viewport; @@ -170,21 +189,30 @@ class UsagesLensPlugin { for (let i = 0; i < todo.length && !unavailable; i += CONCURRENCY) { await Promise.all( todo.slice(i, i + CONCURRENCY).map(async (t) => { + const request = nextRequestId(); try { - const { locations: refs } = await lspRequest(() => - commands.lspReferences(nextRequestId(), doc.doc, { line: t.line, col: t.col }, false, true), - ); - this.cache.set(key(t), refs.length); + const { locations: refs, incomplete } = await lspRequest(() => { + this.pending.add(request); + // Opening candidate files for each count triggers expensive server analysis. + // Only an explicit Find Usages should widen the server's scope. + return commands.lspReferences(request, doc.doc, { line: t.line, col: t.col }, false, false); + }, wanted); + if (wanted()) { + if (incomplete) this.incomplete = true; + else this.cache.set(key(t), refs.length); + } } catch (e) { - if (isUnavailable(e)) unavailable = true; + if (wanted() && isUnavailable(e)) unavailable = true; + } finally { + this.pending.delete(request); } }), ); - if (gen !== this.generation || view.state.doc !== text) return; + if (!wanted()) return; } if (unavailable) usagesBackoff.mark(doc.doc); const lines = await blame; - if (gen !== this.generation || view.state.doc !== text) return; + if (!wanted()) return; const lenses: CodeLens[] = []; for (const t of visible) { const offset = posToOffset(text, { line: t.line, col: t.col }); @@ -196,23 +224,24 @@ class UsagesLensPlugin { lenses.push({ pos: offset, title: usagesLabel(n), - tooltip: n ? tx`Show usages of ${t.name}` : undefined, + tooltip: tx`Show usages of ${t.name}`, order: 10, - run: n ? (v) => openUsages(v, Math.min(offset, v.state.doc.length)) : undefined, + run: (v) => openUsages(v, Math.min(offset, v.state.doc.length)), }); } if (!sameLenses(view.state.field(lensField), lenses)) view.dispatch({ effects: setLenses.of(lenses) }); } catch (e) { - if (isUnavailable(e)) usagesBackoff.mark(doc.doc); + if (wanted() && isUnavailable(e)) usagesBackoff.mark(doc.doc); } } destroy() { - this.refresh.cancel(); + this.schedule.cancel(); this.unsubscribe(); this.offReady(); + this.offSettled(); this.offBlame(); - this.generation++; + this.cancel(); } } diff --git a/src/editor/lsp/hint.test.ts b/src/editor/lsp/hint.test.ts new file mode 100644 index 000000000..fedff076c --- /dev/null +++ b/src/editor/lsp/hint.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { showHint, showPendingHint } from "./hint"; + +let view: EditorView; +const hint = () => document.querySelector(".cm-lsp-hint")?.textContent ?? null; + +beforeEach(() => { + vi.useFakeTimers(); + view = new EditorView({ state: EditorState.create({ doc: "foo()" }), parent: document.body }); +}); +afterEach(() => { + view.destroy(); + vi.useRealTimers(); +}); + +describe("pending hints", () => { + it("does not flash for fast requests", () => { + const done = showPendingHint(view, "Waiting…"); + vi.advanceTimersByTime(100); + done(); + vi.advanceTimersByTime(1000); + expect(hint()).toBeNull(); + }); + + it("shows a slow request and removes its hint on completion", () => { + const done = showPendingHint(view, "Waiting…"); + vi.advanceTimersByTime(250); + expect(hint()).toBe("Waiting…"); + done(); + expect(hint()).toBeNull(); + }); + + it("does not dismiss a newer hint when an old request finishes", () => { + const done = showPendingHint(view, "Waiting…"); + vi.advanceTimersByTime(250); + showHint(view, "New error", { tone: "error" }); + done(); + expect(hint()).toBe("New error"); + }); + + it("does not overwrite a newer hint while waiting to show progress", () => { + const done = showPendingHint(view, "Waiting…"); + vi.advanceTimersByTime(100); + showHint(view, "New error", { tone: "error" }); + vi.advanceTimersByTime(150); + expect(hint()).toBe("New error"); + done(); + expect(hint()).toBe("New error"); + }); + + it.each(["edit", "move", "destroy"])("does not show stale progress after %s", (change) => { + const done = showPendingHint(view, "Waiting…"); + if (change === "edit") view.dispatch({ changes: { from: 0, insert: "x" } }); + if (change === "move") view.dispatch({ selection: { anchor: 3 } }); + if (change === "destroy") view.destroy(); + vi.advanceTimersByTime(250); + done(); + expect(hint()).toBeNull(); + }); +}); diff --git a/src/editor/lsp/hint.ts b/src/editor/lsp/hint.ts index d103f78ad..8db8656a1 100644 --- a/src/editor/lsp/hint.ts +++ b/src/editor/lsp/hint.ts @@ -67,11 +67,36 @@ export function showHint( timers.set( view, setTimeout(() => { - if (view.state.field(hintField, false)) view.dispatch({ effects: setHint.of(null) }); + if (view.dom.isConnected && view.state.field(hintField, false)) view.dispatch({ effects: setHint.of(null) }); }, opts.timeout ?? 4000), ); } +/** Show progress only for slow requests; the disposer removes only its own hint. */ +export function showPendingHint(view: EditorView, content: string): () => void { + const { doc, selection } = view.state; + const previous = view.state.field(hintField, false); + let shown: Hint | null = null; + const timer = setTimeout(() => { + if ( + !view.dom.isConnected || + view.state.doc !== doc || + !view.state.selection.eq(selection) || + view.state.field(hintField, false) !== previous + ) + return; + showHint(view, content, { timeout: 30_000 }); + shown = view.state.field(hintField); + }, 250); + return () => { + clearTimeout(timer); + if (shown && view.state.field(hintField, false) === shown) { + clearTimeout(timers.get(view)); + if (view.dom.isConnected) hideHint(view); + } + }; +} + export function hideHint(view: EditorView): boolean { if (!view.state.field(hintField, false)) return false; view.dispatch({ effects: setHint.of(null) }); diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index cede48117..33052b67b 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -46,7 +46,7 @@ import { localHighlights, type DeclarationTarget, } from "./syntaxFallback"; -import { showHint } from "./hint"; +import { showHint, showPendingHint } from "./hint"; import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore"; import { identifierAt, readManyFileLines } from "./files"; import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links"; @@ -166,7 +166,12 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) const declaration = kind === "definition" || kind === "declaration"; let serverDown = false; try { - locs = dedupeLocations(await gotoAfterEdits(view, () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind)))); + const done = showPendingHint(view, tx`Finding navigation target…`); + try { + locs = dedupeLocations(await gotoAfterEdits(view, () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind)))); + } finally { + done(); + } } catch (e) { if (isCancelled(e) || !declaration) { reportLspError(view, e, true); diff --git a/src/editor/lsp/navigationPending.test.ts b/src/editor/lsp/navigationPending.test.ts new file mode 100644 index 000000000..50589aca2 --- /dev/null +++ b/src/editor/lsp/navigationPending.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { commands } from "@/platform/ipc"; + +const lspGoto = vi.hoisted(() => vi.fn()); +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto } }; +}); + +const { gotoTargets } = await import("./navigation"); +const { lspDoc } = await import("./lsp"); +const { provideEditorService } = await import("@/workbench/contributions"); +const openFile = vi.fn(async () => {}); +provideEditorService({ openFile } as never); + +let view: EditorView; +beforeEach(() => { + vi.useFakeTimers(); + openFile.mockClear(); + view = new EditorView({ + state: EditorState.create({ + doc: "run()", + extensions: [lspDoc.of({ doc: 1, path: "/p/main.rs", language: "rust", uiMode: "Rust", readOnly: false })], + }), + parent: document.body, + }); +}); +afterEach(() => { + view.destroy(); + vi.useRealTimers(); +}); + +describe("slow navigation feedback", () => { + it("shows progress while finding a definition, then opens the target and clears the hint", async () => { + let resolve!: (result: Awaited>) => void; + lspGoto.mockImplementationOnce(() => new Promise((r) => (resolve = r))); + const pending = gotoTargets(view, "definition"); + await vi.advanceTimersByTimeAsync(250); + expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("Finding navigation target…"); + expect(openFile).not.toHaveBeenCalled(); + resolve({ + status: "ok", + data: [{ path: "/p/lib.rs", range: { start: { line: 3, col: 4 }, end: { line: 3, col: 7 } }, uri: null }], + }); + await pending; + expect(openFile).toHaveBeenCalledWith("/p/lib.rs", { selection: { line: 3, col: 4 }, focus: true }); + expect(document.querySelector(".cm-lsp-hint")).toBeNull(); + }); + + it("replaces progress with the server error", async () => { + let resolve!: (result: Awaited>) => void; + lspGoto.mockImplementationOnce(() => new Promise((r) => (resolve = r))); + const pending = gotoTargets(view, "implementation"); + await vi.advanceTimersByTimeAsync(250); + resolve({ status: "error", error: { kind: "protocol", message: "Server timed out" } }); + await pending; + expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("Server timed out"); + expect(openFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d9b6ffed2..e506b0f7b 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1033,6 +1033,7 @@ "Log filters": "日志筛选器", "Long tasks": "长任务", "Looking for references…": "正在查找引用…", + "Finding navigation target…": "正在查找跳转目标…", "Looks for TODO and FIXME in added lines.": "在新增的行中查找 TODO 和 FIXME。", "Macros": "宏", "Main Menu": "主菜单",