Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
2dbeafc
lsp integration draft
terion-name Apr 3, 2026
0fb38f1
address review comments
terion-name Apr 13, 2026
115e5f7
Add post-edit LSP diagnostics for mutating tools
terion-name Apr 13, 2026
c11119b
Fix post-edit diagnostics freshness and patch file tracking
terion-name Apr 13, 2026
4f4ee43
Fail closed on task_apply_git_patch diff discovery errors
terion-name Apr 13, 2026
21781c6
🤖 feat: add workspace LSP diagnostics snapshots
terion-name Apr 13, 2026
7248bd6
🤖 fix: harden LSP diagnostics recovery
terion-name Apr 13, 2026
7d52175
fix lsp disposal diagnostics races
terion-name Apr 13, 2026
b43cd80
🤖 feat: add stats diagnostics panel
terion-name Apr 13, 2026
d9dccb3
fix diagnostics stats follow-ups
terion-name Apr 13, 2026
ad65e35
tests: fix remaining lint issues in LSP/UI tests
terion-name Apr 13, 2026
f0b9d6c
🤖 refactor: split LSP launch resolution from client transport
terion-name Apr 15, 2026
62bbc24
fix lsp launch-plan probing and caching
terion-name Apr 15, 2026
59f8750
fix stale LSP launch plan cache on restart
terion-name Apr 15, 2026
c1f8a06
Add LSP provisioning policies and config
terion-name Apr 15, 2026
dda72e5
fix LSP trust gating and provisioning reuse
terion-name Apr 15, 2026
e38f10e
Add LSP provisioning setting to GeneralSection
terion-name Apr 15, 2026
c14cb96
fix: tighten LSP settings persistence
terion-name Apr 15, 2026
fea8e10
🤖 tests: fix LSP manager trust-gating expectation
terion-name Apr 15, 2026
7d70cce
fix lsp CLI test overrides
terion-name Apr 16, 2026
06cba0c
Fix mux run trust inheritance for worktree paths
terion-name Apr 16, 2026
8c7745c
🤖 fix: poll tracked LSP diagnostics
terion-name Apr 16, 2026
a9e1429
fix lsp diagnostics refresh edge cases
terion-name Apr 16, 2026
b380e56
🤖 fix: reset polled LSP workspace refresh state
terion-name Apr 16, 2026
151c555
🤖 fix: resolve nested TypeScript LSP provisioning
terion-name Apr 16, 2026
9fa1dbb
🤖 fix: harden untrusted LSP pathCommand envs
terion-name Apr 16, 2026
396f978
🤖 fix: keep LSP provisioning overrides out of temp and saved config
terion-name Apr 16, 2026
528c91b
Only poll LSP diagnostics with active listeners
terion-name Apr 16, 2026
261e9ba
Show LSP diagnostics retry state in stats
terion-name Apr 16, 2026
9bf3d4f
🤖 fix: preserve inherited env for untrusted LSP path launches
terion-name Apr 16, 2026
5072cef
🤖 fix: keep sanitized PATH for untrusted LSP launches
terion-name Apr 16, 2026
f9df0fe
Make LSP path env sanitization runtime-aware
terion-name Apr 16, 2026
af6b7d8
🤖 fix: harden pathCommand PATH sanitization
terion-name Apr 16, 2026
4aa6dc7
🤖 fix: infer LSP workspace symbols from directories
terion-name Apr 16, 2026
d1f2bbb
🤖 fix: prefer deepest workspace_symbols root
terion-name Apr 16, 2026
f13a143
Merge origin/main into lsp-integration
terion-name Apr 17, 2026
64aed6b
🤖 fix: aggregate directory workspace symbols across roots
terion-name Apr 17, 2026
0893d37
🤖 fix: stabilize workspace_symbols directory queries
terion-name Apr 17, 2026
9e961fb
Fix TS workspace symbol warm-up root selection
terion-name Apr 17, 2026
1968129
fix TypeScript workspace_symbols warm-up
terion-name Apr 17, 2026
d9f5948
🤖 fix: prefer exact TS workspace_symbols warm-up matches
terion-name Apr 17, 2026
521f622
🤖 refactor: split workspace symbol roots by directory
terion-name Apr 18, 2026
4845c58
Fix LSP manager test lint assertion
terion-name Apr 18, 2026
e522fe3
🤖 feat: enrich workspace_symbols directory results
terion-name Apr 18, 2026
0b36f9f
🤖 fix: exact-match Go workspace_symbols
terion-name Apr 18, 2026
b2d32e0
🤖 fix: suppress fuzzy Go workspace_symbols after cross-root exact hits
terion-name Apr 18, 2026
e026ba9
Generalize TypeScript LSP project-root selection
terion-name Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import { cleanup, fireEvent, render } from "@testing-library/react";

void mock.module("./CostsTab", () => ({
CostsTab: () => <div>Costs panel</div>,
}));

