Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/lint-fix-all-and-ask-ai.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": patch
---

The Problems panel gains two new lint actions. A "Fix all" button applies every auto-fixable problem at once — in the "This doc" tab it fixes the open document instantly (undoable, attributed to you), and in the "Project" tab it sweeps every fixable file and refreshes the audit, reporting any files it could not fix. On desktop, each problem row also offers "Ask AI": it composes a grounded fix prompt (document, rule, line, message, and the offending text) and types it into your running agent terminal for review — or launches Claude with it if no terminal is open. Deterministic fixes triggered from the UI are now attributed to you (the principal) rather than to an agent; fixes requested by agents over MCP keep agent attribution.
47 changes: 45 additions & 2 deletions packages/app/src/components/DocPanel.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,21 @@ mock.module('@/lib/single-file-mode', () => ({ useSingleFileMode: () => singleFi
// Lint plumbing — DocPanel computes live diagnostics to drive the Problems
// badge. Stub the source so the test controls the count without a provider/fetch.
let diagnosticsValue: Array<{ severity: string }> = [];
let activeProviderValue: unknown = null;
mock.module('@/editor/DocumentContext', () => ({
useDocumentContext: () => ({ activeProvider: null, activeDocName: 'notes' }),
useDocumentContext: () => ({ activeProvider: activeProviderValue, activeDocName: 'notes' }),
}));
mock.module('@/editor/lint-config-client', () => ({
useDocLintConfig: () => ({ data: null }),
}));
mock.module('@/editor/useDocDiagnostics', () => ({
useDocDiagnostics: () => diagnosticsValue,
}));
// Terminal availability drives the Ask-AI gate — null mirrors the web host.
let terminalLaunchValue: unknown = null;
mock.module('@/components/handoff/TerminalLaunchContext', () => ({
useTerminalLaunch: () => terminalLaunchValue,
}));

// Stub the heavy panel children so the test stays focused on tab visibility.
mock.module('@/components/OutlinePanel', () => ({
Expand All @@ -70,8 +76,12 @@ mock.module('@/components/LinksPanel', () => ({
mock.module('@/components/TimelinePanel', () => ({
TimelineContent: () => <div data-testid="timeline-panel" />,
}));
let lastProblemsProps: Record<string, unknown> | null = null;
mock.module('@/components/ProblemsPanel', () => ({
ProblemsPanel: () => <div data-testid="problems-panel" />,
ProblemsPanel: (props: Record<string, unknown>) => {
lastProblemsProps = props;
return <div data-testid="problems-panel" />;
},
}));

const { DocPanel } = await import('./DocPanel');
Expand All @@ -96,6 +106,9 @@ afterEach(() => {
cleanup();
singleFileValue = false;
diagnosticsValue = [];
activeProviderValue = null;
terminalLaunchValue = null;
lastProblemsProps = null;
});

describe('DocPanel — tab gating', () => {
Expand All @@ -121,6 +134,36 @@ describe('DocPanel — tab gating', () => {
});
});

describe('DocPanel — Problems fix/ask-ai wiring', () => {
const fakeProvider = { document: { getText: () => ({ toString: () => '' }) } };

test('web host (no terminal launch context) withholds onAskAi but keeps the fix handlers', () => {
activeProviderValue = fakeProvider;
terminalLaunchValue = null;
renderPanel('problems');
expect(lastProblemsProps?.onAskAi).toBeUndefined();
expect(typeof lastProblemsProps?.onFix).toBe('function');
expect(typeof lastProblemsProps?.onFixAll).toBe('function');
});

test('desktop host (terminal launch available) passes onAskAi alongside the fix handlers', () => {
activeProviderValue = fakeProvider;
terminalLaunchValue = { launchInTerminal: () => {}, installedClis: {} };
renderPanel('problems');
expect(typeof lastProblemsProps?.onAskAi).toBe('function');
expect(typeof lastProblemsProps?.onFixAll).toBe('function');
});

test('without a matching provider every fix/ask handler is withheld', () => {
activeProviderValue = null;
terminalLaunchValue = { launchInTerminal: () => {}, installedClis: {} };
renderPanel('problems');
expect(lastProblemsProps?.onFix).toBeUndefined();
expect(lastProblemsProps?.onFixAll).toBeUndefined();
expect(lastProblemsProps?.onAskAi).toBeUndefined();
});
});

describe('DocPanel — Problems badge', () => {
test('no badge when there are no diagnostics', () => {
diagnosticsValue = [];
Expand Down
23 changes: 22 additions & 1 deletion packages/app/src/components/DocPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { AlertTriangle, Clock, Link2, ListTree, Network } from 'lucide-react';
import { lazy, Suspense, useState } from 'react';
import type { DiffLayout } from '@/components/DiffView';
import { composeLintFixTerminalPaste } from '@/components/handoff/compose-lint-fix-prompt';
import { useTerminalLaunch } from '@/components/handoff/TerminalLaunchContext';
import { requestActiveTerminalInput } from '@/components/handoff/terminal-input-events';
import { LinksPanel } from '@/components/LinksPanel';
import { OutlinePanel } from '@/components/OutlinePanel';
import { ProblemsPanel } from '@/components/ProblemsPanel';
import { TimelineContent } from '@/components/TimelinePanel';
import { Badge } from '@/components/ui/badge';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { applyLintFixes } from '@/editor/apply-lint-fix';
import { applyLintFixes, collectFixes } from '@/editor/apply-lint-fix';
import { useDocumentContext } from '@/editor/DocumentContext';
import { useDocLintConfig } from '@/editor/lint-config-client';
import { useDocDiagnostics } from '@/editor/useDocDiagnostics';
Expand Down Expand Up @@ -95,6 +98,22 @@ export function DocPanel({
applyLintFixes(lintProvider, diagnostic.fixes);
}
};
const handleFixAll = () => {
if (lintProvider !== null) {
applyLintFixes(lintProvider, collectFixes(diagnostics));
}
};
// Hand one diagnostic to the docked terminal's agent as a grounded prompt
// (live TUI reuse or a fresh Claude launch — the host decides). Desktop-only:
// on web nothing subscribes to the terminal-input event, so the button is
// withheld entirely by not passing `onAskAi`.
const terminalLaunch = useTerminalLaunch();
const handleAskAi = (diagnostic: (typeof diagnostics)[number]) => {
if (lintProvider === null) return;
const source = lintProvider.document.getText('source').toString();
const lineText = source.split('\n')[diagnostic.range.start.line];
requestActiveTerminalInput(composeLintFixTerminalPaste(docName, diagnostic, lineText));
};
// Single-file `ok <file>` keeps only the Outline + Problems tabs. Links/Graph
// need a multi-doc knowledge base, and Timeline is git history — all empty or
// inert for a lone git-off file; linting applies to any single file. Coerce a
Expand Down Expand Up @@ -198,6 +217,8 @@ export function DocPanel({
docName={docName}
diagnostics={diagnostics}
onFix={lintProvider !== null ? handleFix : undefined}
onFixAll={lintProvider !== null ? handleFixAll : undefined}
onAskAi={lintProvider !== null && terminalLaunch !== null ? handleAskAi : undefined}
/>
)}
</div>
Expand Down
177 changes: 177 additions & 0 deletions packages/app/src/components/ProblemsPanel.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,23 @@ mock.module('@lingui/react/macro', () => linguiMacroMock);

let auditCalls = 0;
let runLintAuditImpl: () => Promise<LintAuditResponse | null> = async () => null;
let fixLintDocCalls: string[] = [];
let fixLintDocImpl: (docName: string) => Promise<{ ok: boolean; errorDetail?: string | null }> =
async () => ({ ok: true });
const toastError = mock((_message: string) => {});

mock.module('sonner', () => ({ toast: { error: toastError } }));
mock.module('@/editor/lint-config-client', () => ({
emitLintConfigChanged: () => {},
subscribeToLintConfigChanged: () => () => {},
runLintAudit: () => {
auditCalls += 1;
return runLintAuditImpl();
},
fixLintDoc: (docName: string) => {
fixLintDocCalls.push(docName);
return fixLintDocImpl(docName);
},
useDocLintConfig: () => ({ data: null }),
useProjectLintConfig: () => ({ data: null }),
fetchEffectiveLintConfig: async () => null,
Expand Down Expand Up @@ -76,6 +85,9 @@ function auditResult(over: Partial<LintAuditResponse> = {}): LintAuditResponse {
beforeEach(() => {
auditCalls = 0;
runLintAuditImpl = async () => null;
fixLintDocCalls = [];
fixLintDocImpl = async () => ({ ok: true });
toastError.mockClear();
});

afterEach(() => {
Expand Down Expand Up @@ -126,6 +138,82 @@ describe('ProblemsPanel', () => {
expect(screen.queryByRole('button', { name: /Fix markdownlint/ })).toBeNull();
});

test('doc-scope Fix all renders when onFixAll is provided and calls it on click', () => {
const fixable = diag({
code: 'MD010',
fixes: [
{
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 1 } },
newText: ' ',
},
],
});
const unfixable = diag({ line: 5, code: 'MD025', message: 'Multiple H1' });
const onFixAll = mock(() => {});
render(
<ProblemsPanel docName="notes" diagnostics={[fixable, unfixable]} onFixAll={onFixAll} />,
);
const button = screen.getByTestId('problems-fix-all') as HTMLButtonElement;
expect(button.disabled).toBe(false);
// The label sizes the click before it happens: only the fixable diagnostic
// counts, not the total problem count.
expect(button.textContent).toContain('(1)');
button.click();
expect(onFixAll).toHaveBeenCalledTimes(1);
});

test('Ask AI renders on fixable AND unfixable rows only when onAskAi is provided', () => {
const fixable = diag({
line: 3,
code: 'MD010',
fixes: [
{
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 1 } },
newText: ' ',
},
],
});
const unfixable = diag({ line: 5, code: 'MD025', message: 'Multiple H1' });
const onAskAi = mock(() => {});
const { unmount } = render(
<ProblemsPanel docName="notes" diagnostics={[fixable, unfixable]} onAskAi={onAskAi} />,
);
const askButtons = screen.getAllByTestId('problems-ask-ai');
// One per row — the unfixable row gets it too (AI matters most where no
// deterministic fix exists).
expect(askButtons).toHaveLength(2);
askButtons[1]?.click();
expect(onAskAi).toHaveBeenCalledTimes(1);
expect((onAskAi.mock.calls[0] as unknown[])[0]).toMatchObject({ code: 'MD025' });
unmount();

render(<ProblemsPanel docName="notes" diagnostics={[fixable, unfixable]} />);
expect(screen.queryByTestId('problems-ask-ai')).toBeNull();
});

test('doc-scope Fix all is disabled when no diagnostic is fixable', () => {
const onFixAll = mock(() => {});
render(
<ProblemsPanel
docName="notes"
diagnostics={[diag({ code: 'MD025', message: 'Multiple H1' })]}
onFixAll={onFixAll}
/>,
);
const disabledButton = screen.getByTestId('problems-fix-all') as HTMLButtonElement;
expect(disabledButton.disabled).toBe(true);
expect(disabledButton.textContent).toContain('(0)');
});

test('doc-scope Fix all is absent without onFixAll or without diagnostics', () => {
const { unmount } = render(<ProblemsPanel docName="notes" diagnostics={[diag({})]} />);
expect(screen.queryByTestId('problems-fix-all')).toBeNull();
unmount();
// Empty state renders no action row at all.
render(<ProblemsPanel docName="notes" diagnostics={[]} onFixAll={mock(() => {})} />);
expect(screen.queryByTestId('problems-fix-all')).toBeNull();
});

test('renders a row per diagnostic, sorted by line', () => {
const diagnostics = [
diag({ code: 'MD012', message: 'Multiple blanks', line: 9 }),
Expand Down Expand Up @@ -332,6 +420,95 @@ describe('ProblemsPanel — project scope', () => {
}
});

test('project Fix all sweeps only fixable files, re-audits, and stays quiet on success', async () => {
const fixableEdit = {
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 1 } },
newText: ' ',
};
runLintAuditImpl = async () =>
auditResult({
files: [
{ file: 'a.md', diagnostics: [diag({ fixes: [fixableEdit] })] },
{ file: 'b.md', diagnostics: [diag({ code: 'MD025', message: 'Multiple H1' })] },
{ file: 'nested/c.md', diagnostics: [diag({ fixes: [fixableEdit] })] },
],
});
render(<ProblemsPanel docName="notes" diagnostics={[]} />);
fireEvent.click(screen.getByTestId('panel-scope-project'));
await waitFor(() => expect(screen.getByText('a.md')).toBeTruthy());

// Counts fixable problems across the audit (a.md + nested/c.md), not files
// and not the unfixable b.md diagnostic.
expect(screen.getByTestId('problems-fix-all').textContent).toContain('(2)');
fireEvent.click(screen.getByTestId('problems-fix-all'));
// Only the two files carrying fixable diagnostics are swept, extension-less.
await waitFor(() => expect(fixLintDocCalls).toEqual(['a', 'nested/c']));
// The sweep ends in a fresh audit fetch (initial activation + re-audit).
await waitFor(() => expect(auditCalls).toBe(2));
expect(toastError).not.toHaveBeenCalled();
});

test('project Fix all continues past per-file failures and surfaces one error toast', async () => {
const fixableEdit = {
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 1 } },
newText: ' ',
};
runLintAuditImpl = async () =>
auditResult({
files: [
{ file: 'bad.md', diagnostics: [diag({ fixes: [fixableEdit] })] },
{ file: 'good.md', diagnostics: [diag({ fixes: [fixableEdit] })] },
],
});
fixLintDocImpl = async (docName) =>
docName === 'bad' ? { ok: false, errorDetail: 'conflict' } : { ok: true };
render(<ProblemsPanel docName="notes" diagnostics={[]} />);
fireEvent.click(screen.getByTestId('panel-scope-project'));
await waitFor(() => expect(screen.getByText('bad.md')).toBeTruthy());

fireEvent.click(screen.getByTestId('problems-fix-all'));
// The failed file does not stop the sweep.
await waitFor(() => expect(fixLintDocCalls).toEqual(['bad', 'good']));
await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1));
expect(String(toastError.mock.calls[0]?.[0])).toContain('1 of 2');
// The toast names the first casualty and the server's reason.
const options = toastError.mock.calls[0]?.[1] as { description?: string } | undefined;
expect(options?.description).toBe('bad.md — conflict');
});

test('failure toast omits the dash-suffix when the server gives no error detail', async () => {
const fixableEdit = {
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 1 } },
newText: ' ',
};
runLintAuditImpl = async () =>
auditResult({ files: [{ file: 'bad.md', diagnostics: [diag({ fixes: [fixableEdit] })] }] });
fixLintDocImpl = async () => ({ ok: false, errorDetail: null });
render(<ProblemsPanel docName="notes" diagnostics={[]} />);
fireEvent.click(screen.getByTestId('panel-scope-project'));
await waitFor(() => expect(screen.getByText('bad.md')).toBeTruthy());

fireEvent.click(screen.getByTestId('problems-fix-all'));
await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1));
// Null detail → just the filename, no ` — ` suffix.
const options = toastError.mock.calls[0]?.[1] as { description?: string } | undefined;
expect(options?.description).toBe('bad.md');
});

test('project Fix all is disabled while loading and when nothing is fixable', async () => {
runLintAuditImpl = async () =>
auditResult({
files: [{ file: 'plain.md', diagnostics: [diag({ code: 'MD025' })] }],
});
render(<ProblemsPanel docName="notes" diagnostics={[]} />);
fireEvent.click(screen.getByTestId('panel-scope-project'));
await waitFor(() => expect(screen.getByText('plain.md')).toBeTruthy());
// Loaded audit with zero fixable files keeps the button disabled.
expect((screen.getByTestId('problems-fix-all') as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(screen.getByTestId('problems-fix-all'));
expect(fixLintDocCalls).toEqual([]);
});

test('doc-scope content and count stay doc-scoped while project scope is active', async () => {
runLintAuditImpl = async () =>
auditResult({ files: [{ file: 'other.md', diagnostics: [diag({}), diag({ line: 9 })] }] });
Expand Down
Loading