дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
Merge remote-tracking branch 'origin/main' into fix/agents-shift-selection
# Conflicts: # src/features/agents/AgentsView.tsx
Цей коміт міститься в:
коміт
964e6c13e8
17 files changed
+863
-80
No files matched your search
@@ -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<void>>(async () => {}),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
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 = <T>(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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function editor(count = 1, sync?: () => Promise<void>) {
|
||||
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<ReturnType<typeof refs>>();
|
||||
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<ReturnType<typeof refs>>();
|
||||
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<ReturnType<typeof refs>>();
|
||||
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<void>();
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>();
|
||||
private cacheDoc: Text | null = null;
|
||||
private outline: { doc: Text; targets: LensTarget[] } | null = null;
|
||||
private generation = 0;
|
||||
private pending = new Set<RequestId>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof commands.lspGoto>());
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
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<ReturnType<typeof commands.lspGoto>>) => 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<ReturnType<typeof commands.lspGoto>>) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<button type="button" className={styles.statusItem} onClick={() => target && openTask(target.id)}>
|
||||
{input.length > 0 && (
|
||||
@@ -24,6 +26,12 @@ export function AgentsStatus() {
|
||||
{running} <T>running</T>
|
||||
</span>
|
||||
)}
|
||||
{unseen.length > 0 && (
|
||||
<span className={styles.statusPart}>
|
||||
<span className={styles.unreadDot} aria-hidden="true" />
|
||||
{unseen.length} <T>unread</T>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { SESSION_KIND, age, branchLabel, groupTasks, type TaskGroup } from "./mo
|
||||
import { openHome, openTask, useAgents } from "./store";
|
||||
import { taskMenu } from "./taskActions";
|
||||
import styles from "./Agents.module.css";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
|
||||
export function AgentsHeaderActions() {
|
||||
return (
|
||||
@@ -42,9 +43,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<Set<string>>(() => new Set(["g:archived"]));
|
||||
const [selected, setSelected] = useState<Set<string>>(() => {
|
||||
@@ -120,7 +123,12 @@ export function AgentsView() {
|
||||
) : (
|
||||
<span className={styles.row} title={n.task.error ?? n.task.title}>
|
||||
<TaskStatusIcon status={n.task.status} />
|
||||
<span className={styles.title}>{n.task.title}</span>
|
||||
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
|
||||
{n.task.title}
|
||||
</span>
|
||||
{unread.has(n.task.id) && (
|
||||
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
|
||||
)}
|
||||
<span className={styles.detail}>
|
||||
{providerName(n.task.provider)}
|
||||
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
<div className={styles.popup} role="listbox" aria-label={tx(completion.kind === "file" ? "Files" : "Commands")}>
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={item.kind === "file" ? item.path : item.command.name}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
className={clsx(styles.item, i === active && styles.active)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
accept(item);
|
||||
}}
|
||||
>
|
||||
{item.kind === "file" ? (
|
||||
<>
|
||||
<Icon name={fileIconName(basename(item.rel))} size={16} />
|
||||
<span className={styles.itemName}>{basename(item.rel)}</span>
|
||||
<span className={styles.itemDetail}>{item.rel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.itemName}>/{item.command.name}</span>
|
||||
{item.command.hint && <span className={styles.itemHint}>{item.command.hint}</span>}
|
||||
<span className={styles.itemDetail}>{item.command.description}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{open && completionAnchor && (
|
||||
<Popup
|
||||
open
|
||||
anchor={completionAnchor}
|
||||
placement="bottom-start"
|
||||
autoFocus={false}
|
||||
restoreFocus={false}
|
||||
closeOnOutsideClick={false}
|
||||
onClose={() => setCompletion(null)}
|
||||
className={styles.popup}
|
||||
role="listbox"
|
||||
aria-label={tx(completion.kind === "file" ? "Files" : "Commands")}
|
||||
>
|
||||
<div className={styles.items}>
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={item.kind === "file" ? item.path : item.command.name}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
className={clsx(styles.item, i === active && styles.active)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
accept(item);
|
||||
}}
|
||||
>
|
||||
{item.kind === "file" ? (
|
||||
<>
|
||||
<Icon name={fileIconName(basename(item.rel))} size={16} />
|
||||
<span className={styles.itemName}>{basename(item.rel)}</span>
|
||||
<span className={styles.itemDetail}>{item.rel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.itemName}>/{item.command.name}</span>
|
||||
{item.command.hint && <span className={styles.itemHint}>{item.command.hint}</span>}
|
||||
<span className={styles.itemDetail}>{item.command.description}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
{images.length > 0 && (
|
||||
<div className={styles.images} onClick={(e) => 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}
|
||||
|
||||
@@ -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(<HomeEditor />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,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;
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -44,6 +44,8 @@ interface AgentsState {
|
||||
/** Why the tasks could not be loaded. */
|
||||
error: string | null;
|
||||
tasks: Map<string, AgentTask>;
|
||||
/** Tasks with unseen replies or status changes in this project. */
|
||||
unread: Set<string>;
|
||||
/** Entries of the tasks (and subagents) opened in this window, by `entriesKey`. */
|
||||
entries: Map<string, AgentEntry[]>;
|
||||
changes: Map<string, AgentChange[]>;
|
||||
@@ -85,6 +87,7 @@ export const useAgents = create<AgentsState>(() => ({
|
||||
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<ComposerDraft>) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Session tabs on screen; their tasks do not notify. */
|
||||
const visible = new Set<string>();
|
||||
/** Session tabs on screen; a task can be shown in more than one split. */
|
||||
const visible = new Map<string, number>();
|
||||
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<void> {
|
||||
|
||||
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"
|
||||
|
||||
@@ -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<typeof import("@tauri-apps/api/core")>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
},
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
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<typeof import("@/workbench/contributions")>()),
|
||||
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(
|
||||
<>
|
||||
<AgentsView />
|
||||
<AgentsStatus />
|
||||
</>,
|
||||
);
|
||||
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 <span data-testid="badge">{useBadge()}</span>;
|
||||
}
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
tasks: new Map([task("one", "needsInput"), task("two", "ready")].map((t) => [t.id, t])),
|
||||
unread: new Set(["one", "two"]),
|
||||
}),
|
||||
);
|
||||
render(<Badge />);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLTextAreaElement | null>,
|
||||
caret: number | null,
|
||||
text: string,
|
||||
): VirtualElement | null {
|
||||
const [anchor, setAnchor] = useState<VirtualElement | null>(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;
|
||||
}
|
||||
@@ -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": "主菜单",
|
||||
@@ -2152,6 +2153,7 @@
|
||||
"Underscored": "下划线",
|
||||
"Underwaved": "波浪线",
|
||||
"Undo": "撤消",
|
||||
"Unread": "未读",
|
||||
"Undo Commit": "撤消提交",
|
||||
"Undo Commit…": "撤消提交…",
|
||||
"Undo Move / Rename": "撤消移动/重命名",
|
||||
@@ -3244,6 +3246,7 @@
|
||||
"Also remove its worktree folder": "同时删除它的 worktree 目录",
|
||||
"needs input": "等待输入",
|
||||
"running": "正在运行",
|
||||
"unread": "未读",
|
||||
"This agent task no longer exists": "这个 Agent 任务已不存在",
|
||||
"Local checkout": "当前工作区",
|
||||
"Needs input": "等待输入",
|
||||
|
||||
Reference in new issue
Заблокувати користувача