describe("StatsContainer diagnostics", () => {
let originalWindow: typeof globalThis.window;
let originalDocument: typeof globalThis.document;

beforeEach(() => {
originalWindow = globalThis.window;
originalDocument = globalThis.document;

globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
globalThis.window.localStorage.clear();
});

afterEach(() => {
cleanup();
globalThis.window = originalWindow;
globalThis.document = originalDocument;
});

test("switches to the Diagnostics sub-tab and exposes button pressed state", async () => {
// eslint-disable-next-line no-restricted-syntax -- The test must import after mock.module() so StatsContainer sees the stubbed CostsTab instead of loading Mermaid-heavy dependencies.
const { StatsContainer } = await import("./StatsContainer");
const view = render(<StatsContainer workspaceId="workspace-1" />);

const costButton = view.getByRole("button", { name: "Cost" });
const diagnosticsButton = view.getByRole("button", { name: "Diagnostics" });

expect(view.getByText("Costs panel")).toBeTruthy();
expect(costButton.getAttribute("aria-pressed")).toBe("true");
expect(diagnosticsButton.getAttribute("aria-pressed")).toBe("false");

fireEvent.click(diagnosticsButton);

expect(view.queryByText("Costs panel")).toBeNull();
expect(view.getByText("Loading diagnostics...")).toBeTruthy();
expect(costButton.getAttribute("aria-pressed")).toBe("false");
expect(diagnosticsButton.getAttribute("aria-pressed")).toBe("true");
});
});
9 changes: 7 additions & 2 deletions src/browser/features/RightSidebar/StatsContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
* - "Cost" — renders CostsTab
* - "Timing" — renders TimingPanel from StatsTab
* - "Models" — renders ModelBreakdownPanel from StatsTab
* - "Diagnostics" — renders DiagnosticsPanel from StatsTab
*/

import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { CostsTab } from "./CostsTab";
import { TimingPanel, ModelBreakdownPanel } from "./StatsTab";
import { TimingPanel, ModelBreakdownPanel, DiagnosticsPanel } from "./StatsTab";

type StatsSubTab = "cost" | "timing" | "models";
type StatsSubTab = "cost" | "timing" | "models" | "diagnostics";

interface StatsOption {
value: StatsSubTab;
Expand All @@ -22,6 +23,7 @@ const OPTIONS: StatsOption[] = [
{ value: "cost", label: "Cost" },
{ value: "timing", label: "Timing" },
{ value: "models", label: "Models" },
{ value: "diagnostics", label: "Diagnostics" },
];

interface StatsContainerProps {
Expand All @@ -43,6 +45,7 @@ export function StatsContainer(props: StatsContainerProps) {
<button
key={option.value}
type="button"
aria-pressed={isActive}
className={`rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors ${
isActive
? "bg-accent text-foreground"
Expand All @@ -59,6 +62,8 @@ export function StatsContainer(props: StatsContainerProps) {
{effectiveTab === "cost" && <CostsTab workspaceId={props.workspaceId} />}
{effectiveTab === "timing" && <TimingPanel workspaceId={props.workspaceId} />}
{effectiveTab === "models" && <ModelBreakdownPanel workspaceId={props.workspaceId} />}
{/* Keep the LSP subscription inside the leaf panel so inactive Stats tabs stay unsubscribed. */}
{effectiveTab === "diagnostics" && <DiagnosticsPanel workspaceId={props.workspaceId} />}
</div>
);
}
253 changes: 253 additions & 0 deletions src/browser/features/RightSidebar/StatsTab.diagnostics.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import { cleanup, render } from "@testing-library/react";

import type { WorkspaceLspDiagnosticsViewState } from "@/browser/stores/WorkspaceStore";

let currentDiagnosticsViewState: WorkspaceLspDiagnosticsViewState = {
snapshot: null,
connection: {
status: "loading",
errorMessage: null,
},
};

void mock.module("@/browser/stores/WorkspaceStore", () => ({
useWorkspaceStatsSnapshot: () => null,
useWorkspaceLspDiagnosticsViewState: () => currentDiagnosticsViewState,
}));

import { DiagnosticsPanel } from "./StatsTab";

describe("DiagnosticsPanel", () => {
let originalWindow: typeof globalThis.window;
let originalDocument: typeof globalThis.document;
let originalConsoleError: typeof console.error;
let consoleErrorCalls: unknown[][];

beforeEach(() => {
originalWindow = globalThis.window;
originalDocument = globalThis.document;
originalConsoleError = console.error;
consoleErrorCalls = [];

globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
currentDiagnosticsViewState = {
snapshot: null,
connection: {
status: "loading",
errorMessage: null,
},
};
console.error = (...args: unknown[]) => {
consoleErrorCalls.push(args);
};
});

afterEach(() => {
cleanup();
currentDiagnosticsViewState = {
snapshot: null,
connection: {
status: "loading",
errorMessage: null,
},
};
console.error = originalConsoleError;
globalThis.window = originalWindow;
globalThis.document = originalDocument;
});

test("shows a loading state before the first diagnostics snapshot arrives", () => {
const view = render(<DiagnosticsPanel workspaceId="workspace-1" />);

expect(view.getByText("Loading diagnostics...")).toBeTruthy();
});

test("shows an empty state when the diagnostics snapshot contains no entries", () => {
currentDiagnosticsViewState = {
snapshot: {
workspaceId: "workspace-1",
diagnostics: [],
},
connection: {
status: "ready",
errorMessage: null,
},
};

const view = render(<DiagnosticsPanel workspaceId="workspace-1" />);

expect(view.getByText("No diagnostics for this workspace.")).toBeTruthy();
});

test("renders multi-server diagnostics and includes unknown severities in the summary", () => {
currentDiagnosticsViewState = {
snapshot: {
workspaceId: "workspace-1",
diagnostics: [
{
uri: "file:///workspace/src/example.ts",
path: "/workspace/src/example.ts",
serverId: "typescript",
rootUri: "file:///workspace",
version: 3,
diagnostics: [
{
range: {
start: { line: 1, character: 4 },
end: { line: 1, character: 10 },
},
severity: 1,
source: "tsserver",
code: 2322,
message: "Type 'string' is not assignable to type 'number'.",
},
{
range: {
start: { line: 4, character: 2 },
end: { line: 4, character: 8 },
},
severity: 2,
source: "eslint",
code: "no-console",
message: "Unexpected console statement.",
},
],
receivedAtMs: 1,
},
{
uri: "file:///workspace/src/example.ts",
path: "/workspace/src/example.ts",
serverId: "eslint",
rootUri: "file:///workspace",
version: 3,
diagnostics: [
{
range: {
start: { line: 6, character: 1 },
end: { line: 6, character: 12 },
},
source: "eslint",
code: "custom/rule",
message: "Use the shared logger helper.",
},
],
receivedAtMs: 1,
},
{
uri: "file:///workspace/src/utils.ts",
path: "/workspace/src/utils.ts",
serverId: "typescript",
rootUri: "file:///workspace",
version: 1,
diagnostics: [
{
range: {
start: { line: 9, character: 0 },
end: { line: 9, character: 4 },
},
severity: 3,
source: "tsserver",
message: "'helper' is declared but its value is never read.",
},
],
receivedAtMs: 2,
},
],
},
connection: {
status: "ready",
errorMessage: null,
},
};

const view = render(<DiagnosticsPanel workspaceId="workspace-1" />);

expect(view.getAllByText("/workspace/src/example.ts").length).toBe(2);
expect(view.getByText("/workspace/src/utils.ts")).toBeTruthy();
expect(view.getByText("Type 'string' is not assignable to type 'number'.")).toBeTruthy();
expect(view.getByText("Unexpected console statement.")).toBeTruthy();
expect(view.getByText("Use the shared logger helper.")).toBeTruthy();
expect(view.getByText("'helper' is declared but its value is never read.")).toBeTruthy();

expect(view.container.textContent).toContain(
"4 diagnostics across 3 files · 1 error · 1 warning · 1 information · 1 unknown"
);
expect(view.container.textContent).toContain("Server: typescript");
expect(view.container.textContent).toContain("Server: eslint");
expect(view.container.textContent).toContain("Error · Line 2, Column 5 · tsserver · Code 2322");
expect(view.container.textContent).toContain(
"Warning · Line 5, Column 3 · eslint · Code no-console"
);
expect(view.container.textContent).toContain(
"Unknown · Line 7, Column 2 · eslint · Code custom/rule"
);
expect(view.container.textContent).toContain("Information · Line 10, Column 1 · tsserver");
expect(
consoleErrorCalls.some((call) =>
call.some(
(value) =>
typeof value === "string" &&
value.includes("Encountered two children with the same key")
)
)
).toBe(false);
});

test("shows a retrying state when the diagnostics subscription has not produced a snapshot yet", () => {
currentDiagnosticsViewState = {
snapshot: null,
connection: {
status: "retrying",
errorMessage: "socket dropped",
},
};

const view = render(<DiagnosticsPanel workspaceId="workspace-1" />);

expect(view.getByText("Retrying diagnostics subscription...")).toBeTruthy();
expect(view.getByText("Last error: socket dropped")).toBeTruthy();
expect(view.queryByText("Loading diagnostics...")).toBeNull();
});

test("renders a retry banner while continuing to show the last diagnostics snapshot", () => {
currentDiagnosticsViewState = {
snapshot: {
workspaceId: "workspace-1",
diagnostics: [
{
uri: "file:///workspace/src/example.ts",
path: "/workspace/src/example.ts",
serverId: "typescript",
rootUri: "file:///workspace",
version: 1,
diagnostics: [
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 5 },
},
severity: 1,
source: "tsserver",
message: "stale diagnostic",
},
],
receivedAtMs: 1,
},
],
},
connection: {
status: "retrying",
errorMessage: "stream closed unexpectedly",
},
};

const view = render(<DiagnosticsPanel workspaceId="workspace-1" />);

expect(view.getByText("Retrying diagnostics subscription...")).toBeTruthy();
expect(view.getByText("Last error: stream closed unexpectedly")).toBeTruthy();
expect(view.getByText("stale diagnostic")).toBeTruthy();
});
});
Loading