From ba8fe45ace6b9ce22cfe50e67e19a2126a6287c4 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Mon, 26 Oct 2026 18:36:32 +0900 Subject: [PATCH 01/18] feat: Cycle 0055 pt2: one freshness label for the reader page and the LLM Wiki view The page installer is serialized with toString(), so the shared wording is one pure function handed to it through WikiUpdatePageSettings.describeFreshness. Refs #132 --- extension/src/deepWikiReader.ts | 3 +- .../src/tests/describeWikiFreshness.test.ts | 87 +++++++++++++++++++ extension/src/utils/describeWikiFreshness.ts | 43 +++++++++ .../src/utils/installWikiUpdateStatus.ts | 38 ++------ 4 files changed, 140 insertions(+), 31 deletions(-) create mode 100644 extension/src/tests/describeWikiFreshness.test.ts create mode 100644 extension/src/utils/describeWikiFreshness.ts diff --git a/extension/src/deepWikiReader.ts b/extension/src/deepWikiReader.ts index 6522fb40f..b8e50f5aa 100644 --- a/extension/src/deepWikiReader.ts +++ b/extension/src/deepWikiReader.ts @@ -44,6 +44,7 @@ import type { WikiIncludedSource, } from './utils/answerDeepWikiQuestion' import { assertNoLinkOnWikiPath } from './utils/assertNoLinkOnWikiPath' +import { describeWikiFreshness } from './utils/describeWikiFreshness' import { describeWikiUpdate } from './utils/describeWikiUpdate' import { inspectDeepWikiFreshness, @@ -104,7 +105,7 @@ const qaClient = `(function () { { action: 'Keyboard shortcuts', keys: '?', context: 'DeepWiki, outside text inputs', selector: '[data-shortcut-help]' }, { action: 'Close keyboard shortcuts', keys: 'Esc', context: 'Keyboard shortcuts dialog' } ], 'body'); - (${installWikiUpdateStatus.toString()})({ readerPrefix: prefix, pagePublication: (document.currentScript && document.currentScript.getAttribute('data-codebook-publication')) || undefined, updateStatusRoute: '${WIKI_UPDATE_STATUS_ROUTE}', freshnessRoute: '${WIKI_FRESHNESS_ROUTE}', describeUpdate: (${describeWikiUpdate.toString()}), statusPollMs: ${WIKI_UPDATE_STATUS_POLL_MS}, idlePollMs: ${WIKI_UPDATE_IDLE_POLL_MS}, freshnessMinIntervalMs: ${WIKI_FRESHNESS_MIN_INTERVAL_MS}, startWaitMs: ${WIKI_UPDATE_START_WAIT_MS}, statusRetryLimit: ${WIKI_UPDATE_STATUS_RETRY_LIMIT}, pathLimit: ${WIKI_UPDATE_SHOWN_PATH_LIMIT}, reasonLimitChars: ${WIKI_UPDATE_REASON_LIMIT_CHARS}, msPerMinute: ${MS_PER_MINUTE}, recoveryRequest: ${JSON.stringify(WIKI_UPDATE_RECOVERY_REQUEST)}, outsideOutputApprovalsNote: ${JSON.stringify(WIKI_OUTSIDE_OUTPUT_APPROVALS_NOTE)}, refusedStepAdvice: ${JSON.stringify(WIKI_REFUSED_STEP_ADVICE)}, missingPageTextDelayMs: ${WIKI_MISSING_PAGE_TEXT_DELAY_MS}, missingPagePathLimitChars: ${WIKI_MISSING_PAGE_PATH_LIMIT_CHARS} }); + (${installWikiUpdateStatus.toString()})({ readerPrefix: prefix, pagePublication: (document.currentScript && document.currentScript.getAttribute('data-codebook-publication')) || undefined, updateStatusRoute: '${WIKI_UPDATE_STATUS_ROUTE}', freshnessRoute: '${WIKI_FRESHNESS_ROUTE}', describeUpdate: (${describeWikiUpdate.toString()}), describeFreshness: (${describeWikiFreshness.toString()}), statusPollMs: ${WIKI_UPDATE_STATUS_POLL_MS}, idlePollMs: ${WIKI_UPDATE_IDLE_POLL_MS}, freshnessMinIntervalMs: ${WIKI_FRESHNESS_MIN_INTERVAL_MS}, startWaitMs: ${WIKI_UPDATE_START_WAIT_MS}, statusRetryLimit: ${WIKI_UPDATE_STATUS_RETRY_LIMIT}, pathLimit: ${WIKI_UPDATE_SHOWN_PATH_LIMIT}, reasonLimitChars: ${WIKI_UPDATE_REASON_LIMIT_CHARS}, msPerMinute: ${MS_PER_MINUTE}, recoveryRequest: ${JSON.stringify(WIKI_UPDATE_RECOVERY_REQUEST)}, outsideOutputApprovalsNote: ${JSON.stringify(WIKI_OUTSIDE_OUTPUT_APPROVALS_NOTE)}, refusedStepAdvice: ${JSON.stringify(WIKI_REFUSED_STEP_ADVICE)}, missingPageTextDelayMs: ${WIKI_MISSING_PAGE_TEXT_DELAY_MS}, missingPagePathLimitChars: ${WIKI_MISSING_PAGE_PATH_LIMIT_CHARS} }); // The shell's form by its element and its id together: a page published before the publisher kept the ids of a page's content apart from the reader's may carry the id on a heading of its article, which stands before the form. var form = document.querySelector('form#qa-form, [data-codebook-question]'); if (!form) return; diff --git a/extension/src/tests/describeWikiFreshness.test.ts b/extension/src/tests/describeWikiFreshness.test.ts new file mode 100644 index 000000000..da5ebd89f --- /dev/null +++ b/extension/src/tests/describeWikiFreshness.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { describeWikiFreshness } from '../utils/describeWikiFreshness' + +import { openFakeWikiPage } from './utils/fakeWikiPage' +import { readServedWikiScript } from './utils/readServedWikiScript' + +// Value: protects=AC 2 (row 1's description) and the shared freshness wording of the page and the LLM Wiki view; fails_when=a status gets another label than the reader page shows for it, or a current Wiki without a Git revision loses its note; no_claim=the page's own timing of the check; seam=the pure function +test('describeWikiFreshness words a current, a changed and an undecided Wiki as the reader page does', () => { + // Arrange + const reasonLimit = 160 + + // Act + const labels = { + current: describeWikiFreshness('current', '', false, reasonLimit), + currentWithoutGit: describeWikiFreshness('current', '', true, reasonLimit), + changed: describeWikiFreshness('changed', '', false, reasonLimit), + unknownWithoutReason: describeWikiFreshness( + 'unknown', + '', + false, + reasonLimit, + ), + unknownWithReason: describeWikiFreshness( + 'unknown', + 'The manifest is unreadable.', + false, + reasonLimit, + ), + unanswered: describeWikiFreshness(undefined, '', false, reasonLimit), + } + + // Assert + assert.deepEqual(labels, { + current: 'Wiki source current', + currentWithoutGit: + 'Wiki source current (no Git revision recorded, so added files are not checked)', + changed: 'Source changed · Update available', + unknownWithoutReason: 'Wiki freshness unknown', + unknownWithReason: 'Wiki freshness unknown: The manifest is unreadable.', + unanswered: 'Wiki freshness unknown', + }) +}) + +// Value: protects=the rail's narrow width; fails_when=a long reason is not cut at a space before the limit; no_claim=the tooltip that keeps the whole reason; seam=the pure function +test('describeWikiFreshness cuts a reason longer than the limit at the last space before it', () => { + // Arrange + const reason = + 'An indexed source could not be checked: packages/dashboard/a.ts' + + // Act + const label = describeWikiFreshness('unknown', reason, false, 45) + + // Assert + assert.equal( + label, + 'Wiki freshness unknown: An indexed source could not be checked:…', + ) +}) + +// Value: protects=Eng decision 86, the page installer is serialized with toString() and cannot import the shared function; fails_when=the emitted page script does not carry the shared wording, so the page's label for current, changed or unknown differs from the function's; no_claim=the freshness check itself; seam=the served page script run in the fake page +test('the emitted page script labels a current, a changed and an unknown Wiki with the shared wording', async () => { + // Arrange + const script = await readServedWikiScript() + + // Act + const labels = [] + for (const [freshness, freshnessDetail] of [ + ['current', {}], + ['changed', {}], + ['unknown', { reason: 'The manifest is unreadable.' }], + ] as const) { + const page = await openFakeWikiPage(script, 'current-shell', { + freshness, + freshnessDetail, + }) + labels.push(page.find('[data-codebook-freshness]')?.textContent) + } + + // Assert + assert.deepEqual(labels, [ + 'Wiki source current', + 'Source changed · Update available', + 'Wiki freshness unknown: The manifest is unreadable.', + ]) +}) diff --git a/extension/src/utils/describeWikiFreshness.ts b/extension/src/utils/describeWikiFreshness.ts new file mode 100644 index 000000000..adf9104d0 --- /dev/null +++ b/extension/src/utils/describeWikiFreshness.ts @@ -0,0 +1,43 @@ +/** + * Words how a Wiki stands against its source: the one label the served Wiki page shows above its outline and the editor's LLM Wiki view shows beside its Wiki row. + * + * Why: the page and the view must say the same thing about the same check, so the sentence is written once. The function is embedded into the served page script with `toString()` (through `WikiUpdatePageSettings.describeFreshness`, like `describeWikiUpdate`), so it must stay free of imports and outer variables. + * Trigger: the page calls it with each `api/freshness` answer; the view calls it with each freshness check it reads. + * + * @param status - The answered freshness status (`current`, `changed`, `unknown`), or `undefined` when nothing answered. + * @param reason - Why a check could not decide; empty when the answer gave none. + * @param isAdditionUnchecked - Whether the project has no Git repository, so added files were not looked for. + * @param reasonLimitChars - How many characters of a reason the label shows before it is cut at the last space. + * @returns + * - `changed`: "Source changed · Update available" + * - `current`: "Wiki source current", with the note on unchecked additions where they were not looked for + * - Any other status: "Wiki freshness unknown", followed by the reason when there is one, shortened to the limit + * + * @example + * ```ts + * describeWikiFreshness('current', '', false, 160) // => 'Wiki source current' + * describeWikiFreshness('unknown', 'The manifest is unreadable.', false, 160) // => 'Wiki freshness unknown: The manifest is unreadable.' + * ``` + */ +export function describeWikiFreshness( + status: string | undefined, + reason: string, + isAdditionUnchecked: boolean, + reasonLimitChars: number, +): string { + if (status === 'changed') return 'Source changed · Update available' + if (status === 'current') + return isAdditionUnchecked + ? 'Wiki source current (no Git revision recorded, so added files are not checked)' + : 'Wiki source current' + if (!reason) return 'Wiki freshness unknown' + // The label sits above the outline in a narrow rail: a long reason is cut at the last space before the limit and stays whole in the tooltip. + const lastSpace = reason.lastIndexOf(' ', reasonLimitChars) + const shownReason = + reason.length > reasonLimitChars + ? reason + .slice(0, lastSpace > 0 ? lastSpace : reasonLimitChars) + .trimEnd() + '…' + : reason + return 'Wiki freshness unknown: ' + shownReason +} diff --git a/extension/src/utils/installWikiUpdateStatus.ts b/extension/src/utils/installWikiUpdateStatus.ts index 989f7b796..86fca654a 100644 --- a/extension/src/utils/installWikiUpdateStatus.ts +++ b/extension/src/utils/installWikiUpdateStatus.ts @@ -1,3 +1,4 @@ +import type { describeWikiFreshness } from './describeWikiFreshness' import type { describeWikiUpdate, WikiUpdateDescription, @@ -24,6 +25,8 @@ export type WikiUpdatePageSettings = { pagePublication: string | undefined /** The one wording shared with the editor notification, embedded next to this function. */ describeUpdate: typeof describeWikiUpdate + /** The one wording of the freshness label shared with the editor's LLM Wiki view, embedded next to this function ({@link describeWikiFreshness}). */ + describeFreshness: typeof describeWikiFreshness /** How often the page asks for the update state while a run is active or awaited (ms). */ statusPollMs: number /** How often the page asks while nothing is running and the tab is visible (ms). */ @@ -69,6 +72,7 @@ export type WikiUpdatePageSettings = { * freshnessRoute: 'api/freshness', * pagePublication: 'dc9d3160…@1837466', * describeUpdate: describeWikiUpdate, + * describeFreshness: describeWikiFreshness, * statusPollMs: 2_000, * idlePollMs: 15_000, * freshnessMinIntervalMs: 5_000, @@ -334,32 +338,6 @@ export function installWikiUpdateStatus( state === 'timed-out' || state === 'conflict' - /** Words the freshness label of a check that could not decide; {@link noteFreshness} asks for it before it knows which label applies. @param reason - Why the check could not decide; empty when the answer gave none. @returns "Wiki freshness unknown", followed by the reason when there is one, shortened to the rail's limit. @example undecidedFreshnessLabel('The manifest is unreadable.') // => 'Wiki freshness unknown: The manifest is unreadable.' */ - const undecidedFreshnessLabel = (reason: string): string => { - // The label sits above the outline in a narrow rail: a long reason is cut at the last space before the rail's limit and stays whole in the tooltip. - const lastSpace = reason.lastIndexOf(' ', settings.reasonLimitChars) - const shownReason = - reason.length > settings.reasonLimitChars - ? reason - .slice(0, lastSpace > 0 ? lastSpace : settings.reasonLimitChars) - .trimEnd() + '…' - : reason - return reason - ? 'Wiki freshness unknown: ' + shownReason - : 'Wiki freshness unknown' - } - /** Chooses the text of the freshness label for one answer; called by {@link noteFreshness} on every answered or failed check. @param status - The answered status, or undefined when the reader did not answer. @param undecidedLabel - The label of a check that could not decide, from {@link undecidedFreshnessLabel}. @param isAdditionUnchecked - Whether added files were not looked for. @returns The label of a changed source, of a current one (with the note on unchecked additions), or `undecidedLabel` for any other status. @example freshnessLabelText('current', 'Wiki freshness unknown', false) // => 'Wiki source current' */ - const freshnessLabelText = ( - status: string | undefined, - undecidedLabel: string, - isAdditionUnchecked: boolean, - ): string => { - if (status === 'changed') return 'Source changed · Update available' - if (status !== 'current') return undecidedLabel - return isAdditionUnchecked - ? 'Wiki source current (no Git revision recorded, so added files are not checked)' - : 'Wiki source current' - } /** Takes in what `api/freshness` answered and writes it into the freshness label; called by {@link readFreshness} when its check is answered or fails. @param status - The answered status, or undefined when the reader did not answer. @param reason - Why a check could not decide; empty when the answer gave none. @param isAdditionUnchecked - Whether the project has no Git repository, so added files were not looked for. @returns Nothing; the label, its `data-status` and its tooltip are written when the page has a label. @example noteFreshness('unknown', 'The manifest is unreadable.', false) // the label reads "Wiki freshness unknown: The manifest is unreadable." */ const noteFreshness = ( status: string | undefined, @@ -368,12 +346,12 @@ export function installWikiUpdateStatus( ): void => { freshness = status if (!freshnessLabel) return - // A check that could not decide says why in the label itself: a tooltip needs a pointer, and a paragraph cannot take the focus. - const undecidedLabel = undecidedFreshnessLabel(reason) - freshnessLabel.textContent = freshnessLabelText( + // A check that could not decide says why in the label itself: a tooltip needs a pointer, and a paragraph cannot take the focus. The wording is the one the editor's LLM Wiki view shows (describeFreshness). + freshnessLabel.textContent = settings.describeFreshness( status, - undecidedLabel, + reason, isAdditionUnchecked, + settings.reasonLimitChars, ) freshnessLabel.setAttribute('data-status', status ?? 'unknown') // The reason of an earlier look must not outlive it. From 3e855a9bd810a83a5cf0beb0b4485bb1a5be01d2 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Mon, 26 Oct 2026 18:45:40 +0900 Subject: [PATCH 02/18] feat: Cycle 0055 pt2: LLM Wiki view rows, facts reader, tree provider and commands readLlmWikiViewState is the one pure row function (Eng decision 82); readLlmWikiViewFacts gathers its facts through window closures; llmWikiView is the tree data provider with the debounce, watcher and poll triggers. Refs #132 --- extension/src/constants.ts | 69 ++ extension/src/utils/describeWikiGeneration.ts | 47 ++ .../src/utils/findNearestExistingAncestor.ts | 27 + extension/src/utils/handWikiRequestToChat.ts | 4 +- extension/src/utils/llmWikiView.ts | 422 ++++++++++++ extension/src/utils/readLlmWikiViewFacts.ts | 304 +++++++++ extension/src/utils/readLlmWikiViewState.ts | 604 ++++++++++++++++++ .../src/utils/registerLlmWikiCommands.ts | 137 ++++ .../src/utils/resolveManageTrustCommand.ts | 21 + extension/src/utils/wikiUpdateRun.ts | 37 +- 10 files changed, 1662 insertions(+), 10 deletions(-) create mode 100644 extension/src/utils/describeWikiGeneration.ts create mode 100644 extension/src/utils/findNearestExistingAncestor.ts create mode 100644 extension/src/utils/llmWikiView.ts create mode 100644 extension/src/utils/readLlmWikiViewFacts.ts create mode 100644 extension/src/utils/readLlmWikiViewState.ts create mode 100644 extension/src/utils/registerLlmWikiCommands.ts create mode 100644 extension/src/utils/resolveManageTrustCommand.ts diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 281737ae9..c7a2c2412 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -954,6 +954,75 @@ export const GENERATE_ANYWAY_ACTION = 'Generate anyway' export const UPDATE_WIKI_ACTION = 'Update LLM Wiki' /** The button of a generation's notices that opens the new Wiki in the browser. */ export const OPEN_WIKI_IN_BROWSER_ACTION = 'Open in Browser' +/** How many characters of a commit hash the generation line of the LLM Wiki view's Wiki row shows. */ +export const WIKI_SHORT_REVISION_CHARS = 7 +/** VS Code's command that reveals the chat a request would go to (the chat focused last when it can be shown, the Chat view otherwise) and makes it the chat focused last; with a query it sends the query as a request into that chat and, with `blockOnResponse`, returns when the response completes or waits for a confirmation. Without one it only reveals the chat: the LLM Wiki view's Open Chat action runs it, so a chat that waits for an approval, or never started the skill, is one click away. */ +export const OPEN_CHAT_COMMAND = 'workbench.action.chat.open' +/** The editor's own command that opens settings, with a setting's id as its argument. */ +export const OPEN_SETTINGS_COMMAND = 'workbench.action.openSettings' +/** The id of the setting that names the Wiki output folder. */ +export const WIKI_OUTPUT_DIRECTORY_SETTING = 'codebook.wikiOutputDirectory' +/** The settings search that shows the setting naming the Wiki output folder, the argument of {@link OPEN_SETTINGS_COMMAND}. */ +export const WIKI_OUTPUT_SETTINGS_QUERY = `@id:${WIKI_OUTPUT_DIRECTORY_SETTING}` +/** The id of the LLM Wiki tree view in the Activity Bar (Cycle 0055 pt2). */ +export const LLM_WIKI_VIEW_ID = 'codebook.llmWiki' +/** The context key that says what the LLM Wiki view shows, so its welcome content shows in the empty state only: `loading` until the first read ends, `none` when the folder holds no Wiki and no run, `other` for every row. */ +export const LLM_WIKI_STATE_CONTEXT_KEY = 'codebook.llmWiki.state' +/** The context key that names the editor host (`vscode` or `cursor`), so the welcome content words the runner of the host it runs in. */ +export const CODEBOOK_HOST_CONTEXT_KEY = 'codebook.host' +/** How long the LLM Wiki view waits after the last event that asks for a read before it reads: events inside this time coalesce into one read, and the last event always reads. */ +export const LLM_WIKI_REFRESH_DEBOUNCE_MS = 1_000 +/** How often the LLM Wiki view reads its state again while a row of a run (this window's, another window's or a stopped watch's reservation) is shown: a marker whose window died leaves no file event. */ +export const LLM_WIKI_POLL_MS = 30_000 +/** The command of the LLM Wiki view's title bar Refresh action. */ +export const REFRESH_LLM_WIKI_COMMAND = 'codebook.refreshLlmWiki' +/** The command that a click on the view's attention or last-attempt row runs: it shows the row's full sentence, with Copy details. */ +export const SHOW_LLM_WIKI_ATTENTION_COMMAND = 'codebook.showLlmWikiAttention' +/** The command that copies the chat request of the publisher's recovery ({@link WIKI_UPDATE_RECOVERY_REQUEST}). */ +export const COPY_WIKI_RECOVERY_REQUEST_COMMAND = + 'codebook.copyWikiRecoveryRequest' +/** The command that reveals the Wiki folder in the system's file manager. */ +export const REVEAL_WIKI_FOLDER_COMMAND = 'codebook.revealWikiFolder' +/** The button of the attention notice that copies its sentence. */ +export const COPY_DETAILS_ACTION = 'Copy details' +/** The ids of the editor's command that manages workspace trust, in the order Codebook prefers them; the view runs the first one the editor lists (`workbench.trust.manage` is VS Code's own id; vscode-go calls `workbench.action.manageTrust`). Unverified in each host until the native trial. */ +export const MANAGE_WORKSPACE_TRUST_COMMAND_IDS = [ + 'workbench.trust.manage', + 'workbench.action.manageTrust', +] as const +/** What one inline action of an LLM Wiki view row is: the command it runs and the words of its title. The row function names an action by its key, the manifest contributes each command with a menu entry that tests `viewItem` for the key. */ +export const LLM_WIKI_ROW_ACTIONS = { + update: { command: 'codebook.updateDeepWiki', title: 'Update LLM Wiki' }, + 'stop-watching': { + command: 'codebook.llmWiki.stopWatching', + title: 'Stop watching', + }, + 'cancel-run': { command: 'codebook.llmWiki.cancelRun', title: 'Cancel' }, + 'open-chat': { command: 'codebook.llmWiki.openChat', title: 'Open Chat' }, + 'show-output': { command: SHOW_WIKI_OUTPUT_COMMAND, title: 'Show Output' }, + 'try-again': { command: GENERATE_DEEP_WIKI_COMMAND, title: 'Try again' }, + 'change-location': { + command: 'codebook.llmWiki.changeLocation', + title: 'Change location', + }, + 'copy-recovery': { + command: COPY_WIKI_RECOVERY_REQUEST_COMMAND, + title: 'Copy recovery request', + }, + 'reveal-folder': { + command: REVEAL_WIKI_FOLDER_COMMAND, + title: 'Reveal Wiki Folder', + }, + refresh: { command: REFRESH_LLM_WIKI_COMMAND, title: 'Refresh' }, + 'open-folder': { + command: 'codebook.llmWiki.openFolder', + title: 'Open Folder', + }, + 'manage-trust': { + command: 'codebook.llmWiki.manageTrust', + title: 'Manage Workspace Trust', + }, +} as const /** How often the extension looks for a newly published manifest while a Wiki update is watched. */ export const WIKI_UPDATE_WATCH_POLL_MS = 2_000 /** How often the reader page asks for the update state while a run is active. */ diff --git a/extension/src/utils/describeWikiGeneration.ts b/extension/src/utils/describeWikiGeneration.ts new file mode 100644 index 000000000..fe2940336 --- /dev/null +++ b/extension/src/utils/describeWikiGeneration.ts @@ -0,0 +1,47 @@ +import { WIKI_SHORT_REVISION_CHARS } from '../constants' + +/** What the manifest and the manifest file say of one generation: the revision it records and when the manifest was written (epoch milliseconds). */ +export type WikiGeneration = { + /** The manifest's `generated.revision` as written; kept only when it is a full commit hash. */ + revision?: string + /** The manifest file's modification time; the publisher writes a new manifest at each publish. */ + publishedAtMs?: number +} + +/** + * Words which generation a published Wiki is, for the tooltip of the LLM Wiki view's Wiki row (accepted X1). + * + * Why: a learner who sees `Source changed` asks which source the Wiki was written from and when; the revision and the date answer without opening the browser. + * Trigger: {@link readLlmWikiViewState} calls it for each Wiki row. + * + * @param generation - The revision the manifest records and the manifest file's modification time. + * @param formatDate - Turns the time into the date in the editor's locale; tests pass a fixed one. + * @returns + * - Both known: "Generated from on " + * - A date and no usable revision (a folder without Git, or a revision that is no full commit hash): "Generated on (no Git revision)" + * - A revision and no date: "Generated from " + * - Neither: `undefined`, so the tooltip has no generation line + * + * @example + * describeWikiGeneration({ revision: '1936e41913729663cc99081e3224db89bb2d8e56', publishedAtMs: 1_790_000_000_000 }, () => 'Sep 21, 2026') + * // => 'Generated from 1936e41 on Sep 21, 2026' + */ +export function describeWikiGeneration( + generation: WikiGeneration, + formatDate: (epochMs: number) => string = (epochMs) => + new Date(epochMs).toLocaleDateString(), +): string | undefined { + // A revision is kept only as a full commit hash, as the freshness check keeps it. + const fullRevision = + generation.revision && /^[a-f0-9]{40,64}$/i.test(generation.revision) + ? generation.revision.slice(0, WIKI_SHORT_REVISION_CHARS) + : undefined + const date = + generation.publishedAtMs === undefined + ? undefined + : formatDate(generation.publishedAtMs) + if (fullRevision && date) return `Generated from ${fullRevision} on ${date}` + if (date) return `Generated on ${date} (no Git revision)` + if (fullRevision) return `Generated from ${fullRevision}` + return undefined +} diff --git a/extension/src/utils/findNearestExistingAncestor.ts b/extension/src/utils/findNearestExistingAncestor.ts new file mode 100644 index 000000000..14b4240dc --- /dev/null +++ b/extension/src/utils/findNearestExistingAncestor.ts @@ -0,0 +1,27 @@ +import { stat } from 'node:fs/promises' +import { dirname } from 'node:path' + +/** + * Finds the nearest folder at or above a path that exists, so a file watcher can stand where a first Wiki's folders are not made yet. + * + * Why: in a fresh project the Wiki output's parent (`.codebook/`) does not exist until a run makes it; a watcher on a missing folder would never fire, and the output may lie outside the workspace. + * Trigger: {@link LlmWikiView} calls it at each refresh to keep its watcher on the closest folder that exists. + * + * @param path - An absolute path, usually the Wiki output folder. + * @returns The path itself when it is a folder, else the nearest parent that is; the path's root when nothing below exists. + * @example + * await findNearestExistingAncestor('/repo/.codebook/wiki') // => '/repo' while .codebook does not exist yet + */ +export async function findNearestExistingAncestor( + path: string, +): Promise { + let candidate = path + for (;;) { + const candidateStat = await stat(candidate).catch(() => undefined) + if (candidateStat?.isDirectory()) return candidate + const parent = dirname(candidate) + // The root has no parent: nothing below it exists either. + if (parent === candidate) return candidate + candidate = parent + } +} diff --git a/extension/src/utils/handWikiRequestToChat.ts b/extension/src/utils/handWikiRequestToChat.ts index 1df68edd2..068111229 100644 --- a/extension/src/utils/handWikiRequestToChat.ts +++ b/extension/src/utils/handWikiRequestToChat.ts @@ -1,9 +1,9 @@ +import { OPEN_CHAT_COMMAND } from '../constants' + import { chatCommandReturnsInTime } from './chatCommandReturnsInTime' import { sleepUntilAborted } from './sleepUntilAborted' import type { BoundedWait } from './waitWithin' -/** VS Code's command that reveals the chat a request would go to (the chat focused last when it can be shown, the Chat view otherwise) and makes it the chat focused last; with a query it sends the query as a request into that chat and, with `blockOnResponse`, returns when the response completes or waits for a confirmation. */ -const OPEN_CHAT_COMMAND = 'workbench.action.chat.open' /** VS Code's command "New Chat": the chat focused last gets a new, empty session; the session it showed is kept. {@link describeWikiChatHandOver} looks for it among the editor's commands to say before the press where the request will go. */ export const NEW_CHAT_COMMAND = 'workbench.action.chat.newChat' diff --git a/extension/src/utils/llmWikiView.ts b/extension/src/utils/llmWikiView.ts new file mode 100644 index 000000000..7e4bccb24 --- /dev/null +++ b/extension/src/utils/llmWikiView.ts @@ -0,0 +1,422 @@ +import { join, relative, sep } from 'node:path' + +import type * as vscode from 'vscode' + +import { + CODEBOOK_HOST_CONTEXT_KEY, + LLM_WIKI_POLL_MS, + LLM_WIKI_REFRESH_DEBOUNCE_MS, + LLM_WIKI_STATE_CONTEXT_KEY, + LLM_WIKI_VIEW_ID, + WIKI_MANIFEST_FILE, + WIKI_OUTPUT_DIRECTORY_SETTING, + WIKI_PUBLISH_JOURNAL_SUFFIX, + WIKI_PUBLISH_LOCK_SUFFIX, + WIKI_UPDATE_RUN_MARKER_SUFFIX, +} from '../constants' + +import { + LLM_WIKI_ERROR_ROW, + LLM_WIKI_LOADING_ROW, + readLlmWikiViewState, + type LlmWikiFreshness, + type LlmWikiRow, + type LlmWikiViewFacts, + type LlmWikiViewModel, +} from './readLlmWikiViewState' + +/** What the view asks of the window, each as a closure of the extension's activation. */ +export type LlmWikiViewOptions = { + /** The editor's API; the view uses its tree, watcher and context-key surfaces only. */ + editor: Pick< + typeof vscode, + | 'commands' + | 'EventEmitter' + | 'RelativePattern' + | 'ThemeIcon' + | 'TreeItem' + | 'TreeItemCollapsibleState' + | 'window' + | 'workspace' + > + /** The editor host, set as the context key the welcome content is chosen by. */ + host: 'vscode' | 'cursor' + /** Reads the facts the rows are made of, without the freshness check ({@link readLlmWikiViewFacts}). */ + readFacts: () => Promise + /** Runs the freshness check of the published Wiki. */ + readFreshness: () => Promise + /** Finds the nearest folder at or above the Wiki output that exists ({@link findNearestExistingAncestor}). */ + findWatchedFolder: (outputDirectory: string) => Promise + /** Subscribes to changes of this window's run state; returns the unsubscribe function. */ + onRunChange: (listener: () => void) => () => void + /** Tells the learner that a Wiki was published after a run was stopped or a watch ended. */ + tellPublished: (outputLabel: string) => void + /** Writes a failure to Output › Codebook, where the error row sends the learner. */ + recordFailure: (message: string, cause: unknown) => void + /** The trailing debounce of event-driven refreshes (ms). */ + debounceMs?: number + /** The poll interval while a run row is shown (ms). */ + pollMs?: number +} + +/** The view as the extension's wiring and its commands use it. */ +export type LlmWikiView = { + /** Reads the state now and shows it; resolves once the rows are shown (not the freshness check). */ + refresh(): Promise + /** Asks for a refresh after the debounce; many calls inside it become one read. */ + requestRefresh(): void + /** Resolves once no read and no freshness check is under way. */ + whenIdle(): Promise + /** The row with this id as it is shown now. */ + findRow(rowId: string): LlmWikiRow | undefined + /** The facts of the last read, for commands that act on the shown folder. */ + lastFacts(): LlmWikiViewFacts | undefined + dispose(): void +} + +/** Rows whose presence means a run is shown, so the view reads its state again on a timer. */ +const RUN_ROW_IDS = new Set(['run', 'elsewhere', 'reservation']) + +/** The files that tell the view a run or a publish changed the folder, as the paths below the output folder's parent. */ +const WATCHED_SUFFIXES = [ + WIKI_UPDATE_RUN_MARKER_SUFFIX, + WIKI_PUBLISH_LOCK_SUFFIX, + WIKI_PUBLISH_JOURNAL_SUFFIX, +] as const + +/** + * Names the files whose events refresh the view: the manifest inside the output folder and the three markers beside it. + * @param outputDirectory - The resolved Wiki folder. + * @returns The four absolute paths. + * @example watchedFilesOf('/repo/.codebook/wiki') // => ['/repo/.codebook/wiki/codebook-wiki.json', '/repo/.codebook/wiki.update-run.json', …] + */ +function watchedFilesOf(outputDirectory: string): string[] { + return [ + join(outputDirectory, WIKI_MANIFEST_FILE), + ...WATCHED_SUFFIXES.map((suffix) => `${outputDirectory}${suffix}`), + ] +} + +/** + * Makes a glob, relative to the watched folder, that matches the four watched files and nothing else. + * @param watchedFolder - The nearest existing folder at or above the output. + * @param outputDirectory - The resolved Wiki folder. + * @returns A glob such as `wiki{/codebook-wiki.json,.update-run.json,…}` with forward slashes. + * @example globOfWatchedFiles('/repo/.codebook', '/repo/.codebook/wiki') // => 'wiki{/codebook-wiki.json,.update-run.json,.publish-lock.json,.publish-journal.json}' + */ +function globOfWatchedFiles( + watchedFolder: string, + outputDirectory: string, +): string { + const outputFromFolder = relative(watchedFolder, outputDirectory) + .split(sep) + .join('/') + const endings = [`/${WIKI_MANIFEST_FILE}`, ...WATCHED_SUFFIXES] + return `${outputFromFolder}{${endings.join(',')}}` +} + +/** + * Creates the LLM Wiki tree view: its data provider, its context keys, and the triggers that read its state again (this window's run, the Wiki files, the setting, trust, visibility and a poll while a run row is shown). + * + * Why: the Activity Bar needs one place that shows the generated site's state and its next step; the rows are one pure function of one record of facts ({@link readLlmWikiViewState}), and this module only reads, compares and shows. + * Trigger: `activate()` in `extension.ts` creates it once per window when the editor offers tree views. + * + * A refresh fires a tree change only when the rows, the message or the state actually changed, so a read with nothing new is silent for screen readers and never moves focus. + * + * @param options - The editor API and the window's closures ({@link LlmWikiViewOptions}). + * @returns The view's handle ({@link LlmWikiView}). + * @example + * const view = createLlmWikiView({ editor: vscode, host: 'vscode', readFacts, readFreshness, findWatchedFolder, onRunChange, tellPublished, recordFailure }) + * await view.refresh() + */ +export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { + const { editor } = options + const debounceMs = options.debounceMs ?? LLM_WIKI_REFRESH_DEBOUNCE_MS + const pollMs = options.pollMs ?? LLM_WIKI_POLL_MS + const treeChanges = new editor.EventEmitter() + let model: LlmWikiViewModel = { state: 'other', rows: [LLM_WIKI_LOADING_ROW] } + let shownState: string | undefined + let shownKey = JSON.stringify(model) + let facts: LlmWikiViewFacts | undefined + let previousRowIds: string[] = [] + let previousPublication: string | undefined + let freshnessCache: { key: string; value: LlmWikiFreshness } | undefined + let readCount = 0 + let running: Promise | undefined + let isRereadWanted = false + let pendingFreshness: Promise | undefined + let debounceTimer: NodeJS.Timeout | undefined + let pollTimer: NodeJS.Timeout | undefined + let isDisposed = false + let isWatcherBroken = false + let watcher: + { folder: string; outputDirectory: string; dispose: () => void } | undefined + + void editor.commands.executeCommand( + 'setContext', + CODEBOOK_HOST_CONTEXT_KEY, + options.host, + ) + + const treeView = editor.window.createTreeView(LLM_WIKI_VIEW_ID, { + treeDataProvider: { + onDidChangeTreeData: treeChanges.event, + getChildren: (element) => + element ? [] : model.rows.map((row) => row.id), + getTreeItem: (rowId) => { + const row = model.rows.find((candidate) => candidate.id === rowId) + const item = new editor.TreeItem( + row?.label ?? rowId, + editor.TreeItemCollapsibleState.None, + ) + if (!row) return item + item.id = row.id + item.description = row.description + item.tooltip = row.tooltip + item.iconPath = new editor.ThemeIcon(row.icon) + item.contextValue = row.actions.map((key) => `[${key}]`).join('') + if (row.command) + item.command = { + command: row.command.command, + title: row.label, + arguments: row.command.arguments, + } + return item + }, + }, + }) + + /** Shows a model: sets the context key, the message and fires one tree change, only for what differs from what is shown. */ + const show = (next: LlmWikiViewModel): void => { + const nextKey = JSON.stringify(next) + if (nextKey === shownKey && shownState !== undefined) return + const didStateChange = next.state !== shownState + model = next + shownKey = nextKey + shownState = next.state + treeView.message = next.message + if (didStateChange) + void editor.commands.executeCommand( + 'setContext', + LLM_WIKI_STATE_CONTEXT_KEY, + next.state, + ) + treeChanges.fire() + } + + /** Keeps the poll timer running exactly while a run row is shown. */ + const keepPoll = (): void => { + const isRunShown = model.rows.some((row) => RUN_ROW_IDS.has(row.id)) + if (!isRunShown) { + clearInterval(pollTimer) + pollTimer = undefined + return + } + pollTimer ??= setInterval(() => { + void refresh() + }, pollMs).unref() + } + + /** Asks for a refresh after the debounce; the last call always reads. */ + const requestRefresh = (): void => { + if (isDisposed) return + clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + void refresh() + }, debounceMs) + debounceTimer.unref() + } + + /** Puts the watcher on the nearest existing folder of the output, and makes it again when that folder or the output changed. */ + const keepWatcher = async (outputDirectory: string): Promise => { + const folder = await options.findWatchedFolder(outputDirectory) + if ( + watcher?.folder === folder && + watcher.outputDirectory === outputDirectory + ) + return + watcher?.dispose() + watcher = undefined + try { + const fileWatcher = editor.workspace.createFileSystemWatcher( + new editor.RelativePattern( + folder, + globOfWatchedFiles(folder, outputDirectory), + ), + ) + const watchedFiles = new Set(watchedFilesOf(outputDirectory)) + const onEvent = (file: vscode.Uri): void => { + if (watchedFiles.has(file.fsPath)) requestRefresh() + } + const subscriptions = [ + fileWatcher.onDidCreate(onEvent), + fileWatcher.onDidChange(onEvent), + fileWatcher.onDidDelete(onEvent), + ] + watcher = { + folder, + outputDirectory, + dispose: (): void => { + for (const subscription of subscriptions) subscription.dispose() + fileWatcher.dispose() + }, + } + isWatcherBroken = false + } catch (cause) { + // Without a watcher the view refreshes on visibility and its Refresh only; Output › Codebook records the cause once. + if (!isWatcherBroken) + options.recordFailure( + 'The LLM Wiki view could not watch the Wiki folder', + cause, + ) + isWatcherBroken = true + } + } + + /** Runs the freshness check for the last shown Wiki and shows its answer, unless a newer read replaced it. */ + const checkFreshness = async ( + readNumber: number, + shownFacts: Extract, + ): Promise => { + const value = await options.readFreshness().catch((): LlmWikiFreshness => ({ + status: 'unknown', + reason: 'The freshness check failed.', + })) + if (readNumber !== readCount || isDisposed || !shownFacts.wiki) return + freshnessCache = { key: freshnessKeyOf(shownFacts), value } + show( + readLlmWikiViewState({ + ...shownFacts, + wiki: { ...shownFacts.wiki, freshness: value }, + }), + ) + } + + /** Tells the learner about a Wiki that appeared while a stopped watch's reservation or a failed generation stood. */ + const noteLatePublication = ( + nextFacts: Extract, + ): void => { + const wasWaiting = previousRowIds.some( + (rowId) => rowId === 'reservation' || rowId === 'last-attempt', + ) + if ( + wasWaiting && + nextFacts.publication !== undefined && + nextFacts.publication !== previousPublication && + nextFacts.wiki + ) + options.tellPublished(nextFacts.outputLabel) + } + + /** One read: the facts, the watcher, the rows with the cached or `checking` freshness, then the freshness check apart. */ + const readOnce = async (): Promise => { + readCount += 1 + const readNumber = readCount + let nextFacts: LlmWikiViewFacts + try { + nextFacts = await options.readFacts() + } catch (cause) { + options.recordFailure('The LLM Wiki view could not read its state', cause) + show({ state: 'other', rows: [LLM_WIKI_ERROR_ROW] }) + previousRowIds = ['error'] + keepPoll() + return + } + if (nextFacts.kind === 'ready') { + await keepWatcher(nextFacts.outputDirectory) + nextFacts = { + ...nextFacts, + ...(isWatcherBroken ? { isWatcherBroken: true as const } : {}), + ...(nextFacts.wiki + ? { + wiki: { + ...nextFacts.wiki, + freshness: + freshnessCache?.key === freshnessKeyOf(nextFacts) + ? freshnessCache.value + : { status: 'checking' as const }, + }, + } + : {}), + } + noteLatePublication(nextFacts) + previousPublication = nextFacts.publication + } + facts = nextFacts + const next = readLlmWikiViewState(nextFacts) + previousRowIds = next.rows.map((row) => row.id) + show(next) + keepPoll() + if (nextFacts.kind === 'ready' && nextFacts.wiki) { + const readyFacts = nextFacts + pendingFreshness = checkFreshness(readNumber, readyFacts).finally(() => { + pendingFreshness = undefined + }) + } + } + + /** Reads now; a read asked for while one runs makes that one read again once, so the last event always shows. */ + function refresh(): Promise { + if (isDisposed) return Promise.resolve() + if (running) { + isRereadWanted = true + return running + } + running = (async () => { + do { + isRereadWanted = false + await readOnce() + } while (isRereadWanted && !isDisposed) + })().finally(() => { + running = undefined + }) + return running + } + + const subscriptions: vscode.Disposable[] = [ + treeChanges, + treeView, + treeView.onDidChangeVisibility((event) => { + if (event.visible) requestRefresh() + }), + editor.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration(WIKI_OUTPUT_DIRECTORY_SETTING)) + requestRefresh() + }), + editor.workspace.onDidGrantWorkspaceTrust(() => requestRefresh()), + editor.workspace.onDidChangeWorkspaceFolders(() => requestRefresh()), + { dispose: options.onRunChange(requestRefresh) }, + ] + void refresh() + + return { + refresh, + requestRefresh, + async whenIdle(): Promise { + while (running ?? pendingFreshness) { + await Promise.all([running, pendingFreshness]) + } + }, + findRow: (rowId) => model.rows.find((row) => row.id === rowId), + lastFacts: () => facts, + dispose(): void { + isDisposed = true + clearTimeout(debounceTimer) + clearInterval(pollTimer) + watcher?.dispose() + for (const subscription of subscriptions) subscription.dispose() + }, + } +} + +/** + * Names the publication a cached freshness answer belongs to, so the answer of an older Wiki is never shown for a newer one. + * @param facts - The ready facts. + * @returns The folder and the publisher's receipt. + * @example freshnessKeyOf(facts) // => '/repo/.codebook/wiki|abc@1' + */ +function freshnessKeyOf( + facts: Extract, +): string { + return `${facts.outputDirectory}|${facts.publication ?? ''}` +} diff --git a/extension/src/utils/readLlmWikiViewFacts.ts b/extension/src/utils/readLlmWikiViewFacts.ts new file mode 100644 index 000000000..52a5ed0ae --- /dev/null +++ b/extension/src/utils/readLlmWikiViewFacts.ts @@ -0,0 +1,304 @@ +import { basename } from 'node:path' + +import { WIKI_UPDATE_RECOVERY_REQUEST } from '../constants' + +import { describeWikiGenerateBlock } from './describeWikiGenerateBlock' +import type { WikiUpdateStartBlock } from './findWikiUpdateStartBlock' +import type { AwaitedWikiChat } from './hasWikiPublishEndedWait' +import type { + WikiUpdateMarker, + WikiUpdateMarkerLook, +} from './readLiveWikiUpdateMarker' +import type { + LlmWikiStoppedGeneration, + LlmWikiViewFacts, +} from './readLlmWikiViewState' +import { wikiGenerateNotice } from './wikiGenerateNotice' +import { wikiOutputLabel } from './wikiOutputLabel' +import { wikiRunBoundMs } from './wikiRunBoundMs' +import type { WikiAgentExit } from './wikiRunRecord' +import { wikiUpdateNotice } from './wikiUpdateNotice' +import type { WikiUpdateHost, WikiUpdateStatus } from './wikiUpdateRun' + +/** What {@link readLlmWikiViewFacts} asks of the editor window, each as a closure of the extension's activation, so host tests pass the real ones and unit tests stand-ins. */ +export type LlmWikiViewFactsDependencies = { + /** The editor this window runs in. */ + host: WikiUpdateHost + /** Whether the window has a folder open. */ + isFolderOpen: () => boolean + /** Whether the editor trusts the folder. */ + isTrusted: () => boolean + /** The project and the Wiki folder its setting names, without reading the folder. */ + wikiLocation: () => Promise<{ projectRoot: string; outputDirectory: string }> + /** The publish token of the manifest; `undefined` while there is none. */ + readPublishToken: (outputDirectory: string) => Promise + /** The publisher's receipt that names the manifest; `undefined` while there is none. */ + readPublication: (outputDirectory: string) => Promise + /** What makes the publisher refuse an update before it stages anything. */ + findStartBlock: ( + outputDirectory: string, + projectRoot: string, + ) => Promise + /** Reads the manifest as a Wiki of this project: the revision it records and when it was written. @throws When the manifest is no Wiki of this project. */ + readGeneration: ( + projectRoot: string, + outputDirectory: string, + ) => Promise<{ revision?: string; publishedAtMs?: number }> + /** Says whether the manifest names this project. @throws When the manifest cannot be read. */ + isWikiOwnedBy: ( + projectRoot: string, + outputDirectory: string, + ) => Promise + /** Looks at the run marker as a window that owns none, so this window's own reservation is seen too. */ + readMarker: (outputDirectory: string) => Promise + /** This window's own id in the marker. */ + ownWindowId: string + /** Whether a start of this window is under way that has not begun its run. */ + isStartUnderWay: () => boolean + /** Whether a publication after a stopped watch's end ended its wait. */ + hasPublishEndedWait: ( + outputDirectory: string, + awaited: AwaitedWikiChat, + ) => Promise + /** This window's run state. */ + runStatus: () => WikiUpdateStatus + /** Whether a VS Code run still waits for the chat's first sign. */ + isAwaitingChat: () => boolean + /** How the Cursor agent of this window's last run exited. */ + lastAgentExit: () => WikiAgentExit | undefined + /** The present (epoch milliseconds). */ + now: () => number +} + +/** What a read of the facts found about the marker. */ +type MarkerFacts = Pick< + Extract, + 'elsewhere' | 'reservation' +> + +/** + * Words the sentence of a start block for the attention row: the update's sentence where a Wiki stands, the generation's where none does. + * @param block - The cause and the paths the start check named. + * @param isWikiPresent - Whether a manifest stands in the folder. + * @param location - The Wiki folder and its label. + * @param host - The editor host. + * @param nowMs - The present, for the sentence's time. + * @returns The full sentence. + * @example sentenceOfBlock({ cause: 'held-publish-lock', paths: ['wiki.publish-lock.json'] }, false, location, 'vscode', 0) + */ +function sentenceOfBlock( + block: WikiUpdateStartBlock, + isWikiPresent: boolean, + location: { outputDirectory: string; outputLabel: string }, + host: WikiUpdateHost, + nowMs: number, +): string { + if (!isWikiPresent) + return describeWikiGenerateBlock(block, location.outputLabel) + const notice = wikiUpdateNotice({ + state: 'conflict', + host, + finishedAt: new Date(nowMs).toISOString(), + paths: block.paths, + cause: block.cause, + site: 'kept', + ...location, + }) + return ( + notice?.message ?? describeWikiGenerateBlock(block, location.outputLabel) + ) +} + +/** + * Reads the marker beside the Wiki into this window's view of the other windows: a live run of another window, or the reservation of a stopped watch. + * @param dependencies - The editor window's closures. + * @param outputDirectory - The Wiki folder. + * @returns Row 2b or row 2c facts; none for this window's own running marker (row 2 shows it), for a reservation whose wait a publication ended, and for a look that failed. + * @example await readMarkerFacts(dependencies, '/repo/.codebook/wiki') // => { elsewhere: { kind: 'update', startedAtMs: 1_790_000_000_000 } } + */ +async function readMarkerFacts( + dependencies: LlmWikiViewFactsDependencies, + outputDirectory: string, +): Promise { + const look = await dependencies + .readMarker(outputDirectory) + .catch(() => undefined) + if (look?.kind === 'being-written') { + // This window's own claim is written for a moment before its run begins. + return dependencies.isStartUnderWay() + ? {} + : { elsewhere: { startedAtMs: look.heldBy.beingWrittenSinceMs } } + } + if (look?.kind !== 'live') return {} + const held: WikiUpdateMarker = look.heldBy + if (held.watchEndedAtMs === undefined) + // This window's own live marker is row 2's, never "another window". + return held.windowId === dependencies.ownWindowId + ? {} + : { elsewhere: { kind: held.runKind, startedAtMs: held.startedAtMs } } + const hasWaitEnded = await dependencies + .hasPublishEndedWait(outputDirectory, { + watchEndedAtMs: held.watchEndedAtMs, + candidateNames: [], + }) + .catch(() => false) + if (hasWaitEnded) return {} + return { + reservation: { + kind: held.runKind, + host: held.host, + startedAtMs: held.startedAtMs, + retryAtMs: held.watchEndedAtMs + wikiRunBoundMs(held.runKind, held.host), + }, + } +} + +/** + * Reads this window's run: the running one, or the generation that ended without a publication. + * @param dependencies - The editor window's closures. + * @param outputDirectory - The Wiki folder the view shows. + * @returns Row 2 or row 3 facts. + * @example readRunFacts(dependencies, '/repo/.codebook/wiki') // => { run: { kind: 'generate', startedAtMs: 1_790_000_000_000, timeoutMs: 3_300_000, isAwaitingChat: false } } + */ +function readRunFacts( + dependencies: LlmWikiViewFactsDependencies, + outputDirectory: string, +): Pick, 'run' | 'lastAttempt'> { + const status = dependencies.runStatus() + if (status.state === 'running') + return { + run: { + kind: status.runKind ?? 'update', + startedAtMs: Date.parse(status.startedAt), + timeoutMs: status.timeoutMs, + isAwaitingChat: dependencies.isAwaitingChat(), + }, + } + if ( + status.state === 'idle' || + status.state === 'succeeded' || + status.state === 'already-current' || + status.runKind !== 'generate' || + status.outputDirectory !== outputDirectory + ) + return {} + const sentence = wikiGenerateNotice(status)?.message + if (sentence === undefined) return {} + const exitCode = dependencies.lastAgentExit()?.exitCode ?? undefined + const lastAttempt: LlmWikiStoppedGeneration = { + state: status.state, + host: status.host, + sentence, + ...(status.state === 'timed-out' ? { timeoutMs: status.timeoutMs } : {}), + ...(exitCode === undefined ? {} : { agentExitCode: exitCode }), + } + return { lastAttempt } +} + +/** + * Reads the manifest and the start block into the Wiki and attention facts. + * @param dependencies - The editor window's closures. + * @param location - The project, the Wiki folder and its label. + * @returns The published Wiki (its freshness still `checking`), its generation and the attention cause, where there is one. + * @example await readManifestFacts(dependencies, location) // => { wiki: { freshness: { status: 'checking' }, generation: {} } } + */ +async function readManifestFacts( + dependencies: LlmWikiViewFactsDependencies, + location: { + projectRoot: string + outputDirectory: string + outputLabel: string + }, +): Promise< + Pick, 'wiki' | 'attention'> +> { + const { projectRoot, outputDirectory, outputLabel } = location + const nowMs = dependencies.now() + const block = await dependencies.findStartBlock(outputDirectory, projectRoot) + const token = await dependencies + .readPublishToken(outputDirectory) + .catch(() => 'unreadable') + const generation = + token === undefined + ? undefined + : await dependencies + .readGeneration(projectRoot, outputDirectory) + .catch(() => undefined) + const wiki = generation + ? { wiki: { freshness: { status: 'checking' as const }, generation } } + : {} + if (block) + return { + ...wiki, + attention: { + cause: block.cause, + sentence: sentenceOfBlock( + block, + token !== undefined, + { outputDirectory, outputLabel }, + dependencies.host, + nowMs, + ), + }, + } + // A manifest that stands but reads as no Wiki of this project is another checkout's, or cannot be read. + if (token !== undefined && !generation) { + const isOwned = await dependencies + .isWikiOwnedBy(projectRoot, outputDirectory) + .catch(() => undefined) + return isOwned === false + ? { + attention: { + cause: 'foreign-manifest', + sentence: `The manifest in ${outputLabel} belongs to another checkout, not to this project. Set codebook.wikiOutputDirectory to a folder of this project.`, + }, + } + : { + attention: { + cause: 'unreadable-manifest', + sentence: `The manifest in ${outputLabel} cannot be read as an LLM Wiki of this project. Ask the editor chat "${WIKI_UPDATE_RECOVERY_REQUEST}" to recover it.`, + }, + } + } + return wiki +} + +/** + * Reads every fact the LLM Wiki view's rows are made of, without the slow freshness check: folder and trust, the manifest, the start block, the marker beside the Wiki and this window's run. + * + * Why: the view must show a published Wiki at once and read its freshness afterwards, so the read that decides the rows stays fast. + * Trigger: {@link LlmWikiView} calls it for every refresh. + * + * @param dependencies - The editor window's closures ({@link LlmWikiViewFactsDependencies}). + * @returns The facts for {@link readLlmWikiViewState}; the Wiki's freshness is `checking`. + * @throws What the folder, the manifest or the start check threw; the view shows its error row. + * @example + * await readLlmWikiViewFacts(dependencies) // => { kind: 'ready', host: 'vscode', repositoryName: 'nextjs-dashboard', … } + */ +export async function readLlmWikiViewFacts( + dependencies: LlmWikiViewFactsDependencies, +): Promise { + if (!dependencies.isFolderOpen()) return { kind: 'no-folder' } + if (!dependencies.isTrusted()) return { kind: 'untrusted' } + const { projectRoot, outputDirectory } = await dependencies.wikiLocation() + const outputLabel = wikiOutputLabel(projectRoot, outputDirectory) + const manifestFacts = await readManifestFacts(dependencies, { + projectRoot, + outputDirectory, + outputLabel, + }) + return { + kind: 'ready', + host: dependencies.host, + repositoryName: basename(projectRoot), + outputLabel, + outputDirectory, + nowMs: dependencies.now(), + publication: await dependencies + .readPublication(outputDirectory) + .catch(() => undefined), + ...manifestFacts, + ...readRunFacts(dependencies, outputDirectory), + ...(await readMarkerFacts(dependencies, outputDirectory)), + } +} diff --git a/extension/src/utils/readLlmWikiViewState.ts b/extension/src/utils/readLlmWikiViewState.ts new file mode 100644 index 000000000..6553a379c --- /dev/null +++ b/extension/src/utils/readLlmWikiViewState.ts @@ -0,0 +1,604 @@ +import { + LLM_WIKI_ROW_ACTIONS, + MS_PER_MINUTE, + SHOW_LLM_WIKI_ATTENTION_COMMAND, + WIKI_UPDATE_REASON_LIMIT_CHARS, +} from '../constants' + +import { describeMinutes } from './describeMinutes' +import { describeWikiFreshness } from './describeWikiFreshness' +import { + describeWikiGeneration, + type WikiGeneration, +} from './describeWikiGeneration' +import { formatClockTime } from './formatClockTime' +import type { WikiRunKind, WikiUpdateHost } from './wikiUpdateRun' + +/** The key of one inline action of a row ({@link LLM_WIKI_ROW_ACTIONS}); the manifest shows the command of each key on the rows whose context value names it. */ +export type LlmWikiRowActionKey = keyof typeof LLM_WIKI_ROW_ACTIONS + +/** The stable id of a row, kept across changes of its label so the editor keeps the selection (accessibility). */ +export type LlmWikiRowId = + | 'wiki' + | 'run' + | 'elsewhere' + | 'reservation' + | 'last-attempt' + | 'attention' + | 'output' + | 'folder' + | 'trust' + | 'loading' + | 'error' + +/** One row of the LLM Wiki view, as plain data: the view turns it into a tree item. */ +export type LlmWikiRow = { + id: LlmWikiRowId + label: string + /** The grey text after the label; it may truncate in a narrow sidebar. */ + description?: string + /** The text of the hover; it holds the full text when the description is short. */ + tooltip?: string + /** A theme icon's id (a codicon), for example `book` or `sync~spin`. */ + icon: string + /** The command a click on the row runs, with the row's id as its argument where the command shows the row's sentence. */ + command?: { command: string; arguments?: string[] } + /** The inline actions of the row, also in its context menu. */ + actions: LlmWikiRowActionKey[] + /** The full sentence of an attention or last-attempt row, shown by {@link SHOW_LLM_WIKI_ATTENTION_COMMAND}. */ + detail?: string +} + +/** What the view shows: `none` when the folder holds no Wiki and no run (the welcome content shows), `other` for every row; the view adds `loading` before its first read. */ +export type LlmWikiViewModel = { + state: 'none' | 'other' + rows: LlmWikiRow[] + /** The wrapping message above the tree; set while a VS Code watch is shown. */ + message?: string +} + +/** How fresh the published Wiki is: still being checked, or the answer of the freshness check. */ +export type LlmWikiFreshness = + | { status: 'checking' } + | { + status: 'current' | 'changed' | 'unknown' + reason?: string + isAdditionUnchecked?: boolean + } + +/** Why a generation of this window ended without a publication. */ +export type LlmWikiStoppedGeneration = { + state: 'cancelled' | 'timed-out' | 'failed' | 'conflict' + host: WikiUpdateHost + /** The bound a `timed-out` end passed (ms). */ + timeoutMs?: number + /** The exit code of the Cursor agent's process, where it ended on its own. */ + agentExitCode?: number + /** The full sentence the editor told ({@link wikiGenerateNotice}). */ + sentence: string +} + +/** Everything the view's rows are made of, read once; {@link readLlmWikiViewState} turns it into rows. */ +export type LlmWikiViewFacts = + | { kind: 'no-folder' } + | { kind: 'untrusted' } + | { + kind: 'ready' + /** The editor this window runs in. */ + host: WikiUpdateHost + repositoryName: string + /** The output folder as the learner reads it ({@link wikiOutputLabel}). */ + outputLabel: string + /** The absolute resolved output folder. */ + outputDirectory: string + /** Set when the file watcher could not be made, so the view refreshes on visibility and its Refresh only. */ + isWatcherBroken?: true + nowMs: number + /** The publisher's receipt that names the manifest now on disk; the view compares it between reads to notice a late publication. Absent while the folder holds none. */ + publication?: string + /** A published Wiki that reads as a Wiki of this project. */ + wiki?: { freshness: LlmWikiFreshness; generation: WikiGeneration } + /** This window's run. `isAwaitingChat` marks a VS Code run that still waits for the chat's first sign. */ + run?: { + kind: WikiRunKind + startedAtMs: number + timeoutMs: number + isAwaitingChat: boolean + } + /** A live marker of another window; `kind` is absent while the marker is being written. */ + elsewhere?: { kind?: WikiRunKind; startedAtMs: number } + /** The reservation of a stopped VS Code watch, from any window. */ + reservation?: { + kind?: WikiRunKind + host: WikiUpdateHost + startedAtMs: number + retryAtMs: number + } + lastAttempt?: LlmWikiStoppedGeneration + /** A start block, or a manifest that cannot be used; `cause` is a conflict cause, `foreign-manifest`, or one a later version adds. */ + attention?: { cause: string; sentence: string } + } + +/** How the rows write times and dates; tests pass fixed ones. */ +export type LlmWikiViewWording = { + formatTime: (epochMs: number) => string + formatDate: (epochMs: number) => string +} + +/** The wording the view reads in the editor's locale. */ +const LOCAL_WORDING: LlmWikiViewWording = { + formatTime: (epochMs) => formatClockTime(epochMs), + formatDate: (epochMs) => new Date(epochMs).toLocaleDateString(), +} + +/** The message above the tree while a VS Code watch is shown: Stop watching ends Codebook's watch only. */ +export const LLM_WIKI_WATCHING_MESSAGE = + 'Codebook is watching the chat. Stop watching does not stop the chat; stop it in the chat.' + +/** How an attention cause is worded and what its row offers (the plan's attention table). */ +type AttentionRow = { + label: string + shortCause: string + actions: LlmWikiRowActionKey[] + /** Whether the cause leaves the manifest unusable, so the Wiki row is not shown. */ + hidesWiki: boolean +} + +/** The attention row of every cause of a start block and of a foreign manifest. */ +const ATTENTION_ROWS = new Map([ + [ + 'held-publish-lock', + { + label: 'Publish in progress or stopped', + shortCause: 'publish lock found', + actions: ['copy-recovery', 'refresh'], + hidesWiki: false, + }, + ], + [ + 'unfinished-publish', + { + label: 'Publish in progress or stopped', + shortCause: 'unfinished publish', + actions: ['copy-recovery', 'refresh'], + hidesWiki: false, + }, + ], + [ + 'edited-manifest', + { + label: 'Wiki manifest needs recovery', + shortCause: 'manifest edited', + actions: ['copy-recovery', 'refresh'], + hidesWiki: true, + }, + ], + [ + 'unreadable-manifest', + { + label: 'Wiki manifest needs recovery', + shortCause: 'manifest unreadable', + actions: ['copy-recovery', 'refresh'], + hidesWiki: true, + }, + ], + [ + 'not-a-folder', + { + label: 'Wiki folder belongs elsewhere', + shortCause: 'output is a file or link', + actions: ['change-location'], + hidesWiki: true, + }, + ], + [ + 'linked-parent', + { + label: 'Wiki folder belongs elsewhere', + shortCause: 'link on the folder path', + actions: ['change-location'], + hidesWiki: true, + }, + ], + [ + 'foreign-manifest', + { + label: 'Wiki folder belongs elsewhere', + shortCause: 'another checkout', + actions: ['change-location'], + hidesWiki: true, + }, + ], + [ + 'learner-file', + { + label: 'Wiki files changed by hand', + shortCause: 'your file is in the folder', + actions: ['reveal-folder', 'refresh'], + hidesWiki: false, + }, + ], + [ + 'special-file', + { + label: 'Wiki files changed by hand', + shortCause: 'link or special file', + actions: ['reveal-folder', 'refresh'], + hidesWiki: false, + }, + ], + [ + 'edited-page', + { + label: 'Wiki files changed by hand', + shortCause: 'page edited by hand', + actions: ['reveal-folder', 'refresh'], + hidesWiki: false, + }, + ], + [ + 'missing-page', + { + label: 'Wiki files changed by hand', + shortCause: 'page missing', + actions: ['reveal-folder', 'refresh'], + hidesWiki: false, + }, + ], +]) + +/** What a cause added later without a row of its own falls back to: its own sentence, and a way to look again. */ +const FALLBACK_ATTENTION_ROW: AttentionRow = { + label: 'Wiki needs attention', + shortCause: 'see details', + actions: ['refresh'], + hidesWiki: false, +} + +/** The row of a view that reads its state for the first time. */ +export const LLM_WIKI_LOADING_ROW: LlmWikiRow = { + id: 'loading', + label: 'Reading the LLM Wiki state…', + icon: 'loading~spin', + actions: [], +} + +/** The row of a view whose read threw: the cause is in Output › Codebook, and Refresh retries. */ +export const LLM_WIKI_ERROR_ROW: LlmWikiRow = { + id: 'error', + label: 'Could not read the LLM Wiki state', + description: 'Refresh to retry', + tooltip: + 'The cause is written to Output › Codebook. The Refresh action in the title bar reads the state again.', + icon: 'error', + command: { command: LLM_WIKI_ROW_ACTIONS['show-output'].command }, + actions: [], +} + +/** Words a run kind as the progress word of a row's label. @example runWord('generate') // => 'Generating' */ +function runWord(kind: WikiRunKind | undefined): string { + return kind === 'generate' ? 'Generating' : 'Updating' +} + +/** Names an editor host as the learner knows it. @example hostName('vscode') // => 'VS Code' */ +function hostName(host: WikiUpdateHost): string { + return host === 'cursor' ? 'Cursor' : 'VS Code' +} + +/** Words a number of whole minutes as `N min`. @example minutes(12) // => '12 min' */ +function minutes(wholeMinutes: number): string { + return `${wholeMinutes} min` +} + +/** + * Builds row 1, the published Wiki: its label, the shared freshness wording and the generation line of the tooltip. + * @param facts - The ready facts that hold a Wiki. + * @param wiki - The published Wiki. + * @param wording - How times and dates are written. + * @param hasRunRow - Whether a run row stands, which hides the Update action. + * @returns The Wiki row. + * @example wikiRow(facts, wiki, wording, false).description // => 'Wiki source current' + */ +function wikiRow( + facts: Extract, + wiki: NonNullable['wiki']>, + wording: LlmWikiViewWording, + hasRunRow: boolean, +): LlmWikiRow { + const freshness = wiki.freshness + const description = + freshness.status === 'checking' + ? 'Checking freshness…' + : describeWikiFreshness( + freshness.status, + freshness.reason ?? '', + freshness.isAdditionUnchecked ?? false, + WIKI_UPDATE_REASON_LIMIT_CHARS, + ) + const generationLine = describeWikiGeneration( + wiki.generation, + wording.formatDate, + ) + const reasonLine = + freshness.status === 'unknown' ? freshness.reason : undefined + return { + id: 'wiki', + label: `${facts.repositoryName} Wiki`, + description, + tooltip: [ + generationLine, + reasonLine, + 'Click to open the Wiki in the browser.', + ] + .filter((line) => line !== undefined && line !== '') + .join('\n'), + icon: 'book', + command: { command: 'codebook.openDeepWiki' }, + actions: hasRunRow ? [] : ['update'], + } +} + +/** + * Builds row 2, this window's run, with the actions that fit its host. + * @param facts - The ready facts that hold a run. + * @param run - This window's run. + * @returns The run row: Stop watching, Open Chat and Show Output in VS Code; Cancel and Show Output in Cursor. + * @example runRow(facts, run).label // => 'Generating…' + */ +function runRow( + facts: Extract, + run: NonNullable['run']>, +): LlmWikiRow { + const isCursor = facts.host === 'cursor' + const elapsed = Math.max( + 0, + Math.floor((facts.nowMs - run.startedAtMs) / MS_PER_MINUTE), + ) + const progress = `${minutes(elapsed)} of ${Math.round(run.timeoutMs / MS_PER_MINUTE)}` + let description: string + if (isCursor) description = `cursor-agent · ${progress}` + else if (run.isAwaitingChat) description = 'Waiting for the chat to start' + else description = `In chat · ${progress}` + return { + id: 'run', + label: `${runWord(run.kind)}…`, + description, + tooltip: isCursor + ? 'Cursor Agent is writing the Wiki. Cancel stops it.' + : 'Codebook is watching the chat for the new Wiki. Stop watching ends Codebook’s watch, not the chat.', + icon: 'sync~spin', + actions: isCursor + ? ['cancel-run', 'show-output'] + : ['stop-watching', 'open-chat', 'show-output'], + } +} + +/** + * Builds row 2b, a run of another window. + * @param elsewhere - The other window's marker. + * @param wording - How times are written. + * @returns The row; a marker still being written reads as a start. + * @example elsewhereRow({ kind: 'generate', startedAtMs: 1_790_000_000_000 }, wording).label // => 'Generating in another window' + */ +function elsewhereRow( + elsewhere: NonNullable< + Extract['elsewhere'] + >, + wording: LlmWikiViewWording, +): LlmWikiRow { + return { + id: 'elsewhere', + label: + elsewhere.kind === undefined + ? 'Starting in another window' + : `${runWord(elsewhere.kind)} in another window`, + description: `since ${wording.formatTime(elsewhere.startedAtMs)}`, + icon: 'sync~spin', + actions: [], + } +} + +/** + * Builds row 2c, the reservation a stopped VS Code watch left. + * @param facts - The ready facts that hold a reservation. + * @param reservation - The reservation. + * @param wording - How times are written. + * @returns The row, with the retry time in its description and the run's start in its tooltip. + * @example reservationRow(facts, reservation, wording).label // => 'Waiting for chat' + */ +function reservationRow( + facts: Extract, + reservation: NonNullable< + Extract['reservation'] + >, + wording: LlmWikiViewWording, +): LlmWikiRow { + const retryTime = wording.formatTime(reservation.retryAtMs) + const wholeMinutes = Math.max( + 1, + Math.ceil((reservation.retryAtMs - facts.nowMs) / MS_PER_MINUTE), + ) + return { + id: 'reservation', + label: 'Waiting for chat', + description: `retry after ${retryTime} (about ${minutes(wholeMinutes)})`, + tooltip: `${runWord(reservation.kind)} started at ${wording.formatTime(reservation.startedAtMs)} in ${hostName(reservation.host)}. Open Chat opens the chat view; find the session in the chat history to stop it. Codebook allows a new start after ${retryTime} even if the chat stopped earlier.`, + icon: 'clock', + actions: ['open-chat'], + } +} + +/** Says in a few words how a generation ended without a publication. @example describeStoppedCause({ state: 'timed-out', host: 'cursor', timeoutMs: 3_300_000, sentence: '' }) // => 'stopped after 55 minutes' */ +function describeStoppedCause(stopped: LlmWikiStoppedGeneration): string { + if (stopped.state === 'cancelled') + return stopped.host === 'cursor' ? 'cancelled' : 'stopped watching' + if (stopped.state === 'timed-out') + return `stopped after ${describeMinutes(stopped.timeoutMs ?? 0)}` + if (stopped.state === 'conflict') return 'files in the way' + return stopped.agentExitCode === undefined + ? 'no Wiki published' + : `agent exit ${stopped.agentExitCode}` +} + +/** + * Builds row 3, the generation of this window that ended without a publication. + * @param stopped - How it ended. + * @param isTryAgainOffered - Whether Try again is offered: not while a run or an attention row stands. + * @returns The row; a click shows its full sentence. + * @example lastAttemptRow({ state: 'failed', host: 'cursor', agentExitCode: 1, sentence: '…' }, true).description // => 'agent exit 1' + */ +function lastAttemptRow( + stopped: LlmWikiStoppedGeneration, + isTryAgainOffered: boolean, +): LlmWikiRow { + return { + id: 'last-attempt', + label: 'Last generation stopped', + description: describeStoppedCause(stopped), + tooltip: stopped.sentence, + icon: 'info', + command: { + command: SHOW_LLM_WIKI_ATTENTION_COMMAND, + arguments: ['last-attempt'], + }, + actions: isTryAgainOffered ? ['try-again'] : [], + detail: stopped.sentence, + } +} + +/** + * Builds row 4, what blocks a start or makes the manifest unusable. + * @param attention - The cause and the sentence of the block. + * @returns The row; a click shows the sentence with Copy details. + * @example attentionRow({ cause: 'edited-page', sentence: '…' }).label // => 'Wiki files changed by hand' + */ +function attentionRow( + attention: NonNullable< + Extract['attention'] + >, +): LlmWikiRow { + const row = ATTENTION_ROWS.get(attention.cause) ?? FALLBACK_ATTENTION_ROW + return { + id: 'attention', + label: row.label, + description: row.shortCause, + tooltip: attention.sentence, + icon: 'warning', + command: { + command: SHOW_LLM_WIKI_ATTENTION_COMMAND, + arguments: ['attention'], + }, + actions: row.actions, + detail: attention.sentence, + } +} + +/** + * Builds the rows of a ready view in the fixed order: Wiki, run, elsewhere, reservation, last attempt, attention. + * @param facts - The ready facts. + * @param wording - How times and dates are written. + * @returns The rows before the Output row; empty when the folder holds no Wiki and no run. + * @example readyRows(facts, wording).map((row) => row.id) // => ['wiki'] + */ +function readyRows( + facts: Extract, + wording: LlmWikiViewWording, +): LlmWikiRow[] { + const hasRunRow = Boolean(facts.run || facts.elsewhere || facts.reservation) + const attentionRowOfCause = facts.attention + ? (ATTENTION_ROWS.get(facts.attention.cause) ?? FALLBACK_ATTENTION_ROW) + : undefined + // A cause that leaves the manifest unusable takes the Wiki row away; the other causes leave the published Wiki readable. + const isWikiShown = Boolean(facts.wiki) && !attentionRowOfCause?.hidesWiki + const rows: LlmWikiRow[] = [] + if (facts.wiki && isWikiShown) + rows.push(wikiRow(facts, facts.wiki, wording, hasRunRow)) + if (facts.run) rows.push(runRow(facts, facts.run)) + if (facts.elsewhere) rows.push(elsewhereRow(facts.elsewhere, wording)) + if (facts.reservation) + rows.push(reservationRow(facts, facts.reservation, wording)) + // A publication ends the last attempt: the Wiki row stands instead. + if (facts.lastAttempt && !isWikiShown) + rows.push(lastAttemptRow(facts.lastAttempt, !hasRunRow && !facts.attention)) + if (facts.attention) rows.push(attentionRow(facts.attention)) + return rows +} + +/** + * Builds row 5, the output folder and its Change location action. + * @param facts - The ready facts. + * @returns The Output row; its tooltip says so when the file watcher could not be made. + * @example outputRow(facts).label // => 'Output: .codebook/wiki' + */ +function outputRow( + facts: Extract, +): LlmWikiRow { + return { + id: 'output', + label: `Output: ${facts.outputLabel}`, + description: 'Change location', + tooltip: facts.isWatcherBroken + ? `${facts.outputDirectory}\nCodebook could not watch this folder, so the view refreshes when it is shown and with Refresh only.` + : facts.outputDirectory, + icon: 'folder', + actions: ['change-location'], + } +} + +/** + * Turns one record of facts into the rows of the LLM Wiki view, in their fixed order, each present exactly when its fact holds. + * + * Why: the view shows a Wiki beside a run, a reservation and an attention cause, so its rows are independent axes, not one state; one pure function keeps the complexity limits met and lets one table-driven test cover the whole matrix (Eng decision 82). + * Trigger: {@link LlmWikiView} calls it after each read of the facts. + * + * @param facts - What the folder, the trust, this window's run, other windows' markers, the manifest and the last attempt say. + * @param wording - How times and dates are written; the editor's locale when omitted. + * @returns + * - No folder: one row that opens a folder; untrusted: one row that manages trust + * - Otherwise the Wiki, run, elsewhere, reservation, last-attempt and attention rows that hold, then the Output row when any of them does + * - No row at all, and state `none`, when the folder holds no Wiki and no run (the welcome content shows) + * - A `message` while a VS Code watch is shown + * + * @example + * ```ts + * readLlmWikiViewState({ kind: 'no-folder' }).rows[0]?.label // => 'Open a folder to use the LLM Wiki' + * ``` + */ +export function readLlmWikiViewState( + facts: LlmWikiViewFacts, + wording: LlmWikiViewWording = LOCAL_WORDING, +): LlmWikiViewModel { + if (facts.kind === 'no-folder') + return { + state: 'other', + rows: [ + { + id: 'folder', + label: 'Open a folder to use the LLM Wiki', + icon: 'folder-opened', + actions: ['open-folder'], + }, + ], + } + if (facts.kind === 'untrusted') + return { + state: 'other', + rows: [ + { + id: 'trust', + label: 'Trust this folder to use the LLM Wiki', + icon: 'shield', + actions: ['manage-trust'], + }, + ], + } + const rows = readyRows(facts, wording) + if (rows.length === 0) return { state: 'none', rows: [] } + const isWatchShown = + facts.host === 'vscode' && Boolean(facts.run ?? facts.reservation) + return { + state: 'other', + rows: [...rows, outputRow(facts)], + ...(isWatchShown ? { message: LLM_WIKI_WATCHING_MESSAGE } : {}), + } +} diff --git a/extension/src/utils/registerLlmWikiCommands.ts b/extension/src/utils/registerLlmWikiCommands.ts new file mode 100644 index 000000000..0003c3903 --- /dev/null +++ b/extension/src/utils/registerLlmWikiCommands.ts @@ -0,0 +1,137 @@ +import { homedir } from 'node:os' + +import type * as vscode from 'vscode' + +import { + COPY_DETAILS_ACTION, + COPY_WIKI_RECOVERY_REQUEST_COMMAND, + LLM_WIKI_ROW_ACTIONS, + OPEN_CHAT_COMMAND, + OPEN_SETTINGS_COMMAND, + REFRESH_LLM_WIKI_COMMAND, + REVEAL_WIKI_FOLDER_COMMAND, + SHOW_LLM_WIKI_ATTENTION_COMMAND, + WIKI_OUTPUT_SETTINGS_QUERY, + WIKI_UPDATE_RECOVERY_REQUEST, +} from '../constants' + +import type { LlmWikiView } from './llmWikiView' +import { resolveManageTrustCommand } from './resolveManageTrustCommand' + +/** What the commands of the LLM Wiki view ask of the window. */ +export type LlmWikiCommandOptions = { + editor: Pick + /** Registers a command of this window ({@link registerPlainCommand}). */ + register: ( + command: string, + handler: (...args: never[]) => unknown, + ) => vscode.Disposable + /** Shows a notification; resolves with the pressed button. */ + showNotice: ( + severity: 'information' | 'warning' | 'error', + message: string, + ...actions: string[] + ) => Thenable + view: LlmWikiView + /** Stops this window's run: the VS Code watch, or the Cursor agent. */ + cancelRun: () => void + /** The resolved Wiki folder. */ + findOutputDirectory: () => Promise +} + +/** + * Shows the full sentence of an attention or last-attempt row, with Copy details; the click of those rows. + * @param options - The window's closures. + * @param rowId - The id of the clicked row. + * @returns Nothing; the sentence is copied with the home folder shown as `~` when the learner presses Copy details. + * @example await showRowDetail(options, 'attention') + */ +async function showRowDetail( + options: LlmWikiCommandOptions, + rowId: string, +): Promise { + const detail = options.view.findRow(rowId)?.detail + if (detail === undefined) return + const choice = await options.showNotice( + 'warning', + detail, + COPY_DETAILS_ACTION, + ) + if (choice === COPY_DETAILS_ACTION) + await options.editor.env.clipboard.writeText( + detail.split(homedir()).join('~'), + ) +} + +/** + * Runs the editor's command that manages workspace trust, or says where to find it when the editor lists none of the known ids. + * @param options - The window's closures. + * @returns Nothing. + * @example await manageTrust(options) + */ +async function manageTrust(options: LlmWikiCommandOptions): Promise { + const available = await options.editor.commands.getCommands(true) + const commandId = resolveManageTrustCommand(available) + if (commandId === undefined) { + void options.showNotice( + 'information', + 'Run "Workspaces: Manage Workspace Trust" from the Command Palette to trust this folder.', + ) + return + } + await options.editor.commands.executeCommand(commandId) +} + +/** + * Registers every command of the LLM Wiki view: Refresh, the row sentence, the recovery request, the folder reveal and the wrappers of the row actions whose editor commands are not Codebook's own. + * + * Why: the manifest can show an inline action only for a command it contributes, so each action that runs an editor command, or one whose title differs by host (Stop watching, Cancel), is a Codebook command with the title the row needs. + * Trigger: `activate()` calls it once per window beside the view. + * + * @param options - The window's closures ({@link LlmWikiCommandOptions}). + * @returns The registrations, to dispose with the extension. + * @example + * context.subscriptions.push(...registerLlmWikiCommands({ editor: vscode, registerCommand, showNotice, view, cancelRun, findOutputDirectory })) + */ +export function registerLlmWikiCommands( + options: LlmWikiCommandOptions, +): vscode.Disposable[] { + const { register: registerCommand, editor } = options + const cancelRun = (): void => options.cancelRun() + return [ + registerCommand(REFRESH_LLM_WIKI_COMMAND, () => options.view.refresh()), + registerCommand(SHOW_LLM_WIKI_ATTENTION_COMMAND, (rowId: string) => + showRowDetail(options, rowId), + ), + registerCommand(COPY_WIKI_RECOVERY_REQUEST_COMMAND, async () => { + await editor.env.clipboard.writeText(WIKI_UPDATE_RECOVERY_REQUEST) + void options.showNotice( + 'information', + 'Copied the recovery request. Paste it into the editor chat.', + ) + }), + registerCommand(REVEAL_WIKI_FOLDER_COMMAND, async () => + editor.commands.executeCommand( + 'revealFileInOS', + editor.Uri.file(await options.findOutputDirectory()), + ), + ), + registerCommand(LLM_WIKI_ROW_ACTIONS['stop-watching'].command, cancelRun), + registerCommand(LLM_WIKI_ROW_ACTIONS['cancel-run'].command, cancelRun), + registerCommand(LLM_WIKI_ROW_ACTIONS['open-chat'].command, () => + editor.commands.executeCommand(OPEN_CHAT_COMMAND), + ), + registerCommand(LLM_WIKI_ROW_ACTIONS['change-location'].command, () => + editor.commands.executeCommand( + OPEN_SETTINGS_COMMAND, + WIKI_OUTPUT_SETTINGS_QUERY, + ), + ), + registerCommand(LLM_WIKI_ROW_ACTIONS['open-folder'].command, () => + editor.commands.executeCommand('vscode.openFolder'), + ), + registerCommand(LLM_WIKI_ROW_ACTIONS['manage-trust'].command, () => + manageTrust(options), + ), + ] +} diff --git a/extension/src/utils/resolveManageTrustCommand.ts b/extension/src/utils/resolveManageTrustCommand.ts new file mode 100644 index 000000000..b2223d0ee --- /dev/null +++ b/extension/src/utils/resolveManageTrustCommand.ts @@ -0,0 +1,21 @@ +import { MANAGE_WORKSPACE_TRUST_COMMAND_IDS } from '../constants' + +/** + * Picks the id of the editor's command that manages workspace trust from the commands the editor lists. + * + * Why: the command's id is not documented and may differ between VS Code and Cursor ({@link MANAGE_WORKSPACE_TRUST_COMMAND_IDS}); the first listed one is run, and none lets the caller say where to find it. + * Trigger: the LLM Wiki view's Manage Workspace Trust action. + * + * @param availableCommands - The ids `vscode.commands.getCommands(true)` lists. + * @returns The first known id the editor lists; `undefined` when it lists none. + * @example + * resolveManageTrustCommand(['workbench.trust.manage', 'other']) // => 'workbench.trust.manage' + * resolveManageTrustCommand(['other']) // => undefined + */ +export function resolveManageTrustCommand( + availableCommands: readonly string[], +): string | undefined { + return MANAGE_WORKSPACE_TRUST_COMMAND_IDS.find((commandId) => + availableCommands.includes(commandId), + ) +} diff --git a/extension/src/utils/wikiUpdateRun.ts b/extension/src/utils/wikiUpdateRun.ts index e5673f6f1..daf4d4d75 100644 --- a/extension/src/utils/wikiUpdateRun.ts +++ b/extension/src/utils/wikiUpdateRun.ts @@ -247,12 +247,32 @@ function refusalReason( export class WikiUpdateRun { #status: WikiUpdateStatus = { state: 'idle' } readonly #now: () => Date + readonly #changeListeners = new Set<() => void>() /** @param now - Clock for result timestamps; tests pass a fixed one. */ constructor(now: () => Date = () => new Date()) { this.#now = now } + /** + * Calls a listener after every change of the state; used by the LLM Wiki view, which reads its rows again when this window's run starts, advances or ends. + * @param listener - Called synchronously after the state changed; it must not throw. + * @returns The function that removes the listener. + * @example const stopListening = run.onDidChange(() => refreshView()) + */ + onDidChange(listener: () => void): () => void { + this.#changeListeners.add(listener) + return (): void => { + this.#changeListeners.delete(listener) + } + } + + /** Replaces the state and tells the listeners; every change of the state goes through it. @param next - The new state. @example this.#record({ state: 'idle' }) */ + #record(next: WikiUpdateStatus): void { + this.#status = next + for (const listener of this.#changeListeners) listener() + } + /** @returns A copy of the current state, safe to serialize. @example run.status().state // => 'idle' */ status(): WikiUpdateStatus { return structuredClone(this.#status) @@ -272,7 +292,7 @@ export class WikiUpdateRun { runKind: WikiRunKind = 'update', ): boolean { if (this.isRunning()) return false - this.#status = { + this.#record({ state: 'running', host, startedAt: this.#now().toISOString(), @@ -281,14 +301,14 @@ export class WikiUpdateRun { ...(isOutputOutsideProject ? { isOutputOutsideProject: true } : {}), ...(runKind === 'generate' ? { runKind } : {}), ...output, - } + }) return true } /** Notes that the agent has staged a candidate site, so the page can say work is under way. Ignored when no run is active. @example run.markCandidateStaged() */ markCandidateStaged(): void { if (this.#status.state !== 'running') return - this.#status = { ...this.#status, candidateStaged: true } + this.#record({ ...this.#status, candidateStaged: true }) } /** @@ -305,7 +325,7 @@ export class WikiUpdateRun { */ end(result: WikiUpdateEnd): void { if (this.#status.state !== 'running') return - this.#status = endedStatus(this.#status, this.#now().toISOString(), result) + this.#record(endedStatus(this.#status, this.#now().toISOString(), result)) } /** Records that nothing needed updating; no run starts. @param output - Resolved Wiki folder and its label. @param published - What the current site is called. @param areAddedFilesUnchecked - Whether the project has no Git repository, so "current" covers the indexed sources only; the state then says so. @example run.alreadyCurrent({ outputDirectory: '/repo/.codebook/wiki', outputLabel: '.codebook/wiki' }, { identity: 'a1b2c3d4e5f6+0f9e8d7c', revision: 'a1b2c3d4e5f6', index: '0f9e8d7c' }, false) */ @@ -315,13 +335,13 @@ export class WikiUpdateRun { areAddedFilesUnchecked: boolean, ): void { if (this.isRunning()) return - this.#status = { + this.#record({ state: 'already-current', finishedAt: this.#now().toISOString(), ...(areAddedFilesUnchecked ? { addedFilesUnchecked: true } : {}), ...published, ...output, - } + }) } /** Records a start that was refused before any agent ran, so the page shows why; nothing was replaced. Ignored while a run is active. @param host - Editor host that was asked. @param output - Resolved Wiki folder and its label. @param refusal - The block found before the start. @example run.refuseStart('vscode', output, { kind: 'conflict', paths: ['index.html'], cause: 'edited-page' }) */ @@ -332,7 +352,7 @@ export class WikiUpdateRun { ): void { if (this.isRunning()) return const finishedAt = this.#now().toISOString() - this.#status = + this.#record( refusal.kind === 'conflict' && refusal.paths.length > 0 ? { state: 'conflict', @@ -349,6 +369,7 @@ export class WikiUpdateRun { finishedAt, ...refusalReason(refusal), ...output, - } + }, + ) } } From 75ff62e8c36e8119aec61a84b9af8a535ab215d8 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Mon, 26 Oct 2026 18:49:05 +0900 Subject: [PATCH 03/18] feat: Cycle 0055 pt2: LLM Wiki view in the Activity Bar, its commands, manifest entries and learning icon Refs #132 --- extension/package.json | 235 +++++++++++++++++- extension/src/assets/learning.svg | 1 + extension/src/extension.ts | 129 ++++++++-- extension/src/tests/utils/wikiUpdateWindow.ts | 212 +++++++++++++++- 4 files changed, 548 insertions(+), 29 deletions(-) create mode 100644 extension/src/assets/learning.svg diff --git a/extension/package.json b/extension/package.json index 5b2898c55..d7f82a7f4 100644 --- a/extension/package.json +++ b/extension/package.json @@ -168,11 +168,15 @@ }, { "command": "codebook.generateDeepWiki", - "title": "Codebook: Generate LLM Wiki" + "title": "Codebook: Generate LLM Wiki", + "shortTitle": "Try again", + "icon": "$(play)" }, { "command": "codebook.updateDeepWiki", - "title": "Codebook: Update LLM Wiki" + "title": "Codebook: Update LLM Wiki", + "shortTitle": "Update LLM Wiki", + "icon": "$(cloud-download)" }, { "command": "codebook.cancelWikiRun", @@ -180,7 +184,9 @@ }, { "command": "codebook.showWikiOutput", - "title": "Codebook: Show LLM Wiki Output" + "title": "Codebook: Show LLM Wiki Output", + "shortTitle": "Show Output", + "icon": "$(output)" }, { "command": "codebook.closeRepositoryWiki", @@ -203,6 +209,58 @@ "command": "codebook.moveLearningSummary", "title": "Codebook: Move Learning Summary…", "icon": "$(move)" + }, + { + "command": "codebook.refreshLlmWiki", + "title": "Codebook: Refresh LLM Wiki", + "shortTitle": "Refresh", + "icon": "$(refresh)" + }, + { + "command": "codebook.showLlmWikiAttention", + "title": "Codebook: Show LLM Wiki Details" + }, + { + "command": "codebook.copyWikiRecoveryRequest", + "title": "Codebook: Copy LLM Wiki Recovery Request", + "shortTitle": "Copy recovery request", + "icon": "$(copy)" + }, + { + "command": "codebook.revealWikiFolder", + "title": "Codebook: Reveal LLM Wiki Folder", + "shortTitle": "Reveal Wiki Folder", + "icon": "$(folder-opened)" + }, + { + "command": "codebook.llmWiki.stopWatching", + "title": "Stop watching", + "icon": "$(debug-stop)" + }, + { + "command": "codebook.llmWiki.cancelRun", + "title": "Cancel", + "icon": "$(debug-stop)" + }, + { + "command": "codebook.llmWiki.openChat", + "title": "Open Chat", + "icon": "$(comment-discussion)" + }, + { + "command": "codebook.llmWiki.changeLocation", + "title": "Change location", + "icon": "$(edit)" + }, + { + "command": "codebook.llmWiki.openFolder", + "title": "Open Folder", + "icon": "$(folder-opened)" + }, + { + "command": "codebook.llmWiki.manageTrust", + "title": "Manage Workspace Trust", + "icon": "$(shield)" } ], "configuration": { @@ -246,7 +304,7 @@ { "id": "codebookLearning", "title": "Learning summary", - "icon": "src/assets/book.svg" + "icon": "src/assets/learning.svg" } ], "panel": [ @@ -263,6 +321,10 @@ "id": "codebook.repositoryWiki", "name": "Repository Wiki" }, + { + "id": "codebook.llmWiki", + "name": "LLM Wiki" + }, { "id": "codebook.learningOutline", "name": "Learning outline" @@ -284,6 +346,16 @@ ] }, "viewsWelcome": [ + { + "view": "codebook.llmWiki", + "when": "codebook.llmWiki.state == none && codebook.host == vscode", + "contents": "Explore this project through source-linked chapters, diagrams and questions, written by your editor's AI.\n[Generate LLM Wiki](command:codebook.generateDeepWiki)\nIt runs in a new Copilot Chat session and uses your chat plan. The chat asks you to approve its commands and may ask you to Continue; keep the Chat view open.\nThe Wiki is written to the folder the setting codebook.wikiOutputDirectory names (default .codebook/wiki). [Change location](command:workbench.action.openSettings?%5B%22%40id%3Acodebook.wikiOutputDirectory%22%5D)" + }, + { + "view": "codebook.llmWiki", + "when": "codebook.llmWiki.state == none && codebook.host == cursor", + "contents": "Explore this project through source-linked chapters, diagrams and questions, written by your editor's AI.\n[Generate LLM Wiki](command:codebook.generateDeepWiki)\nIt runs through Cursor's agent CLI (cursor-agent), uses your Cursor plan and can take several minutes. You can keep working.\nThe Wiki is written to the folder the setting codebook.wikiOutputDirectory names (default .codebook/wiki). [Change location](command:workbench.action.openSettings?%5B%22%40id%3Acodebook.wikiOutputDirectory%22%5D)" + }, { "view": "codebook.learningOutline", "contents": "Connect the application screen with its actual code.\n[Open Study workspace](command:codebook.openStudyWorkspace)" @@ -300,12 +372,167 @@ "command": "codebook.moveLearningSummary", "when": "view == codebook.learningSummary && codebook.learningSummaryMovable", "group": "navigation" + }, + { + "command": "codebook.refreshLlmWiki", + "when": "view == codebook.llmWiki", + "group": "navigation" } ], "commandPalette": [ { "command": "codebook.moveLearningSummary", "when": "codebook.learningSummaryMovable" + }, + { + "command": "codebook.showLlmWikiAttention", + "when": "false" + }, + { + "command": "codebook.llmWiki.stopWatching", + "when": "false" + }, + { + "command": "codebook.llmWiki.cancelRun", + "when": "false" + }, + { + "command": "codebook.llmWiki.openChat", + "when": "false" + }, + { + "command": "codebook.llmWiki.changeLocation", + "when": "false" + }, + { + "command": "codebook.llmWiki.openFolder", + "when": "false" + }, + { + "command": "codebook.llmWiki.manageTrust", + "when": "false" + } + ], + "view/item/context": [ + { + "command": "codebook.updateDeepWiki", + "when": "view == codebook.llmWiki && viewItem =~ /\\[update\\]/", + "group": "inline@0" + }, + { + "command": "codebook.updateDeepWiki", + "when": "view == codebook.llmWiki && viewItem =~ /\\[update\\]/", + "group": "llmWiki@0" + }, + { + "command": "codebook.llmWiki.stopWatching", + "when": "view == codebook.llmWiki && viewItem =~ /\\[stop-watching\\]/", + "group": "inline@1" + }, + { + "command": "codebook.llmWiki.stopWatching", + "when": "view == codebook.llmWiki && viewItem =~ /\\[stop-watching\\]/", + "group": "llmWiki@1" + }, + { + "command": "codebook.llmWiki.cancelRun", + "when": "view == codebook.llmWiki && viewItem =~ /\\[cancel-run\\]/", + "group": "inline@2" + }, + { + "command": "codebook.llmWiki.cancelRun", + "when": "view == codebook.llmWiki && viewItem =~ /\\[cancel-run\\]/", + "group": "llmWiki@2" + }, + { + "command": "codebook.llmWiki.openChat", + "when": "view == codebook.llmWiki && viewItem =~ /\\[open-chat\\]/", + "group": "inline@3" + }, + { + "command": "codebook.llmWiki.openChat", + "when": "view == codebook.llmWiki && viewItem =~ /\\[open-chat\\]/", + "group": "llmWiki@3" + }, + { + "command": "codebook.showWikiOutput", + "when": "view == codebook.llmWiki && viewItem =~ /\\[show-output\\]/", + "group": "inline@4" + }, + { + "command": "codebook.showWikiOutput", + "when": "view == codebook.llmWiki && viewItem =~ /\\[show-output\\]/", + "group": "llmWiki@4" + }, + { + "command": "codebook.generateDeepWiki", + "when": "view == codebook.llmWiki && viewItem =~ /\\[try-again\\]/", + "group": "inline@5" + }, + { + "command": "codebook.generateDeepWiki", + "when": "view == codebook.llmWiki && viewItem =~ /\\[try-again\\]/", + "group": "llmWiki@5" + }, + { + "command": "codebook.llmWiki.changeLocation", + "when": "view == codebook.llmWiki && viewItem =~ /\\[change-location\\]/", + "group": "inline@6" + }, + { + "command": "codebook.llmWiki.changeLocation", + "when": "view == codebook.llmWiki && viewItem =~ /\\[change-location\\]/", + "group": "llmWiki@6" + }, + { + "command": "codebook.copyWikiRecoveryRequest", + "when": "view == codebook.llmWiki && viewItem =~ /\\[copy-recovery\\]/", + "group": "inline@7" + }, + { + "command": "codebook.copyWikiRecoveryRequest", + "when": "view == codebook.llmWiki && viewItem =~ /\\[copy-recovery\\]/", + "group": "llmWiki@7" + }, + { + "command": "codebook.revealWikiFolder", + "when": "view == codebook.llmWiki && viewItem =~ /\\[reveal-folder\\]/", + "group": "inline@8" + }, + { + "command": "codebook.revealWikiFolder", + "when": "view == codebook.llmWiki && viewItem =~ /\\[reveal-folder\\]/", + "group": "llmWiki@8" + }, + { + "command": "codebook.refreshLlmWiki", + "when": "view == codebook.llmWiki && viewItem =~ /\\[refresh\\]/", + "group": "inline@9" + }, + { + "command": "codebook.refreshLlmWiki", + "when": "view == codebook.llmWiki && viewItem =~ /\\[refresh\\]/", + "group": "llmWiki@9" + }, + { + "command": "codebook.llmWiki.openFolder", + "when": "view == codebook.llmWiki && viewItem =~ /\\[open-folder\\]/", + "group": "inline@10" + }, + { + "command": "codebook.llmWiki.openFolder", + "when": "view == codebook.llmWiki && viewItem =~ /\\[open-folder\\]/", + "group": "llmWiki@10" + }, + { + "command": "codebook.llmWiki.manageTrust", + "when": "view == codebook.llmWiki && viewItem =~ /\\[manage-trust\\]/", + "group": "inline@11" + }, + { + "command": "codebook.llmWiki.manageTrust", + "when": "view == codebook.llmWiki && viewItem =~ /\\[manage-trust\\]/", + "group": "llmWiki@11" } ] } diff --git a/extension/src/assets/learning.svg b/extension/src/assets/learning.svg new file mode 100644 index 000000000..7ff169649 --- /dev/null +++ b/extension/src/assets/learning.svg @@ -0,0 +1 @@ + diff --git a/extension/src/extension.ts b/extension/src/extension.ts index ffdd864d7..8aa175ad4 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -24,6 +24,8 @@ import { GENERATE_CONFIRM_ACTION, GENERATE_DEEP_WIKI_COMMAND, GENERATE_WIKI_ACTION, + LLM_WIKI_POLL_MS, + LLM_WIKI_REFRESH_DEBOUNCE_MS, OPEN_WIKI_IN_BROWSER_ACTION, PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE, @@ -102,6 +104,7 @@ import { detectWikiPublishConflicts } from './utils/detectWikiPublishConflicts' import { endOfWikiChatStart } from './utils/endOfWikiChatStart' import { failureOfWikiChatReplyEnd } from './utils/failureOfWikiChatReplyEnd' import { findGeneratedWikiProblem } from './utils/findGeneratedWikiProblem' +import { findNearestExistingAncestor } from './utils/findNearestExistingAncestor' import { findUnacknowledgedWikiLeftover } from './utils/findUnacknowledgedWikiLeftover' import { findWikiUpdateStartBlock } from './utils/findWikiUpdateStartBlock' import { @@ -113,14 +116,17 @@ import { formatCodebookDiagnostic, type CodebookDiagnostic, } from './utils/formatCodebookDiagnostic' +import { hasWikiPublishEndedWait } from './utils/hasWikiPublishEndedWait' import { inspectDeepWikiFreshness } from './utils/inspectDeepWikiFreshness' import type { WikiLeftover } from './utils/isAcknowledgedWikiLeftover' import { isWikiManifestOf } from './utils/isWikiManifestOf' import { joinInFlight } from './utils/joinInFlight' import { listWikiCandidates } from './utils/listWikiCandidates' +import { createLlmWikiView } from './utils/llmWikiView' import { mayWikiChatStillPublish } from './utils/mayWikiChatStillPublish' import { noticeWithSetupStep } from './utils/noticeWithSetupStep' import { readLiveWikiUpdateMarker } from './utils/readLiveWikiUpdateMarker' +import { readLlmWikiViewFacts } from './utils/readLlmWikiViewFacts' import { readWikiFileToOpen } from './utils/readWikiFileToOpen' import { readWikiPublicationIdentity } from './utils/readWikiPublicationIdentity' import { readWikiPublishToken } from './utils/readWikiPublishToken' @@ -130,6 +136,7 @@ import { refreshCursorMcpCatalog, removeOwnedCursorMcpCatalog, } from './utils/refreshCursorMcpCatalog' +import { registerLlmWikiCommands } from './utils/registerLlmWikiCommands' import { registerPlainCommand } from './utils/registerPlainCommand' import { requestCursorWikiText } from './utils/requestCursorWikiText' import { resolveDeepWikiDirectory } from './utils/resolveDeepWikiDirectory' @@ -295,6 +302,8 @@ export function activate( /** The output of the latest run's record, and whether the editor stored it; {@link dropWikiReservation} waits for it at shutdown before removing the reload's fallback reservation. */ let lastWikiRunWrite: { outputDirectory: string; written: Promise } | undefined + /** How the Cursor agent of this window's last run exited; the LLM Wiki view words a stopped generation's cause from it. */ + let lastWikiAgentExit: WikiAgentExit | undefined // This window's id in the marker of a running update; another window's live marker refuses a start here. const wikiWindowId = randomUUID() // The reservation each of this window's stopped VS Code watches left on disk for other windows, by Wiki output, with the timer that removes it at that watch's bound. @@ -1401,6 +1410,7 @@ export function activate( hasNewPublication: false, }), ) + lastWikiAgentExit = run.agentExit wikiUpdateRun.end(end) const endedAtMs = Date.now() // Stopping a VS Code watch does not stop the chat, and neither does a watch that broke off or a request Codebook left in the chat at the 30 seconds; a Cursor agent is stopped with its run, and a closing window takes its chat with it. A chat whose publish is on disk has done its work, also when the watch was stopped while it read that publish or just before its next look: it leaves no chat that may still publish and no reservation. @@ -2557,6 +2567,22 @@ export function activate( }, }), ) + /** Stops this window's run, or tells the learner why there is nothing to stop; called by Stop LLM Wiki Run and by the LLM Wiki view's Stop watching and Cancel. @example cancelWikiRun() */ + const cancelWikiRun = (): void => { + if (activeWikiRun) { + activeWikiRun.controller.abort() + return + } + // A run that is still starting has nothing to stop yet; the learner is told so instead of "nothing is running". + if (wikiUpdate) { + void showNotice('information', WIKI_RUN_STARTING_SENTENCE) + return + } + void showNotice( + 'information', + 'No LLM Wiki generation or update is running in this window.', + ) + } context.subscriptions.push( registerCommand('codebook.startAgentServer', start), registerCommand('codebook.stopAgentServer', stopOnRequest), @@ -2810,21 +2836,7 @@ export function activate( } }), // Stops this window's run, generation or update: a VS Code watch, also while it still waits for the chat's first sign, or the Cursor agent. The chat itself is not stopped. - registerCommand(CANCEL_WIKI_RUN_COMMAND, () => { - if (activeWikiRun) { - activeWikiRun.controller.abort() - return - } - // A run that is still starting has nothing to stop yet; the learner is told so instead of "nothing is running". - if (wikiUpdate) { - void showNotice('information', WIKI_RUN_STARTING_SENTENCE) - return - } - void showNotice( - 'information', - 'No LLM Wiki generation or update is running in this window.', - ) - }), + registerCommand(CANCEL_WIKI_RUN_COMMAND, cancelWikiRun), registerCommand(SHOW_WIKI_OUTPUT_COMMAND, () => { diagnostics.show() }), @@ -2908,6 +2920,93 @@ export function activate( replacingReaders = settled }), ) + // The LLM Wiki view: rows from the Wiki folder, the run state and the markers; an editor without tree views shows none. + if (Reflect.has(vscode.window ?? {}, 'createTreeView')) { + const llmWikiView = createLlmWikiView({ + editor: vscode, + host: isCursorHost ? 'cursor' : 'vscode', + debounceMs: LLM_WIKI_REFRESH_DEBOUNCE_MS, + pollMs: LLM_WIKI_POLL_MS, + readFacts: () => + readLlmWikiViewFacts({ + host: isCursorHost ? 'cursor' : 'vscode', + isFolderOpen: () => + Boolean(vscode.workspace.workspaceFolders?.length), + isTrusted: () => vscode.workspace.isTrusted, + wikiLocation, + readPublishToken: readWikiPublishToken, + readPublication: readWikiPublicationIdentity, + findStartBlock: findWikiUpdateStartBlock, + readGeneration: async (projectRoot, outputDirectory) => { + const manifest = await readDeepWikiManifest( + outputDirectory, + projectRoot, + ) + const manifestStat = await lstat( + join(outputDirectory, WIKI_MANIFEST_FILE), + ).catch(() => undefined) + return { + revision: manifest.revision, + publishedAtMs: manifestStat?.mtimeMs, + } + }, + isWikiOwnedBy, + // A window id nobody has: this window's own reservation is seen as the stopped watch's too. + readMarker: (outputDirectory) => + readLiveWikiUpdateMarker(outputDirectory, '\u0000llm-wiki-view'), + ownWindowId: wikiWindowId, + isStartUnderWay: () => wikiUpdate !== undefined, + hasPublishEndedWait: hasWikiPublishEndedWait, + runStatus: () => wikiUpdateRun.status(), + isAwaitingChat: () => activeWikiRun?.awaitsChat === true, + lastAgentExit: () => lastWikiAgentExit, + now: () => Date.now(), + }), + readFreshness: async () => { + const { projectRoot, outputDirectory } = await wikiLocation() + const published = await inspectPublishedWiki( + projectRoot, + outputDirectory, + ) + if (!published) + return { + status: 'unknown', + reason: 'The manifest could not be read.', + } + return { + status: published.freshness.status, + reason: published.freshness.reason, + isAdditionUnchecked: published.freshness.addedFilesUnchecked === true, + } + }, + findWatchedFolder: findNearestExistingAncestor, + onRunChange: (listener) => wikiUpdateRun.onDidChange(listener), + tellPublished: (outputLabel) => + showWikiActionNotice( + 'information', + `An LLM Wiki was published in ${outputLabel}.`, + [OPEN_WIKI_IN_BROWSER_ACTION], + ), + recordFailure: (message, cause) => { + try { + output?.error(`${message}: ${thrownMessage(cause)}`) + } catch { + // A disposed channel has nowhere to write. + } + }, + }) + context.subscriptions.push( + { dispose: () => llmWikiView.dispose() }, + ...registerLlmWikiCommands({ + editor: vscode, + register: registerCommand, + showNotice, + view: llmWikiView, + cancelRun: cancelWikiRun, + findOutputDirectory: async () => (await wikiLocation()).outputDirectory, + }), + ) + } // Register lazy readers before restored captured documents or contributed views request their providers. workspace() repository() diff --git a/extension/src/tests/utils/wikiUpdateWindow.ts b/extension/src/tests/utils/wikiUpdateWindow.ts index fc8129044..42ff86fae 100644 --- a/extension/src/tests/utils/wikiUpdateWindow.ts +++ b/extension/src/tests/utils/wikiUpdateWindow.ts @@ -46,6 +46,26 @@ type FakeStatusBarEntry = { dispose(): void } +/** One row of the LLM Wiki view as the editor would draw it. */ +export type FakeLlmWikiRow = { + id: string + label: string + description?: string + tooltip?: string + iconId?: string + command?: { command: string; arguments?: unknown[] } + contextValue?: string +} + +/** The LLM Wiki view as the fake editor holds it: its rows, its message, its context keys and how often it fired a change. */ +export type FakeLlmWikiView = { + rows(): FakeLlmWikiRow[] + message(): string | undefined + contextKeys: Record + treeChangeCount(): number + setVisible(isVisible: boolean): void +} + type UriHandler = (uri: { path: string; query: string }) => Promise /** A document the fake editor reports as opened: only the address the extension looks at. */ @@ -161,6 +181,12 @@ export async function openWikiUpdateWindow(options: { message: string, choices: string[], ) => string | undefined + /** Whether the editor trusts the folder; true when omitted. */ + trusted?: boolean + /** Commands the editor lists besides the chat's (for example the id of its manage-trust command). */ + extraEditorCommands?: string[] + /** Makes the editor refuse the file watcher of the LLM Wiki view with this message. */ + wikiWatcherFails?: string }): Promise<{ chatRequests: Array<{ name: string; query: string }> executedCommands: string[] @@ -176,8 +202,22 @@ export async function openWikiUpdateWindow(options: { skillCalls: string[] /** Every line the extension wrote to Output › Codebook, as `info: …` or `error: …`. */ outputLines: string[] - /** Runs one of the extension's registered commands, as the Command Palette does. */ - runCommand(name: string): Promise + /** Runs one of the extension's registered commands with the arguments a menu or a row passes, as the Command Palette does. */ + runCommand(name: string, ...args: unknown[]): Promise + /** What the editor shows of the LLM Wiki view. */ + llmWiki: FakeLlmWikiView + /** Reports a file event the way the editor's watchers do. */ + reportFileEvent(kind: 'change' | 'create' | 'delete', fsPath: string): void + /** Says how many file watchers of the extension are alive. */ + liveWatcherCount(): number + /** The patterns every file watcher of the extension was created with. */ + watchedPatterns(): Array<{ base: string; pattern: string }> + /** What the extension copied to the clipboard. */ + clipboard: string[] + /** Changes whether the editor trusts the folder; trust is granted during a session, never revoked. */ + grantTrust(): void + /** Closes the folder: the window has none from now on. */ + closeFolder(): void visibleStatusBarTexts(): string[] looks: { candidateListings: number @@ -272,7 +312,26 @@ export async function openWikiUpdateWindow(options: { }, ) } - const commands = new Map Promise>() + const commands = new Map Promise>() + const fileWatching = createFakeFileWatching() + const clipboard: string[] = [] + const contextKeys: Record = {} + const configurationListeners: Array< + (event: { affectsConfiguration: (id: string) => boolean }) => void + > = [] + const trustListeners: Array<() => void> = [] + let isTrusted = options.trusted ?? true + let isFolderOpen = true + let treeProvider: + | { + getChildren(): string[] + getTreeItem(id: string): Record + onDidChangeTreeData(listener: () => void): { dispose(): void } + } + | undefined + let treeMessage: string | undefined + let treeChangeCount = 0 + const visibilityListeners: Array<(event: { visible: boolean }) => void> = [] const holds = new Map< HeldUpdateStep, { reached(): void; released: Promise } @@ -372,7 +431,10 @@ export async function openWikiUpdateWindow(options: { StatusBarAlignment: { Left: 1, Right: 2 }, RelativePattern: FakeRelativePattern, commands: { - registerCommand(name: string, action: () => Promise) { + registerCommand( + name: string, + action: (...args: unknown[]) => Promise, + ) { commands.set(name, action) return { dispose(): void {} } }, @@ -383,10 +445,20 @@ export async function openWikiUpdateWindow(options: { 'workbench.action.chat.newChat', 'workbench.action.chat.cancel', 'workbench.action.chat.removeAllPendingRequests', + ...(options.extraEditorCommands ?? []), ].filter( (command) => !options.missingEditorCommands?.includes(command), ), - executeCommand: async (name: string, argument?: unknown) => { + executeCommand: async ( + name: string, + argument?: unknown, + value?: unknown, + ) => { + // A context key is the editor's own bookkeeping, not a command the learner ran. + if (name === 'setContext') { + contextKeys[String(argument)] = value + return undefined + } // Only the chat command that carries a query is the request; without one it reveals the chat. const query = typeof argument === 'object' && @@ -424,6 +496,21 @@ export async function openWikiUpdateWindow(options: { workspace: { getConfiguration: () => ({ get: () => outputSetting }), onDidChangeWorkspaceFolders: () => ({ dispose(): void {} }), + onDidChangeConfiguration: ( + listener: (event: { + affectsConfiguration: (id: string) => boolean + }) => void, + ) => listenTo(configurationListeners, listener), + onDidGrantWorkspaceTrust: (listener: () => void) => + listenTo(trustListeners, listener), + get isTrusted() { + return isTrusted + }, + get workspaceFolders() { + return isFolderOpen + ? [{ uri: { scheme: 'file', fsPath: openedWorkspace } }] + : undefined + }, onDidOpenTextDocument: ( listener: (document: FakeTextDocument) => void, ) => { @@ -432,15 +519,55 @@ export async function openWikiUpdateWindow(options: { return listenTo(documentOpenListeners, listener) }, // The opened Wiki watches the project's files; these tests report no file event. - createFileSystemWatcher: - createFakeFileWatching().createFileSystemWatcher, + createFileSystemWatcher: (pattern: FakeRelativePattern) => { + // The LLM Wiki view's own watcher can be refused; the opened Wiki's watches the project. + if ( + options.wikiWatcherFails !== undefined && + pattern.pattern !== '**' + ) + throw new Error(options.wikiWatcherFails) + return fileWatching.createFileSystemWatcher(pattern) + }, + }, + EventEmitter: class { + readonly #listeners: Array<(value: unknown) => void> = [] + readonly event = (listener: (value: unknown) => void) => + listenTo(this.#listeners, listener) + fire(value?: unknown): void { + for (const listener of [...this.#listeners]) listener(value) + } + dispose(): void {} + }, + TreeItem: class { + id?: string + description?: string + tooltip?: string + iconPath?: unknown + contextValue?: string + command?: unknown + constructor( + readonly label: string, + readonly collapsibleState?: number, + ) {} }, + ThemeIcon: class { + constructor(readonly id: string) {} + }, + TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, env: { ...(options.cursor ? { appName: 'Cursor' } : {}), asExternalUri: keepLocalAddress, openExternal: async () => true, + clipboard: { + writeText: async (text: string) => { + clipboard.push(text) + }, + }, + }, + Uri: { + parse: parseFakeUri, + file: (fsPath: string) => ({ scheme: 'file', fsPath }), }, - Uri: { parse: parseFakeUri }, window: { registerUriHandler: (handler: { handleUri: UriHandler }) => { handleUri = handler.handleUri @@ -484,6 +611,29 @@ export async function openWikiUpdateWindow(options: { onDidStartTerminalShellExecution: ( listener: (event: FakeShellExecution) => void, ) => listenTo(terminalCommandListeners, listener), + createTreeView: ( + _id: string, + treeOptions: { + treeDataProvider: NonNullable + }, + ) => { + treeProvider = treeOptions.treeDataProvider + treeProvider.onDidChangeTreeData(() => { + treeChangeCount += 1 + }) + return { + get message() { + return treeMessage + }, + set message(next: string | undefined) { + treeMessage = next + }, + onDidChangeVisibility: ( + listener: (event: { visible: boolean }) => void, + ) => listenTo(visibilityListeners, listener), + dispose(): void {}, + } + }, // A real editor always has both, so every test runs the product's own way of showing a run; only collecting their Cancel is optional. withProgress, createStatusBarItem, @@ -680,10 +830,48 @@ export async function openWikiUpdateWindow(options: { statusBarTexts, skillCalls, outputLines, - async runCommand(name: string): Promise { + async runCommand(name: string, ...args: unknown[]): Promise { const command = commands.get(name) if (!command) throw new Error(`The window registered no ${name}.`) - await command() + await command(...args) + }, + llmWiki: { + rows: () => + (treeProvider?.getChildren() ?? []).map((id) => { + const item = treeProvider?.getTreeItem(id) ?? {} + const icon = item.iconPath + return { + id, + label: String(item.label), + description: item.description as string | undefined, + tooltip: item.tooltip as string | undefined, + iconId: + typeof icon === 'object' && icon !== null && 'id' in icon + ? String(icon.id) + : undefined, + command: item.command as FakeLlmWikiRow['command'], + contextValue: item.contextValue as string | undefined, + } + }), + message: () => treeMessage, + contextKeys, + treeChangeCount: () => treeChangeCount, + setVisible(isVisible: boolean): void { + for (const listener of [...visibilityListeners]) + listener({ visible: isVisible }) + }, + }, + reportFileEvent: (kind, fsPath): void => fileWatching.report(kind, fsPath), + liveWatcherCount: () => fileWatching.liveWatcherCount(), + watchedPatterns: () => + fileWatching.watched.map(({ base, pattern }) => ({ base, pattern })), + clipboard, + grantTrust(): void { + isTrusted = true + for (const listener of [...trustListeners]) listener() + }, + closeFolder(): void { + isFolderOpen = false }, visibleStatusBarTexts: (): string[] => statusBarEntries @@ -728,6 +916,10 @@ export async function openWikiUpdateWindow(options: { }, setOutputSetting(value: string): void { outputSetting = value + for (const listener of [...configurationListeners]) + listener({ + affectsConfiguration: (id) => id === 'codebook.wikiOutputDirectory', + }) }, setFreshness(next: WikiFreshness): void { freshness = next From 4a0881c98b3f5212e0b3f1168c1dfa7e33303b63 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Mon, 26 Oct 2026 18:50:53 +0900 Subject: [PATCH 04/18] feat: Cycle 0055 pt2: tests for the LLM Wiki view's one row function Refs #132 --- .../src/tests/readLlmWikiViewState.test.ts | 583 ++++++++++++++++++ 1 file changed, 583 insertions(+) create mode 100644 extension/src/tests/readLlmWikiViewState.test.ts diff --git a/extension/src/tests/readLlmWikiViewState.test.ts b/extension/src/tests/readLlmWikiViewState.test.ts new file mode 100644 index 000000000..0f59f0c52 --- /dev/null +++ b/extension/src/tests/readLlmWikiViewState.test.ts @@ -0,0 +1,583 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { + readLlmWikiViewState, + type LlmWikiViewFacts, + type LlmWikiViewWording, +} from '../utils/readLlmWikiViewState' + +type ReadyFacts = Extract + +const START_MS = Date.UTC(2026, 9, 26, 12, 0, 0) +const MINUTE_MS = 60_000 +const wording: LlmWikiViewWording = { + formatTime: (epochMs) => new Date(epochMs).toISOString().slice(11, 16), + formatDate: () => 'Oct 26, 2026', +} + +/** A ready window twelve minutes after the run started, in VS Code, with no Wiki and no run. */ +function ready(overrides: Partial = {}): ReadyFacts { + return { + kind: 'ready', + host: 'vscode', + repositoryName: 'nextjs-dashboard', + outputLabel: '.codebook/wiki', + outputDirectory: '/repo/.codebook/wiki', + nowMs: START_MS + 12 * MINUTE_MS, + ...overrides, + } +} + +const publishedWiki: ReadyFacts['wiki'] = { + freshness: { status: 'current' }, + generation: { + revision: '1936e41913729663cc99081e3224db89bb2d8e56', + publishedAtMs: START_MS, + }, +} +const generationRun: ReadyFacts['run'] = { + kind: 'generate', + startedAtMs: START_MS, + timeoutMs: 3_300_000, + isAwaitingChat: false, +} + +/** The ids of the rows of a model, in order. */ +const idsOf = (facts: LlmWikiViewFacts): string[] => + readLlmWikiViewState(facts, wording).rows.map((row) => row.id) + +// Value: protects=AC 2 (folder and trust rows); fails_when=a window without a folder shows no way to open one, or an untrusted folder shows no way to manage trust; no_claim=what the commands do; seam=the pure row function +test('a window without a folder offers Open Folder and an untrusted folder offers Manage Workspace Trust', () => { + // Arrange + const noFolder: LlmWikiViewFacts = { kind: 'no-folder' } + const untrusted: LlmWikiViewFacts = { kind: 'untrusted' } + + // Act + const rows = [noFolder, untrusted].map( + (facts) => readLlmWikiViewState(facts, wording).rows[0], + ) + + // Assert + assert.deepEqual( + rows.map((row) => [row?.id, row?.label, row?.actions]), + [ + ['folder', 'Open a folder to use the LLM Wiki', ['open-folder']], + ['trust', 'Trust this folder to use the LLM Wiki', ['manage-trust']], + ], + ) +}) + +// Value: protects=AC 1 (empty state shows the welcome); fails_when=a folder with no Wiki and no run returns a row or leaves state other, so the welcome content never shows; no_claim=the welcome text (manifest test); seam=the pure row function +test('a folder with no Wiki and no run has no rows and state none so the welcome content shows', () => { + // Arrange + const facts = ready() + + // Act + const model = readLlmWikiViewState(facts, wording) + + // Assert + assert.deepEqual(model, { state: 'none', rows: [] }) +}) + +// Value: protects=AC 2 row 1; fails_when=the Wiki row loses its label, the reader's freshness wording, the generation tooltip, the open command or the Update action; no_claim=the browser opening; seam=the pure row function +test('a published Wiki shows its folder name, the reader freshness label, its generation and opens in the browser', () => { + // Arrange + const facts = ready({ wiki: publishedWiki }) + + // Act + const model = readLlmWikiViewState(facts, wording) + + // Assert + assert.deepEqual(model.rows[0], { + id: 'wiki', + label: 'nextjs-dashboard Wiki', + description: 'Wiki source current', + tooltip: + 'Generated from 1936e41 on Oct 26, 2026\nClick to open the Wiki in the browser.', + icon: 'book', + command: { command: 'codebook.openDeepWiki' }, + actions: ['update'], + }) + assert.equal(model.state, 'other') +}) + +// Value: protects=AC 2 (row 1 description takes each freshness label); fails_when=a freshness answer gets another wording than the reader page; no_claim=the freshness check; seam=the pure row function +test('the Wiki row takes the checking, changed, unknown and no-Git freshness labels', () => { + // Arrange + const descriptionFor = ( + freshness: NonNullable['freshness'], + ) => + readLlmWikiViewState( + ready({ wiki: { ...publishedWiki, freshness } }), + wording, + ).rows[0]?.description + + // Act + const descriptions = [ + descriptionFor({ status: 'checking' }), + descriptionFor({ status: 'changed' }), + descriptionFor({ status: 'unknown', reason: 'Git is not installed.' }), + descriptionFor({ status: 'current', isAdditionUnchecked: true }), + ] + + // Assert + assert.deepEqual(descriptions, [ + 'Checking freshness…', + 'Source changed · Update available', + 'Wiki freshness unknown: Git is not installed.', + 'Wiki source current (no Git revision recorded, so added files are not checked)', + ]) +}) + +// Value: protects=AC 10 (accepted X1), the four forms of the generation line; fails_when=a form names the wrong revision or date, keeps a short non-commit revision, or shows a line when neither is known; no_claim=the manifest read; seam=the pure row function +test('the Wiki row tooltip names the generation in each of its four forms', () => { + // Arrange + const tooltipFor = ( + generation: NonNullable['generation'], + ) => + readLlmWikiViewState( + ready({ wiki: { freshness: { status: 'current' }, generation } }), + wording, + ).rows[0]?.tooltip?.split('\n')[0] + const revision = '1936e41913729663cc99081e3224db89bb2d8e56' + + // Act + const lines = [ + tooltipFor({ revision, publishedAtMs: START_MS }), + tooltipFor({ publishedAtMs: START_MS }), + tooltipFor({ revision: 'main', publishedAtMs: START_MS }), + tooltipFor({ revision }), + tooltipFor({}), + ] + + // Assert + assert.deepEqual(lines, [ + 'Generated from 1936e41 on Oct 26, 2026', + 'Generated on Oct 26, 2026 (no Git revision)', + 'Generated on Oct 26, 2026 (no Git revision)', + 'Generated from 1936e41', + 'Click to open the Wiki in the browser.', + ]) +}) + +// Value: protects=AC 2 (row 1 stays beside row 2 and a published Wiki can be read during an update); fails_when=the Wiki row disappears during a run, or still offers Update beside it; no_claim=the update's own refusal; seam=the pure row function +test('the Wiki row stays beside a running update and hides its Update action', () => { + // Arrange + const facts = ready({ + wiki: publishedWiki, + run: { ...generationRun, kind: 'update', timeoutMs: 900_000 }, + }) + + // Act + const model = readLlmWikiViewState(facts, wording) + + // Assert + assert.deepEqual( + model.rows.map((row) => [row.id, row.actions]), + [ + ['wiki', []], + ['run', ['stop-watching', 'open-chat', 'show-output']], + ['output', ['change-location']], + ], + ) +}) + +// Value: protects=AC 2 row 2 in VS Code; fails_when=the run row loses Stop watching, Open Chat or Show Output as inline actions, shows them as its click, or words the chat progress wrongly; no_claim=the status bar entry; seam=the pure row function +test('a VS Code generation row shows the chat progress and carries Stop watching, Open Chat and Show Output', () => { + // Arrange + const facts = ready({ run: generationRun }) + + // Act + const row = readLlmWikiViewState(facts, wording).rows[0] + + // Assert + assert.deepEqual(row, { + id: 'run', + label: 'Generating…', + description: 'In chat · 12 min of 55', + tooltip: + 'Codebook is watching the chat for the new Wiki. Stop watching ends Codebook’s watch, not the chat.', + icon: 'sync~spin', + actions: ['stop-watching', 'open-chat', 'show-output'], + }) +}) + +// Value: protects=AC 2 row 2 before the first sign; fails_when=a VS Code run that waits for the chat shows the minutes instead of the waiting text; no_claim=the first-sign detection; seam=the pure row function +test('a VS Code run that waits for the chat reads Waiting for the chat to start', () => { + // Arrange + const facts = ready({ run: { ...generationRun, isAwaitingChat: true } }) + + // Act + const description = readLlmWikiViewState(facts, wording).rows[0]?.description + + // Assert + assert.equal(description, 'Waiting for the chat to start') +}) + +// Value: protects=AC 2 row 2 in Cursor; fails_when=the Cursor run offers Open Chat or Stop watching, or does not name the agent; no_claim=the agent process; seam=the pure row function +test('a Cursor update row names cursor-agent and carries Cancel and Show Output only', () => { + // Arrange + const facts = ready({ + host: 'cursor', + run: { ...generationRun, kind: 'update', timeoutMs: 900_000 }, + }) + + // Act + const model = readLlmWikiViewState(facts, wording) + + // Assert + assert.deepEqual( + [model.rows[0]?.label, model.rows[0]?.description, model.rows[0]?.actions], + ['Updating…', 'cursor-agent · 12 min of 15', ['cancel-run', 'show-output']], + ) + assert.equal(model.message, undefined) +}) + +// Value: protects=the TreeView.message while row 2 or 2c shows in VS Code; fails_when=the wrapping message is missing in VS Code during a watch, or shows in Cursor or without a run; no_claim=the editor's wrapping; seam=the pure row function +test('the watching message shows for a VS Code run and a reservation and never in Cursor', () => { + // Arrange + const reservation: ReadyFacts['reservation'] = { + kind: 'generate', + host: 'vscode', + startedAtMs: START_MS, + retryAtMs: START_MS + 55 * MINUTE_MS, + } + const messageOf = (facts: ReadyFacts) => + readLlmWikiViewState(facts, wording).message + + // Act + const messages = [ + messageOf(ready({ run: generationRun })), + messageOf(ready({ reservation })), + messageOf(ready({ host: 'cursor', run: generationRun })), + messageOf(ready({ wiki: publishedWiki })), + ] + + // Assert + assert.deepEqual(messages, [ + 'Codebook is watching the chat. Stop watching does not stop the chat; stop it in the chat.', + 'Codebook is watching the chat. Stop watching does not stop the chat; stop it in the chat.', + undefined, + undefined, + ]) +}) + +// Value: protects=AC 2 row 2b; fails_when=another window's run is not named by kind and start time, or offers an action; no_claim=marker liveness; seam=the pure row function +test('another window’s update, generation and half-written marker are named with their start time and offer no action', () => { + // Arrange + const rowFor = (elsewhere: ReadyFacts['elsewhere']) => + readLlmWikiViewState(ready({ elsewhere }), wording).rows[0] + + // Act + const rows = [ + rowFor({ kind: 'update', startedAtMs: START_MS }), + rowFor({ kind: 'generate', startedAtMs: START_MS }), + rowFor({ startedAtMs: START_MS }), + ] + + // Assert + assert.deepEqual( + rows.map((row) => [row?.label, row?.description, row?.actions]), + [ + ['Updating in another window', 'since 12:00', []], + ['Generating in another window', 'since 12:00', []], + ['Starting in another window', 'since 12:00', []], + ], + ) +}) + +// Value: protects=AC 2 row 2c; fails_when=a stopped watch's reservation does not show the retry time and the minutes left, its start in the tooltip or Open Chat; no_claim=the reservation's bound; seam=the pure row function +test('a stopped watch’s reservation reads Waiting for chat with its retry time and offers Open Chat', () => { + // Arrange + const facts = ready({ + reservation: { + kind: 'generate', + host: 'vscode', + startedAtMs: START_MS, + retryAtMs: START_MS + 55 * MINUTE_MS, + }, + }) + + // Act + const row = readLlmWikiViewState(facts, wording).rows[0] + + // Assert + assert.deepEqual(row, { + id: 'reservation', + label: 'Waiting for chat', + description: 'retry after 12:55 (about 43 min)', + tooltip: + 'Generating started at 12:00 in VS Code. Open Chat opens the chat view; find the session in the chat history to stop it. Codebook allows a new start after 12:55 even if the chat stopped earlier.', + icon: 'clock', + actions: ['open-chat'], + }) +}) + +// Value: protects=AC 2 row 3 and AC 4; fails_when=a generation that ended without a Wiki hides its short cause, or the cause is worded wrongly for each ending; no_claim=the full sentence (host tests); seam=the pure row function +test('the last attempt row words each ending of a generation in a few words and offers Try again', () => { + // Arrange + const sentence = 'The generation stopped.' + const descriptionFor = ( + stopped: NonNullable, + ): [string | undefined, string[] | undefined] => { + const row = readLlmWikiViewState(ready({ lastAttempt: stopped }), wording) + .rows[0] + return [row?.description, row?.actions] + } + + // Act + const endings = [ + descriptionFor({ state: 'cancelled', host: 'vscode', sentence }), + descriptionFor({ state: 'cancelled', host: 'cursor', sentence }), + descriptionFor({ + state: 'timed-out', + host: 'cursor', + timeoutMs: 3_300_000, + sentence, + }), + descriptionFor({ state: 'failed', host: 'cursor', sentence }), + descriptionFor({ + state: 'failed', + host: 'cursor', + agentExitCode: 1, + sentence, + }), + descriptionFor({ state: 'conflict', host: 'vscode', sentence }), + ] + + // Assert + assert.deepEqual(endings, [ + ['stopped watching', ['try-again']], + ['cancelled', ['try-again']], + ['stopped after 55 minutes', ['try-again']], + ['no Wiki published', ['try-again']], + ['agent exit 1', ['try-again']], + ['files in the way', ['try-again']], + ]) +}) + +// Value: protects=AC 2 row 3 click; fails_when=the row click does not run the attention command with the row id, or the full sentence is lost; no_claim=the notice; seam=the pure row function +test('the last attempt row click shows its full sentence through the attention command', () => { + // Arrange + const facts = ready({ + lastAttempt: { + state: 'failed', + host: 'cursor', + sentence: 'Cursor Agent finished without publishing an LLM Wiki.', + }, + }) + + // Act + const row = readLlmWikiViewState(facts, wording).rows[0] + + // Assert + assert.deepEqual( + [row?.command, row?.detail], + [ + { command: 'codebook.showLlmWikiAttention', arguments: ['last-attempt'] }, + 'Cursor Agent finished without publishing an LLM Wiki.', + ], + ) +}) + +// Value: protects=AC 4 and the late publication (row 3 goes away); fails_when=the last attempt row stays beside a published Wiki, or Try again shows beside a run or an attention row; no_claim=the late-publication notice; seam=the pure row function +test('the last attempt row goes away when a Wiki is published and hides Try again beside a run or an attention row', () => { + // Arrange + const stopped: ReadyFacts['lastAttempt'] = { + state: 'cancelled', + host: 'vscode', + sentence: 'Stopped.', + } + + // Act + const ids = { + withWiki: idsOf(ready({ lastAttempt: stopped, wiki: publishedWiki })), + withRun: readLlmWikiViewState( + ready({ lastAttempt: stopped, run: generationRun }), + wording, + ).rows.find((row) => row.id === 'last-attempt')?.actions, + withAttention: readLlmWikiViewState( + ready({ + lastAttempt: stopped, + attention: { cause: 'held-publish-lock', sentence: 'Locked.' }, + }), + wording, + ).rows.find((row) => row.id === 'last-attempt')?.actions, + } + + // Assert + assert.deepEqual(ids, { + withWiki: ['wiki', 'output'], + withRun: [], + withAttention: [], + }) +}) + +const ATTENTION_CASES: Array<{ + cause: string + label: string + shortCause: string + actions: string[] + hidesWiki: boolean +}> = [ + { + cause: 'held-publish-lock', + label: 'Publish in progress or stopped', + shortCause: 'publish lock found', + actions: ['copy-recovery', 'refresh'], + hidesWiki: false, + }, + { + cause: 'unfinished-publish', + label: 'Publish in progress or stopped', + shortCause: 'unfinished publish', + actions: ['copy-recovery', 'refresh'], + hidesWiki: false, + }, + { + cause: 'edited-manifest', + label: 'Wiki manifest needs recovery', + shortCause: 'manifest edited', + actions: ['copy-recovery', 'refresh'], + hidesWiki: true, + }, + { + cause: 'unreadable-manifest', + label: 'Wiki manifest needs recovery', + shortCause: 'manifest unreadable', + actions: ['copy-recovery', 'refresh'], + hidesWiki: true, + }, + { + cause: 'not-a-folder', + label: 'Wiki folder belongs elsewhere', + shortCause: 'output is a file or link', + actions: ['change-location'], + hidesWiki: true, + }, + { + cause: 'linked-parent', + label: 'Wiki folder belongs elsewhere', + shortCause: 'link on the folder path', + actions: ['change-location'], + hidesWiki: true, + }, + { + cause: 'foreign-manifest', + label: 'Wiki folder belongs elsewhere', + shortCause: 'another checkout', + actions: ['change-location'], + hidesWiki: true, + }, + { + cause: 'learner-file', + label: 'Wiki files changed by hand', + shortCause: 'your file is in the folder', + actions: ['reveal-folder', 'refresh'], + hidesWiki: false, + }, + { + cause: 'special-file', + label: 'Wiki files changed by hand', + shortCause: 'link or special file', + actions: ['reveal-folder', 'refresh'], + hidesWiki: false, + }, + { + cause: 'edited-page', + label: 'Wiki files changed by hand', + shortCause: 'page edited by hand', + actions: ['reveal-folder', 'refresh'], + hidesWiki: false, + }, + { + cause: 'missing-page', + label: 'Wiki files changed by hand', + shortCause: 'page missing', + actions: ['reveal-folder', 'refresh'], + hidesWiki: false, + }, + { + cause: 'a-cause-added-later', + label: 'Wiki needs attention', + shortCause: 'see details', + actions: ['refresh'], + hidesWiki: false, + }, +] + +for (const attentionCase of ATTENTION_CASES) { + // Value: protects=AC 2 (each of the ten conflict causes, the foreign manifest and a later cause); fails_when=the cause loses its label, short cause, actions or its row-click sentence, or hides or keeps the Wiki row wrongly; no_claim=how the cause is found (host tests); seam=the pure row function + test(`the attention cause ${attentionCase.cause} shows ${attentionCase.label} with its sentence and actions`, () => { + // Arrange + const facts = ready({ + wiki: publishedWiki, + attention: { cause: attentionCase.cause, sentence: 'The full sentence.' }, + }) + + // Act + const model = readLlmWikiViewState(facts, wording) + + // Assert + const attention = model.rows.find((row) => row.id === 'attention') + assert.deepEqual( + { + label: attention?.label, + description: attention?.description, + actions: attention?.actions, + command: attention?.command, + detail: attention?.detail, + isWikiShown: model.rows.some((row) => row.id === 'wiki'), + }, + { + label: attentionCase.label, + description: attentionCase.shortCause, + actions: attentionCase.actions, + command: { + command: 'codebook.showLlmWikiAttention', + arguments: ['attention'], + }, + detail: 'The full sentence.', + isWikiShown: !attentionCase.hidesWiki, + }, + ) + }) +} + +// Value: protects=AC 2 (fixed order of rows and row 5's presence); fails_when=the rows come in another order, or the Output row shows in the empty state or lacks Change location and the watcher note; no_claim=the settings command; seam=the pure row function +test('every row shows in the fixed order and the Output row comes last with its Change location action and watcher note', () => { + // Arrange + const facts = ready({ + wiki: publishedWiki, + run: generationRun, + elsewhere: { kind: 'update', startedAtMs: START_MS }, + reservation: { + kind: 'generate', + host: 'vscode', + startedAtMs: START_MS, + retryAtMs: START_MS + MINUTE_MS, + }, + lastAttempt: { state: 'failed', host: 'vscode', sentence: 'Failed.' }, + attention: { cause: 'edited-page', sentence: 'Edited.' }, + isWatcherBroken: true, + }) + + // Act + const model = readLlmWikiViewState(facts, wording) + + // Assert + assert.deepEqual( + model.rows.map((row) => row.id), + ['wiki', 'run', 'elsewhere', 'reservation', 'attention', 'output'], + ) + assert.deepEqual(model.rows.at(-1), { + id: 'output', + label: 'Output: .codebook/wiki', + description: 'Change location', + tooltip: + '/repo/.codebook/wiki\nCodebook could not watch this folder, so the view refreshes when it is shown and with Refresh only.', + icon: 'folder', + actions: ['change-location'], + }) +}) From 57e19fdee0aa713ee5338db14dbf88604fa24217 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Mon, 26 Oct 2026 18:57:34 +0900 Subject: [PATCH 05/18] feat: Cycle 0055 pt2: host tests for the LLM Wiki view The watcher stands on the output's parent (the markers lie beside the output folder); the harness gains tree views, trust, folder, clipboard and file events. Refs #132 --- extension/src/tests/llmWikiView.test.ts | 1001 +++++++++++++++++ .../src/tests/utils/openProjectWithoutWiki.ts | 84 ++ extension/src/tests/utils/wikiUpdateWindow.ts | 71 +- extension/src/tests/wikiGenerateFlow.test.ts | 78 +- extension/src/utils/llmWikiView.ts | 15 +- 5 files changed, 1149 insertions(+), 100 deletions(-) create mode 100644 extension/src/tests/llmWikiView.test.ts create mode 100644 extension/src/tests/utils/openProjectWithoutWiki.ts diff --git a/extension/src/tests/llmWikiView.test.ts b/extension/src/tests/llmWikiView.test.ts new file mode 100644 index 000000000..8a6a50f3c --- /dev/null +++ b/extension/src/tests/llmWikiView.test.ts @@ -0,0 +1,1001 @@ +import assert from 'node:assert/strict' +import { + mkdtemp, + readFile, + rename, + rm, + symlink, + unlink, + utimes, + writeFile, +} from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { test, type TestContext } from 'node:test' + +import { waitUntil } from '../runtimeSupport/utils/waitUntil' + +import { + exists, + openProjectWithoutWiki, + publishGeneratedWiki, +} from './utils/openProjectWithoutWiki' +import { publishedWikiFixture } from './utils/publishedWikiFixture' +import { openWikiUpdateWindow } from './utils/wikiUpdateWindow' + +type Window = Awaited> + +const STATE_KEY = 'codebook.llmWiki.state' +const GENERATE_COMMAND = 'codebook.generateDeepWiki' +/** A debounce and a poll short enough to wait for. */ +const FAST = { LLM_WIKI_REFRESH_DEBOUNCE_MS: 40, LLM_WIKI_POLL_MS: 120 } +const WATCHED_ENDINGS = + '{/codebook-wiki.json,.update-run.json,.publish-lock.json,.publish-journal.json}' + +const idsOf = (window: Window): string[] => + window.llmWiki.rows().map((row) => row.id) +const rowOf = (window: Window, rowId: string) => + window.llmWiki.rows().find((row) => row.id === rowId) + +/** Waits until the view has read its state for the first time. */ +const untilFirstRead = (window: Window) => + waitUntil( + () => window.llmWiki.contextKeys[STATE_KEY] !== 'loading', + 'the first read of the LLM Wiki state', + 6_000, + ) +/** Waits until a row with this id is shown, or gone. */ +const untilRow = (window: Window, rowId: string, isShown = true) => + waitUntil( + () => (rowOf(window, rowId) !== undefined) === isShown, + `the ${rowId} row ${isShown ? 'to show' : 'to go'}`, + 6_000, + ) +/** Waits until the Wiki row's freshness check has answered. */ +const untilFreshness = (window: Window) => + waitUntil( + () => rowOf(window, 'wiki')?.description !== 'Checking freshness…', + 'the freshness label', + 6_000, + ) + +/** Opens a window on a project with a real published Wiki. */ +async function openPublishedWiki( + context: TestContext, + options: Partial[0]> = {}, + prepare: ( + wiki: Awaited>, + ) => Promise = async () => {}, +) { + const wiki = await publishedWikiFixture() + await prepare(wiki) + const window = await openWikiUpdateWindow({ + constants: FAST, + ...options, + projectRoot: wiki.projectRoot, + openReader: false, + }) + context.after(async () => { + await window.close() + await wiki.cleanup() + }) + await untilFirstRead(window) + return { wiki, window } +} + +/** Opens a window on a project with no Wiki, with the view's short timing. */ +async function openEmptyProject( + context: TestContext, + options: Parameters[0] = {}, +) { + const project = await openProjectWithoutWiki({ constants: FAST, ...options }) + context.after(() => project.close()) + await untilFirstRead(project) + return project +} + +// Value: protects=AC 1 and AC 2 (loading), the welcome content not showing before the first read; fails_when=the state key is not `loading` until the first read ends, or the tree shows no reading row; no_claim=the welcome text; seam=a start check held open +test('until its first read ends the view shows Reading the LLM Wiki state and the state key is loading, then none', async (context) => { + // Arrange + const project = await openProjectWithoutWiki({ + constants: FAST, + holdAtOpen: ['start-block'], + }) + context.after(() => project.close()) + + // Act + const whileLoading = { + key: project.llmWiki.contextKeys[STATE_KEY], + labels: project.llmWiki.rows().map((row) => row.label), + } + project.hold('start-block').release() + await untilFirstRead(project) + + // Assert + assert.deepEqual(whileLoading, { + key: 'loading', + labels: ['Reading the LLM Wiki state…'], + }) + assert.equal(project.llmWiki.contextKeys[STATE_KEY], 'none') +}) + +// Value: protects=AC 1, the empty state; fails_when=a folder with no Wiki returns a row, sets a TreeView message or leaves the state key other, so the welcome content with Generate LLM Wiki never shows; no_claim=the welcome text (manifest test); seam=the stubbed editor's tree view +test('a folder with no Wiki returns no rows and no message and sets the state key none, with the host key vscode', async (context) => { + // Arrange + const project = await openEmptyProject(context) + + // Act + const shown = { + rows: project.llmWiki.rows(), + message: project.llmWiki.message(), + state: project.llmWiki.contextKeys[STATE_KEY], + host: project.llmWiki.contextKeys['codebook.host'], + } + + // Assert + assert.deepEqual(shown, { + rows: [], + message: undefined, + state: 'none', + host: 'vscode', + }) +}) + +// Value: protects=AC 1, the welcome content chosen by host; fails_when=Cursor sets the VS Code host key, so Cursor's welcome shows the Copilot Chat sentence; no_claim=the welcome text; seam=the stubbed editor's context keys +test('Cursor sets the host key cursor', async (context) => { + // Arrange + const project = await openEmptyProject(context, { cursor: true }) + + // Act + const host = project.llmWiki.contextKeys['codebook.host'] + + // Assert + assert.equal(host, 'cursor') +}) + +// Value: protects=AC 2 row 1 and the loading label; fails_when=the Wiki row hides while the freshness check runs, never shows Checking freshness…, or opens anything but the browser Wiki; no_claim=the freshness check; seam=a freshness inspection held open +test('a published Wiki opens in the browser from its row and reads Checking freshness… until the check answers', async (context) => { + // Arrange + const { window } = await openPublishedWiki(context, { + holdAtOpen: ['freshness-inspection'], + }) + const whileChecking = rowOf(window, 'wiki') + + // Act + window.hold('freshness-inspection').release() + await untilFreshness(window) + + // Assert + assert.deepEqual( + [ + whileChecking?.description, + whileChecking?.command?.command, + rowOf(window, 'wiki')?.description, + idsOf(window), + window.llmWiki.contextKeys[STATE_KEY], + ], + [ + 'Checking freshness…', + 'codebook.openDeepWiki', + 'Source changed · Update available', + ['wiki', 'output'], + 'other', + ], + ) +}) + +// Value: protects=AC 10 (accepted X1) with a manifest the real publisher wrote; fails_when=the tooltip names no revision, or one that is not the first 7 characters of the manifest's revision; no_claim=the date's locale; seam=a Wiki published by the shipped publisher +test('the Wiki row tooltip names the first seven characters of the manifest revision and the date of the publication', async (context) => { + // Arrange + const { window } = await openPublishedWiki(context) + + // Act + const tooltip = rowOf(window, 'wiki')?.tooltip + + // Assert + assert.match(tooltip ?? '', /^Generated from a1b2c3d on .+\nClick to open/) +}) + +const ATTENTION_HOST_CASES: Array<{ + name: string + prepare: ( + wiki: Awaited>, + ) => Promise + label: string + shortCause: string + isWikiShown: boolean +}> = [ + { + name: 'edited-page', + prepare: async (wiki): Promise => + writeFile(join(wiki.outputDirectory, 'index.html'), '

Mine

'), + label: 'Wiki files changed by hand', + shortCause: 'page edited by hand', + isWikiShown: true, + }, + { + name: 'missing-page', + prepare: async (wiki): Promise => + rm(join(wiki.outputDirectory, 'index.html')), + label: 'Wiki files changed by hand', + shortCause: 'page missing', + isWikiShown: true, + }, + { + name: 'special-file', + prepare: async (wiki): Promise => + symlink(wiki.learnerFile, join(wiki.outputDirectory, 'shared.md')), + label: 'Wiki files changed by hand', + shortCause: 'link or special file', + isWikiShown: true, + }, + { + name: 'edited-manifest', + prepare: async (wiki): Promise => { + const manifestPath = join(wiki.outputDirectory, 'codebook-wiki.json') + await writeFile(manifestPath, `${await readFile(manifestPath, 'utf8')}\n`) + }, + label: 'Wiki manifest needs recovery', + shortCause: 'manifest edited', + isWikiShown: false, + }, + { + name: 'unreadable-manifest', + prepare: async (wiki): Promise => + writeFile( + join(wiki.outputDirectory, 'codebook-wiki.json'), + '{ "schemaVersion": 1, "pages": [', + ), + label: 'Wiki manifest needs recovery', + shortCause: 'manifest unreadable', + isWikiShown: false, + }, + { + name: 'unfinished-publish', + prepare: async (wiki): Promise => { + const journal = `${wiki.outputDirectory}.publish-journal.json` + await writeFile(journal, JSON.stringify({ operation: 'publish' })) + const longAgo = new Date(Date.now() - 3_600_000) + await utimes(journal, longAgo, longAgo) + }, + label: 'Publish in progress or stopped', + shortCause: 'unfinished publish', + isWikiShown: true, + }, + { + name: 'held-publish-lock', + prepare: async (wiki): Promise => { + const lock = `${wiki.outputDirectory}.publish-lock.json` + await writeFile(lock, JSON.stringify({ pid: 99_999_999 })) + const longAgo = new Date(Date.now() - 3_600_000) + await utimes(lock, longAgo, longAgo) + }, + label: 'Publish in progress or stopped', + shortCause: 'publish lock found', + isWikiShown: true, + }, + { + name: 'not-a-folder', + prepare: async (wiki): Promise => { + await rm(wiki.outputDirectory, { recursive: true }) + await writeFile(wiki.outputDirectory, 'a file where the folder was') + }, + label: 'Wiki folder belongs elsewhere', + shortCause: 'output is a file or link', + isWikiShown: false, + }, + { + name: 'linked-parent', + prepare: async (wiki): Promise => { + const movedAside = join(wiki.projectRoot, 'moved-codebook') + await rename(join(wiki.projectRoot, '.codebook'), movedAside) + await symlink(movedAside, join(wiki.projectRoot, '.codebook'), 'junction') + }, + label: 'Wiki folder belongs elsewhere', + shortCause: 'link on the folder path', + isWikiShown: false, + }, +] + +for (const attentionCase of ATTENTION_HOST_CASES) { + // Value: protects=AC 2, each start block the update start finds is a row of the view with its label and its Wiki row rule; fails_when=the cause shows another label, or hides the readable Wiki for a cause that leaves it readable, or keeps an unusable one; no_claim=the learner-file cause, which only a run's end finds (the pure matrix); seam=a Wiki published by the shipped publisher, then damaged on disk + test(`the view shows ${attentionCase.label} for a published Wiki with ${attentionCase.name}`, async (context) => { + // Arrange + const { window } = await openPublishedWiki( + context, + {}, + attentionCase.prepare, + ) + await untilRow(window, 'attention') + + // Act + const attention = rowOf(window, 'attention') + + // Assert + assert.deepEqual( + [ + attention?.label, + attention?.description, + rowOf(window, 'wiki') !== undefined, + ], + [ + attentionCase.label, + attentionCase.shortCause, + attentionCase.isWikiShown, + ], + ) + }) +} + +// Value: protects=AC 2 (the foreign manifest), a Wiki folder of another checkout; fails_when=a manifest naming another checkout shows the Wiki row or any label but the one that sends the learner to Change location; no_claim=the refusal sentences of Generate and Update; seam=a hand-written manifest of another repository +test('a manifest of another checkout shows Wiki folder belongs elsewhere with Change location and no Wiki row', async (context) => { + // Arrange + const project = await openProjectWithoutWiki({ constants: FAST }) + context.after(() => project.close()) + await publishGeneratedWiki( + project.outputDirectory, + project.projectRoot, + '/somewhere/else', + ) + await project.runCommand('codebook.refreshLlmWiki') + await untilRow(project, 'attention') + + // Act + const attention = rowOf(project, 'attention') + + // Assert + assert.deepEqual( + [ + attention?.label, + attention?.description, + attention?.contextValue, + idsOf(project), + ], + [ + 'Wiki folder belongs elsewhere', + 'another checkout', + '[change-location]', + ['attention', 'output'], + ], + ) +}) + +// Value: protects=AC 2 (the row click shows the cause's sentence with Copy details); fails_when=clicking the attention row shows another sentence than the update's, offers no Copy details, or copies the home folder instead of ~; no_claim=every cause's wording (describeWikiUpdate tests); seam=a hand-edited page +test('clicking the attention row shows the update’s sentence with the page it names and Copy details copies it', async (context) => { + // Arrange + const { window } = await openPublishedWiki( + context, + { chooseAction: (_message, actions) => actions[0] }, + async (wiki) => + writeFile(join(wiki.outputDirectory, 'index.html'), '

Mine

'), + ) + await untilRow(window, 'attention') + const click = rowOf(window, 'attention')?.command + + // Act + await window.runCommand(click?.command ?? '', ...(click?.arguments ?? [])) + await waitUntil( + () => window.clipboard.length > 0, + 'the copied details', + 6_000, + ) + + // Assert + assert.deepEqual( + [ + { command: click?.command, arguments: click?.arguments }, + window.warnings[0]?.includes('index.html'), + window.offeredActions[0]?.actions, + window.clipboard[0] === window.warnings[0]?.split(homedir()).join('~'), + ], + [ + { command: 'codebook.showLlmWikiAttention', arguments: ['attention'] }, + true, + ['Copy details'], + true, + ], + ) +}) + +// Value: protects=AC 2 (the folder and trust rows); fails_when=a window without a folder shows no Open Folder row, an untrusted folder shows no Manage Workspace Trust row, or trust granted later does not replace it; no_claim=the editor's own trust dialog; seam=the stubbed editor's trust and folder state +test('a window without a folder offers Open Folder and an untrusted folder offers Manage Workspace Trust until trust is granted', async (context) => { + // Arrange + const noFolder = await openWikiUpdateWindow({ + projectRoot: tmpdir(), + openReader: false, + noFolder: true, + constants: FAST, + }) + context.after(() => noFolder.close()) + const { window: untrusted } = await openPublishedWiki(context, { + trusted: false, + }) + const whileUntrusted = idsOf(untrusted) + + // Act + untrusted.grantTrust() + await untilRow(untrusted, 'wiki') + + // Assert + assert.deepEqual( + [ + noFolder.llmWiki + .rows() + .map((row) => [row.id, row.label, row.contextValue]), + whileUntrusted, + idsOf(untrusted), + ], + [ + [['folder', 'Open a folder to use the LLM Wiki', '[open-folder]']], + ['trust'], + ['wiki', 'output'], + ], + ) +}) + +// Value: protects=the manage-trust id decision; fails_when=the action runs an id the editor does not list, or runs none and says nothing; no_claim=that the id exists in Cursor (native trial); seam=the stubbed editor's command list +test('Manage Workspace Trust runs the first known trust command the editor lists, and says where to find it when it lists none', async (context) => { + // Arrange + const { window: listed } = await openPublishedWiki(context, { + trusted: false, + extraEditorCommands: ['workbench.action.manageTrust'], + }) + const { window: unlisted } = await openPublishedWiki(context, { + trusted: false, + }) + + // Act + await listed.runCommand('codebook.llmWiki.manageTrust') + await unlisted.runCommand('codebook.llmWiki.manageTrust') + + // Assert + assert.deepEqual( + [listed.executedCommands, unlisted.executedCommands, unlisted.notices], + [ + ['workbench.action.manageTrust'], + [], + [ + 'Run "Workspaces: Manage Workspace Trust" from the Command Palette to trust this folder.', + ], + ], + ) +}) + +// Value: protects=AC 2 (error row); fails_when=a read that throws leaves the tree empty or on loading, writes no cause to Output, or does not recover at Refresh; no_claim=the cause's wording; seam=a folder that no longer exists +test('a read that throws shows Could not read the LLM Wiki state, records the cause in Output and recovers at Refresh', async (context) => { + // Arrange + const project = await openEmptyProject(context) + const realRoot = project.projectRoot + project.openWorkspace(join(realRoot, 'does-not-exist')) + + // Act + await project.runCommand('codebook.refreshLlmWiki') + const errorRow = rowOf(project, 'error') + project.openWorkspace(realRoot) + await project.runCommand('codebook.refreshLlmWiki') + + // Assert + assert.deepEqual( + [ + errorRow?.label, + errorRow?.description, + errorRow?.command?.command, + project.outputLines.some((line) => + line.startsWith('error: The LLM Wiki view could not read its state: '), + ), + project.llmWiki.contextKeys[STATE_KEY], + ], + [ + 'Could not read the LLM Wiki state', + 'Refresh to retry', + 'codebook.showWikiOutput', + true, + 'none', + ], + ) +}) + +// Value: protects=AC 2 (refresh on a change of the Wiki files); fails_when=a manifest created on disk does not show the Wiki row after the watcher's event, an event of an unrelated file reads the state, or the view polls when no run row shows; no_claim=VS Code's real watcher; seam=the fake file watching +test('a manifest created in the output folder shows the Wiki row after the watcher event, and an unrelated file’s event reads nothing', async (context) => { + // Arrange + const project = await openEmptyProject(context) + const readsAtStart = project.looks.publishTokenReads + + // Act + await publishGeneratedWiki(project.outputDirectory, project.projectRoot) + project.reportFileEvent('create', join(project.projectRoot, 'notes.md')) + await new Promise((resolve) => setTimeout(resolve, 300)) + const idsBeforeTheEvent = idsOf(project) + const readsBeforeTheEvent = project.looks.publishTokenReads + project.reportFileEvent( + 'create', + join(project.outputDirectory, 'codebook-wiki.json'), + ) + await untilRow(project, 'wiki') + + // Assert + assert.deepEqual( + [idsBeforeTheEvent, readsBeforeTheEvent - readsAtStart], + [[], 0], + ) +}) + +// Value: protects=AC 2 (the trailing debounce); fails_when=a burst of events reads the state once per event, or the last event of the burst is dropped; no_claim=the 1 second value (a constant); seam=five events 10 ms apart, with the debounce shortened to 40 ms +test('five file events inside the debounce become one read that shows the last state', async (context) => { + // Arrange + const project = await openEmptyProject(context) + const readsAtStart = project.looks.publishTokenReads + await publishGeneratedWiki(project.outputDirectory, project.projectRoot) + const manifestPath = join(project.outputDirectory, 'codebook-wiki.json') + + // Act + for (let event = 0; event < 5; event += 1) { + project.reportFileEvent('change', manifestPath) + await new Promise((resolve) => setTimeout(resolve, 10)) + } + await untilRow(project, 'wiki') + await new Promise((resolve) => setTimeout(resolve, 250)) + + // Assert + assert.equal(project.looks.publishTokenReads - readsAtStart, 1) +}) + +// Value: protects=AC 2 (refresh on the setting and on visibility); fails_when=changing codebook.wikiOutputDirectory or showing the view again does not read the state, or the watcher stays on the old folder; no_claim=VS Code's settings UI; seam=the stubbed configuration event and visibility event +test('changing the output setting and showing the view again read the state again and move the watcher', async (context) => { + // Arrange + const { wiki, window } = await openPublishedWiki(context) + const patternsAtStart = window.watchedPatterns().length + await publishGeneratedWiki( + join(wiki.projectRoot, 'docs', 'wiki'), + wiki.projectRoot, + ) + + // Act + window.setOutputSetting('docs/wiki') + await waitUntil( + () => rowOf(window, 'output')?.label === 'Output: docs/wiki', + 'the new output folder', + 6_000, + ) + const watchedAfterTheSetting = window.watchedPatterns().at(-1) + await rm(join(wiki.projectRoot, 'docs'), { recursive: true }) + window.llmWiki.setVisible(true) + await untilRow(window, 'wiki', false) + + // Assert + assert.deepEqual( + [ + window.watchedPatterns().length > patternsAtStart, + watchedAfterTheSetting?.base, + watchedAfterTheSetting?.pattern, + window.llmWiki.contextKeys[STATE_KEY], + ], + [true, join(wiki.projectRoot, 'docs'), `wiki${WATCHED_ENDINGS}`, 'none'], + ) +}) + +// Value: protects=AC 2 (the 30 second poll for rows 2b); fails_when=a marker whose window died leaves a row for another window forever because no file event arrives; no_claim=the 30 second value; seam=a marker removed without a file event, the poll shortened to 120 ms +test('another window’s run row disappears at the next poll after its marker is removed without a file event', async (context) => { + // Arrange + const { wiki, window } = await openPublishedWiki( + context, + {}, + async (fixture) => { + await writeFile( + `${fixture.outputDirectory}.update-run.json`, + JSON.stringify({ + pid: process.pid, + windowId: 'the-other-window', + host: 'vscode', + startedAtMs: Date.now() - 60_000, + runKind: 'update', + }), + ) + }, + ) + const rowsWhileItRuns = idsOf(window) + + // Act + await unlink(`${wiki.outputDirectory}.update-run.json`) + await untilRow(window, 'elsewhere', false) + + // Assert + assert.deepEqual( + [rowsWhileItRuns, rowOf(window, 'wiki')?.contextValue, idsOf(window)], + [['wiki', 'elsewhere', 'output'], '[update]', ['wiki', 'output']], + ) +}) + +// Value: protects=AC 2 (an output outside the workspace); fails_when=an output folder outside the project shows no Wiki, or the label is not its absolute path; no_claim=the editor's watch outside the workspace; seam=an absolute output setting +test('an output folder outside the project shows its Wiki with the absolute folder as its label', async (context) => { + // Arrange + const outside = await mkdtemp(join(tmpdir(), 'codebook-outside-wiki-')) + context.after(() => rm(outside, { recursive: true, force: true })) + const project = await openProjectWithoutWiki({ + constants: FAST, + outputSetting: join(outside, 'wiki'), + }) + context.after(() => project.close()) + await publishGeneratedWiki(join(outside, 'wiki'), project.projectRoot) + + // Act + await project.runCommand('codebook.refreshLlmWiki') + + // Assert + assert.deepEqual( + [rowOf(project, 'output')?.label, project.watchedPatterns().at(-1)?.base], + [`Output: ${join(outside, 'wiki')}`, outside], + ) +}) + +// Value: protects=AC 2 (a missing output parent) and Eng decision 78; fails_when=the watcher stands on a folder that does not exist, or is not made again on the folder a run creates; no_claim=VS Code's watcher on the root; seam=a Cursor generation whose agent waits +test('in a fresh project the watcher stands on the project and moves to .codebook once a run makes it', async (context) => { + // Arrange + const project = await openEmptyProject(context, { + cursor: true, + cursorAgent: (_root, _prompt, signal) => + new Promise((_resolve, reject) => + signal?.addEventListener('abort', () => reject(new Error('stopped'))), + ), + }) + const watchedAtStart = project.watchedPatterns().at(-1) + + // Act + void project.runCommand(GENERATE_COMMAND) + await untilRow(project, 'run') + await waitUntil( + () => project.watchedPatterns().at(-1)?.base !== watchedAtStart?.base, + 'the watcher on .codebook', + 6_000, + ) + await project.runCommand('codebook.llmWiki.cancelRun') + + // Assert + assert.deepEqual( + [watchedAtStart, project.watchedPatterns().at(-1)], + [ + { + base: project.projectRoot, + pattern: `.codebook/wiki${WATCHED_ENDINGS}`, + }, + { + base: join(project.projectRoot, '.codebook'), + pattern: `wiki${WATCHED_ENDINGS}`, + }, + ], + ) +}) + +// Value: protects=AC 2 (an output folder deleted while the view is open); fails_when=the Wiki row stays after its folder is deleted, or the empty state does not come back; no_claim=the watcher's delete event of the real editor; seam=the fake delete event +test('an output folder deleted while the view is open brings back the empty state', async (context) => { + // Arrange + const { wiki, window } = await openPublishedWiki(context) + + // Act + await rm(wiki.outputDirectory, { recursive: true }) + window.reportFileEvent( + 'delete', + join(wiki.outputDirectory, 'codebook-wiki.json'), + ) + await untilRow(window, 'wiki', false) + + // Assert + assert.deepEqual( + [window.llmWiki.rows(), window.llmWiki.contextKeys[STATE_KEY]], + [[], 'none'], + ) +}) + +// Value: protects=AC 2 (a refresh with nothing new fires no tree change) and accessibility; fails_when=Refresh with an unchanged state fires a tree change, which a screen reader announces; no_claim=focus handling; seam=the tree view's change event count +test('Refresh with nothing new fires no tree change', async (context) => { + // Arrange + const { window } = await openPublishedWiki(context) + await untilFreshness(window) + const changesBefore = window.llmWiki.treeChangeCount() + + // Act + await window.runCommand('codebook.refreshLlmWiki') + await window.runCommand('codebook.refreshLlmWiki') + await new Promise((resolve) => setTimeout(resolve, 200)) + + // Assert + assert.equal(window.llmWiki.treeChangeCount() - changesBefore, 0) +}) + +// Value: protects=AC 2 (row 2 and the TreeView message in VS Code); fails_when=a VS Code generation shows no Generating row with Waiting for the chat to start, lacks Stop watching, Open Chat and Show Output as inline actions, shows them as the row click, or lacks the wrapping message; no_claim=the minutes (pure matrix); seam=a chat that never answers +test('a VS Code generation shows Generating… waiting for the chat, with its three inline actions and the watching message', async (context) => { + // Arrange + const project = await openEmptyProject(context, { + chatReply: () => new Promise(() => {}), + }) + + // Act + void project.runCommand(GENERATE_COMMAND) + await untilRow(project, 'run') + const run = rowOf(project, 'run') + + // Assert + assert.deepEqual( + [ + run?.label, + run?.description, + run?.contextValue, + run?.command, + project.llmWiki.message(), + project.llmWiki.contextKeys[STATE_KEY], + ], + [ + 'Generating…', + 'Waiting for the chat to start', + '[stop-watching][open-chat][show-output]', + undefined, + 'Codebook is watching the chat. Stop watching does not stop the chat; stop it in the chat.', + 'other', + ], + ) +}) + +// Value: protects=AC 2 (row 2c) and AC 4; fails_when=Stop watching leaves no Waiting for chat row with its retry time, or the message goes with the run row; no_claim=the reservation's bound (marker tests); seam=a chat that never answers, stopped through the view's own action +test('Stop watching in the view ends the generation and leaves a Waiting for chat row with the retry time', async (context) => { + // Arrange + const project = await openEmptyProject(context, { + chatReply: () => new Promise(() => {}), + }) + void project.runCommand(GENERATE_COMMAND) + await untilRow(project, 'run') + + // Act + await project.runCommand('codebook.llmWiki.stopWatching') + await untilRow(project, 'reservation') + + // Assert + assert.deepEqual( + [ + idsOf(project), + rowOf(project, 'reservation')?.label, + rowOf(project, 'reservation')?.description?.startsWith('retry after '), + rowOf(project, 'reservation')?.contextValue, + rowOf(project, 'last-attempt')?.description, + project.llmWiki.message(), + ], + [ + ['run', 'reservation', 'last-attempt', 'output'].filter( + (id) => id !== 'run', + ), + 'Waiting for chat', + true, + '[open-chat]', + 'stopped watching', + 'Codebook is watching the chat. Stop watching does not stop the chat; stop it in the chat.', + ], + ) +}) + +// Value: protects=the late-publication notice and AC 4 (a late publication shows as published, no run claims it); fails_when=a Wiki the chat publishes after Stop watching shows no notice with Open in Browser, leaves the reservation or the last-attempt row, or shows the notice twice; no_claim=the browser opening; seam=a publication written after the watch stopped, with the watcher event +test('a Wiki published after Stop watching shows the notice with Open in Browser and replaces the reservation and the last attempt', async (context) => { + // Arrange + const project = await openEmptyProject(context, { + chatReply: () => new Promise(() => {}), + }) + void project.runCommand(GENERATE_COMMAND) + await untilRow(project, 'run') + await project.runCommand('codebook.llmWiki.stopWatching') + await untilRow(project, 'reservation') + const noticesBefore = project.notices.length + + // Act + await new Promise((resolve) => setTimeout(resolve, 20)) + await publishGeneratedWiki(project.outputDirectory, project.projectRoot) + project.reportFileEvent( + 'create', + join(project.outputDirectory, 'codebook-wiki.json'), + ) + await untilRow(project, 'wiki') + await project.runCommand('codebook.refreshLlmWiki') + + // Assert + assert.deepEqual( + [ + idsOf(project), + project.notices.slice(noticesBefore), + project.offeredActions.at(-1), + ], + [ + ['wiki', 'output'], + ['An LLM Wiki was published in .codebook/wiki.'], + { + message: 'An LLM Wiki was published in .codebook/wiki.', + actions: ['Open in Browser'], + }, + ], + ) +}) + +// Value: protects=AC 2 row 3 and AC 4; fails_when=a Cursor generation whose agent exits 1 without publishing shows no Last generation stopped row with its cause and Try again, the click shows another sentence, or Try again starts nothing; no_claim=the end sentence's wording (generate flow tests); seam=an agent that fails with an exit code +test('a Cursor generation that ends with exit code 1 shows Last generation stopped, its sentence on click and Try again', async (context) => { + // Arrange + const project = await openEmptyProject(context, { + cursor: true, + cursorAgent: async () => { + throw Object.assign(new Error('The agent stopped.'), { + exitCode: 1, + exitSignal: null, + }) + }, + }) + await project.runCommand(GENERATE_COMMAND).catch(() => undefined) + await untilRow(project, 'last-attempt') + const row = rowOf(project, 'last-attempt') + + // Act + await project.runCommand( + row?.command?.command ?? '', + ...(row?.command?.arguments ?? []), + ) + + // Assert + assert.deepEqual( + [ + row?.label, + row?.description, + row?.contextValue, + project.warnings.length, + idsOf(project), + ], + [ + 'Last generation stopped', + 'agent exit 1', + '[try-again]', + 1, + ['last-attempt', 'output'], + ], + ) +}) + +// Value: protects=AC 2 (row 2 in Cursor, no Open Chat, no message); fails_when=a Cursor run shows Stop watching or Open Chat, the VS Code message, or no cursor-agent progress; no_claim=the agent's work; seam=a Cursor agent that waits for its abort +test('a Cursor generation shows cursor-agent progress with Cancel and Show Output and no watching message, and Cancel ends it', async (context) => { + // Arrange + const project = await openEmptyProject(context, { + cursor: true, + cursorAgent: (_root, _prompt, signal) => + new Promise((_resolve, reject) => + signal?.addEventListener('abort', () => reject(new Error('stopped'))), + ), + }) + void project.runCommand(GENERATE_COMMAND) + await untilRow(project, 'run') + const run = rowOf(project, 'run') + + // Act + await project.runCommand('codebook.llmWiki.cancelRun') + await untilRow(project, 'run', false) + + // Assert + assert.deepEqual( + [ + run?.description, + run?.contextValue, + project.llmWiki.message(), + rowOf(project, 'last-attempt')?.description, + ], + [ + 'cursor-agent · 0 min of 55', + '[cancel-run][show-output]', + undefined, + 'cancelled', + ], + ) +}) + +// Value: protects=AC 2 (row 1 beside a run); fails_when=a published Wiki disappears or keeps its Update action while an update runs; no_claim=the update's own work; seam=a Cursor update whose agent waits +test('a published Wiki stays readable beside a running update and offers no second Update', async (context) => { + // Arrange + const { window } = await openPublishedWiki(context, { + cursor: true, + cursorAgent: (_root, _prompt, signal) => + new Promise((_resolve, reject) => + signal?.addEventListener('abort', () => reject(new Error('stopped'))), + ), + }) + await untilFreshness(window) + + // Act + void window.runUpdateCommand() + await untilRow(window, 'run') + const rows = window.llmWiki.rows() + await window.runCommand('codebook.llmWiki.cancelRun') + + // Assert + assert.deepEqual( + rows.map((row) => [row.id, row.contextValue]), + [ + ['wiki', ''], + ['run', '[cancel-run][show-output]'], + ['output', '[change-location]'], + ], + ) +}) + +// Value: protects=AC 2 (a watcher that cannot be created); fails_when=the view fails, writes the cause more than once, or hides in the Output row that it refreshes on visibility and Refresh only; no_claim=the cause's wording; seam=a refused file watcher +test('a refused file watcher is recorded once in Output, said in the Output row’s tooltip, and Refresh still works', async (context) => { + // Arrange + const { wiki, window } = await openPublishedWiki(context, { + wikiWatcherFails: 'no watcher available', + }) + await window.runCommand('codebook.refreshLlmWiki') + await unlink(join(wiki.outputDirectory, 'index.html')) + + // Act + await window.runCommand('codebook.refreshLlmWiki') + await untilRow(window, 'attention') + + // Assert + assert.deepEqual( + [ + window.outputLines.filter((line) => line.includes('could not watch')), + rowOf(window, 'output')?.tooltip?.includes('refreshes when it is shown'), + ], + [ + [ + 'error: The LLM Wiki view could not watch the Wiki folder: no watcher available', + ], + true, + ], + ) +}) + +// Value: protects=AC 2 (the row actions are commands a keyboard reaches); fails_when=Open Chat, Change location, Copy recovery request or Reveal Wiki Folder run another editor command or copy another text; no_claim=what the editor does with them; seam=the stubbed editor's command log and clipboard +test('Open Chat, Change location, Reveal Wiki Folder and Copy recovery request run the editor commands and the copy the plan names', async (context) => { + // Arrange + const project = await openEmptyProject(context) + + // Act + await project.runCommand('codebook.llmWiki.openChat') + await project.runCommand('codebook.llmWiki.changeLocation') + await project.runCommand('codebook.revealWikiFolder') + await project.runCommand('codebook.copyWikiRecoveryRequest') + + // Assert + assert.deepEqual( + [project.executedCommands, project.clipboard, project.notices], + [ + [ + 'workbench.action.chat.open', + 'workbench.action.openSettings', + 'revealFileInOS', + ], + ['/codebook Recover the unfinished LLM Wiki publish for this project'], + ['Copied the recovery request. Paste it into the editor chat.'], + ], + ) +}) + +// Value: protects=the view's folder row actions; fails_when=Open Folder runs a command other than the editor's folder dialog; no_claim=the dialog; seam=the stubbed editor's command log +test('Open Folder runs the editor’s folder dialog command', async (context) => { + // Arrange + const project = await openWikiUpdateWindow({ + projectRoot: tmpdir(), + openReader: false, + noFolder: true, + constants: FAST, + }) + context.after(() => project.close()) + + // Act + await project.runCommand('codebook.llmWiki.openFolder') + + // Assert + assert.deepEqual(project.executedCommands, ['vscode.openFolder']) +}) + +// Value: protects=AC 14 (the view only reads); fails_when=reading the state makes the .codebook folder of a project that has none, which a later Generate must make itself after its confirmation; no_claim=the Generate flow's folder; seam=a project with no .codebook +test('reading the state of a project without a Wiki makes no folder', async (context) => { + // Arrange + const project = await openEmptyProject(context) + + // Act + await project.runCommand('codebook.refreshLlmWiki') + + // Assert + assert.equal(await exists(join(project.projectRoot, '.codebook')), false) +}) diff --git a/extension/src/tests/utils/openProjectWithoutWiki.ts b/extension/src/tests/utils/openProjectWithoutWiki.ts new file mode 100644 index 000000000..1de476bcb --- /dev/null +++ b/extension/src/tests/utils/openProjectWithoutWiki.ts @@ -0,0 +1,84 @@ +import { createHash } from 'node:crypto' +import { + access, + mkdir, + mkdtemp, + realpath, + rename, + rm, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { openWikiUpdateWindow } from './wikiUpdateWindow' + +/** Says whether a path exists on disk. */ +export const exists = (path: string): Promise => + access(path).then( + () => true, + () => false, + ) + +/** Publishes a first Wiki the way the publisher would: its entry page, then the manifest of this project and the receipt that names the manifest's hash, each moved into place. */ +export async function publishGeneratedWiki( + outputDirectory: string, + projectRoot: string, + repositoryRoot = projectRoot, +): Promise { + await mkdir(outputDirectory, { recursive: true }) + await writeFile(join(outputDirectory, 'index.html'), 'Wiki') + const manifestText = JSON.stringify({ + schemaVersion: 1, + repositoryRoot, + generated: { revision: '1936e41913729663cc99081e3224db89bb2d8e56' }, + pages: [], + assets: [], + excludedPaths: [], + includedSources: [{ path: 'src/a.ts', sha256: 'a'.repeat(64) }], + }) + const manifestPath = join(outputDirectory, 'codebook-wiki.json') + await writeFile(`${manifestPath}.next`, manifestText) + await rename(`${manifestPath}.next`, manifestPath) + const receiptPath = join(outputDirectory, 'codebook-publication.json') + await writeFile( + `${receiptPath}.next`, + JSON.stringify({ + schemaVersion: 1, + kind: 'published', + manifestSha256: createHash('sha256').update(manifestText).digest('hex'), + }), + ) + await rename(`${receiptPath}.next`, receiptPath) +} + +/** Opens a window on a project that has no Wiki and no `.codebook` folder yet, the ordinary first use; the browser reader is not opened, since there is nothing to serve. */ +export async function openProjectWithoutWiki( + options: Omit< + Parameters[0], + 'projectRoot' | 'openReader' + > = {}, +) { + const projectDirectory = await mkdtemp( + join(tmpdir(), 'codebook-wiki-generate-'), + ) + const projectRoot = await realpath(projectDirectory) + const window = await openWikiUpdateWindow({ + ...options, + projectRoot, + openReader: false, + }) + const outputDirectory = join(projectRoot, '.codebook', 'wiki') + return { + ...window, + projectRoot, + outputDirectory, + markerPath: `${outputDirectory}.update-run.json`, + /** Closes the editor window as the learner does, keeping the project on disk for the test's assertions; `close` still removes it. */ + closeWindow: (): Promise => window.close(), + async close(): Promise { + await window.close() + await rm(projectDirectory, { recursive: true, force: true }) + }, + } +} diff --git a/extension/src/tests/utils/wikiUpdateWindow.ts b/extension/src/tests/utils/wikiUpdateWindow.ts index 42ff86fae..c19fa733b 100644 --- a/extension/src/tests/utils/wikiUpdateWindow.ts +++ b/extension/src/tests/utils/wikiUpdateWindow.ts @@ -97,6 +97,7 @@ export type HeldUpdateStep = | 'marker-change' | 'marker-write' | 'claim-answer' + | 'start-block' /** * Opens an editor window (the real extension with a stubbed `vscode`) on a project whose Wiki already exists, and records everything the learner would see of an update. @@ -183,6 +184,10 @@ export async function openWikiUpdateWindow(options: { ) => string | undefined /** Whether the editor trusts the folder; true when omitted. */ trusted?: boolean + /** Opens the window with no folder. */ + noFolder?: boolean + /** Keeps these steps open from the window's activation on, so a test sees the state before the first read ends; `hold(step)` then returns the same hold. */ + holdAtOpen?: HeldUpdateStep[] /** Commands the editor lists besides the chat's (for example the id of its manage-trust command). */ extraEditorCommands?: string[] /** Makes the editor refuse the file watcher of the LLM Wiki view with this message. */ @@ -224,6 +229,8 @@ export async function openWikiUpdateWindow(options: { stampReads: number stampReadsDone: number markerClaims: number + /** How often the extension read the publish token: once per read of the LLM Wiki view's state, among the start checks' reads. */ + publishTokenReads: number } markerChanges: WikiMarkerChange[] openWorkspace(nextProjectRoot: string): void @@ -292,6 +299,7 @@ export async function openWikiUpdateWindow(options: { stampReads: 0, stampReadsDone: 0, markerClaims: 0, + publishTokenReads: 0, } const markerChanges: WikiMarkerChange[] = [] /** Records one change of this window's marker, and what it answered once it settled. */ @@ -321,7 +329,7 @@ export async function openWikiUpdateWindow(options: { > = [] const trustListeners: Array<() => void> = [] let isTrusted = options.trusted ?? true - let isFolderOpen = true + let isFolderOpen = !options.noFolder let treeProvider: | { getChildren(): string[] @@ -332,10 +340,41 @@ export async function openWikiUpdateWindow(options: { let treeMessage: string | undefined let treeChangeCount = 0 const visibilityListeners: Array<(event: { visible: boolean }) => void> = [] + const holdControls = new Map< + HeldUpdateStep, + { started: Promise; release(): void } + >() const holds = new Map< HeldUpdateStep, { reached(): void; released: Promise } >() + /** Makes a hold on a step, or returns the one already made, for `hold()` and `holdAtOpen`. */ + const makeHold = ( + step: HeldUpdateStep, + ): { started: Promise; release(): void } => { + const existing = holdControls.get(step) + if (existing) return existing + let reached = (): void => {} + let release = (): void => {} + const started = new Promise((resolveStarted) => { + reached = resolveStarted + }) + const released = new Promise((resolveReleased) => { + release = resolveReleased + }) + holds.set(step, { reached, released }) + const controls = { + started, + release(): void { + holds.delete(step) + holdControls.delete(step) + release() + }, + } + holdControls.set(step, controls) + return controls + } + for (const step of options.holdAtOpen ?? []) makeHold(step) /** Waits at a step a test holds open; a step nobody holds passes at once. */ const passHold = async (step: HeldUpdateStep): Promise => { const hold = holds.get(step) @@ -657,16 +696,18 @@ export async function openWikiUpdateWindow(options: { : {}), // The real start check runs; a test that holds its wait at a fresh journal decides when the second look comes, and otherwise the real time passes. './utils/findWikiUpdateStartBlock': { - findWikiUpdateStartBlock: ( + findWikiUpdateStartBlock: async ( outputDirectory: string, projectRoot: string, - ) => - findWikiUpdateStartBlock(outputDirectory, projectRoot, { + ) => { + await passHold('start-block') + return findWikiUpdateStartBlock(outputDirectory, projectRoot, { wait: async (delayMs: number) => { if (holds.has('journal-wait')) await passHold('journal-wait') else await sleepFor(delayMs) }, - }), + }) + }, }, // The real listing runs; the window only counts how often a look parsed the candidates, and can keep one listing open after it has read the folder. './utils/listWikiCandidates': { @@ -718,6 +759,7 @@ export async function openWikiUpdateWindow(options: { }, './utils/readWikiPublishToken': { readWikiPublishToken: async (outputDirectory: string) => { + looks.publishTokenReads += 1 if (publishTokenReadFailure !== undefined) throw new Error(publishTokenReadFailure) await passHold('publish-token-read') @@ -927,24 +969,7 @@ export async function openWikiUpdateWindow(options: { breakPublishTokenRead(message: string | undefined): void { publishTokenReadFailure = message }, - hold(step: HeldUpdateStep): { started: Promise; release(): void } { - let reached = (): void => {} - let release = (): void => {} - const started = new Promise((resolveStarted) => { - reached = resolveStarted - }) - const released = new Promise((resolveReleased) => { - release = resolveReleased - }) - holds.set(step, { reached, released }) - return { - started, - release(): void { - holds.delete(step) - release() - }, - } - }, + hold: makeHold, close: (): Promise => boundary.deactivate(), } } diff --git a/extension/src/tests/wikiGenerateFlow.test.ts b/extension/src/tests/wikiGenerateFlow.test.ts index 978147ba2..74306ac25 100644 --- a/extension/src/tests/wikiGenerateFlow.test.ts +++ b/extension/src/tests/wikiGenerateFlow.test.ts @@ -2,14 +2,11 @@ import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { writeFileSync } from 'node:fs' import { - access, chmod, mkdir, mkdtemp, readFile, readdir, - realpath, - rename, rm, symlink, writeFile, @@ -21,6 +18,11 @@ import { test } from 'node:test' import { waitUntil } from '../runtimeSupport/utils/waitUntil' import type { WikiFreshness } from '../utils/inspectDeepWikiFreshness' +import { + exists, + openProjectWithoutWiki, + publishGeneratedWiki, +} from './utils/openProjectWithoutWiki' import { openWikiUpdateWindow } from './utils/wikiUpdateWindow' const CURRENT: WikiFreshness = { @@ -31,76 +33,6 @@ const CURRENT: WikiFreshness = { const GENERATE_COMMAND = 'codebook.generateDeepWiki' -/** Says whether a path exists on disk. */ -const exists = (path: string): Promise => - access(path).then( - () => true, - () => false, - ) - -/** Publishes a first Wiki the way the publisher would: its entry page, then the manifest of this project and the receipt that names the manifest's hash, each moved into place. */ -async function publishGeneratedWiki( - outputDirectory: string, - projectRoot: string, - repositoryRoot = projectRoot, -): Promise { - await mkdir(outputDirectory, { recursive: true }) - await writeFile(join(outputDirectory, 'index.html'), 'Wiki') - const manifestText = JSON.stringify({ - schemaVersion: 1, - repositoryRoot, - generated: { revision: '1936e41913729663cc99081e3224db89bb2d8e56' }, - pages: [], - assets: [], - excludedPaths: [], - includedSources: [{ path: 'src/a.ts', sha256: 'a'.repeat(64) }], - }) - const manifestPath = join(outputDirectory, 'codebook-wiki.json') - await writeFile(`${manifestPath}.next`, manifestText) - await rename(`${manifestPath}.next`, manifestPath) - const receiptPath = join(outputDirectory, 'codebook-publication.json') - await writeFile( - `${receiptPath}.next`, - JSON.stringify({ - schemaVersion: 1, - kind: 'published', - manifestSha256: createHash('sha256').update(manifestText).digest('hex'), - }), - ) - await rename(`${receiptPath}.next`, receiptPath) -} - -/** Opens a window on a project that has no Wiki and no `.codebook` folder yet, the ordinary first use; the browser reader is not opened, since there is nothing to serve. */ -async function openProjectWithoutWiki( - options: Omit< - Parameters[0], - 'projectRoot' | 'openReader' - > = {}, -) { - const projectDirectory = await mkdtemp( - join(tmpdir(), 'codebook-wiki-generate-'), - ) - const projectRoot = await realpath(projectDirectory) - const window = await openWikiUpdateWindow({ - ...options, - projectRoot, - openReader: false, - }) - const outputDirectory = join(projectRoot, '.codebook', 'wiki') - return { - ...window, - projectRoot, - outputDirectory, - markerPath: `${outputDirectory}.update-run.json`, - /** Closes the editor window as the learner does, keeping the project on disk for the test's assertions; `close` still removes it. */ - closeWindow: (): Promise => window.close(), - async close(): Promise { - await window.close() - await rm(projectDirectory, { recursive: true, force: true }) - }, - } -} - // Value: protects=AC 1 and 14, the first Generate from Codebook's entry in VS Code in a project without `.codebook`; fails_when=Generate starts without the confirmation that names the resolved folder and the chat hand-over, sends no generate request, fails to claim its marker because the folder that holds it is missing (`placeStorageLock` makes no folder), writes no `runKind` into the marker, or makes the output folder itself; no_claim=the chat's own work and its publish (later tests); seam=the stubbed editor test('Generate LLM Wiki in VS Code confirms the folder and the chat hand-over, then hands a generate request to a new chat and claims a generation marker, in a project without .codebook', async (context) => { // Arrange diff --git a/extension/src/utils/llmWikiView.ts b/extension/src/utils/llmWikiView.ts index 7e4bccb24..d5c5e3774 100644 --- a/extension/src/utils/llmWikiView.ts +++ b/extension/src/utils/llmWikiView.ts @@ -1,4 +1,4 @@ -import { join, relative, sep } from 'node:path' +import { dirname, join, relative, sep } from 'node:path' import type * as vscode from 'vscode' @@ -135,7 +135,8 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { const pollMs = options.pollMs ?? LLM_WIKI_POLL_MS const treeChanges = new editor.EventEmitter() let model: LlmWikiViewModel = { state: 'other', rows: [LLM_WIKI_LOADING_ROW] } - let shownState: string | undefined + // Until the first read ends the welcome content must not show: the state is `loading`. + let shownState: string | undefined = 'loading' let shownKey = JSON.stringify(model) let facts: LlmWikiViewFacts | undefined let previousRowIds: string[] = [] @@ -157,6 +158,11 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { CODEBOOK_HOST_CONTEXT_KEY, options.host, ) + void editor.commands.executeCommand( + 'setContext', + LLM_WIKI_STATE_CONTEXT_KEY, + 'loading', + ) const treeView = editor.window.createTreeView(LLM_WIKI_VIEW_ID, { treeDataProvider: { @@ -189,7 +195,7 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { /** Shows a model: sets the context key, the message and fires one tree change, only for what differs from what is shown. */ const show = (next: LlmWikiViewModel): void => { const nextKey = JSON.stringify(next) - if (nextKey === shownKey && shownState !== undefined) return + if (nextKey === shownKey && next.state === shownState) return const didStateChange = next.state !== shownState model = next shownKey = nextKey @@ -229,7 +235,8 @@ export function createLlmWikiView(options: LlmWikiViewOptions): LlmWikiView { /** Puts the watcher on the nearest existing folder of the output, and makes it again when that folder or the output changed. */ const keepWatcher = async (outputDirectory: string): Promise => { - const folder = await options.findWatchedFolder(outputDirectory) + // The markers lie beside the output folder, so its parent is the first folder to stand on. + const folder = await options.findWatchedFolder(dirname(outputDirectory)) if ( watcher?.folder === folder && watcher.outputDirectory === outputDirectory From ebe47c222c4a64427ba7425d695b3942682f53f9 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Mon, 26 Oct 2026 19:00:31 +0900 Subject: [PATCH 06/18] feat: Cycle 0055 pt2: manifest, icon and unit tests; learning.svg joins the package inventory Refs #132 --- extension/scripts/tests/package.test.mjs | 25 ++++ .../scripts/utils/verifyPackageContent.mjs | 1 + .../src/tests/describeWikiGeneration.test.ts | 34 +++++ .../tests/findNearestExistingAncestor.test.ts | 31 ++++ extension/src/tests/llmWikiManifest.test.ts | 136 ++++++++++++++++++ .../tests/resolveManageTrustCommand.test.ts | 25 ++++ extension/src/tests/wikiUpdateRun.test.ts | 30 ++++ extension/src/utils/readLlmWikiViewState.ts | 2 +- 8 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 extension/src/tests/describeWikiGeneration.test.ts create mode 100644 extension/src/tests/findNearestExistingAncestor.test.ts create mode 100644 extension/src/tests/llmWikiManifest.test.ts create mode 100644 extension/src/tests/resolveManageTrustCommand.test.ts diff --git a/extension/scripts/tests/package.test.mjs b/extension/scripts/tests/package.test.mjs index 906d14df5..b0b2c7d26 100644 --- a/extension/scripts/tests/package.test.mjs +++ b/extension/scripts/tests/package.test.mjs @@ -177,6 +177,30 @@ test('a real VSIX loads outside the checkout and excludes bundled applications a original.get('extension/src/assets/book.svg'), await readFile(join(repository, 'extension/src/assets/book.svg')), ) + assert.deepEqual( + original.get('extension/src/assets/learning.svg'), + await readFile(join(repository, 'extension/src/assets/learning.svg')), + ) +}) + +test('the packaged manifest gives the two Activity Bar containers different icon files, both shipped', () => { + // Arrange + const manifest = JSON.parse(original.get('extension/package.json')) + + // Act + const icons = Object.fromEntries( + manifest.contributes.viewsContainers.activitybar.map((container) => [ + container.id, + container.icon, + ]), + ) + + // Assert + assert.deepEqual(icons, { + codebook: 'src/assets/book.svg', + codebookLearning: 'src/assets/learning.svg', + }) + assert.ok(original.has('extension/src/assets/learning.svg')) }) test('a coherent SHA-256 Git revision survives real archive checking', async () => { @@ -340,6 +364,7 @@ for (const method of ['openPromise', 'fromBufferPromise']) { for (const file of [ 'dist/extension.js', 'src/assets/book.svg', + 'src/assets/learning.svg', 'skills/codebook/SKILL.md', ]) { test(`a VSIX missing ${file} is rejected before installation`, async () => { diff --git a/extension/scripts/utils/verifyPackageContent.mjs b/extension/scripts/utils/verifyPackageContent.mjs index a31fa4034..fc02ad3c9 100644 --- a/extension/scripts/utils/verifyPackageContent.mjs +++ b/extension/scripts/utils/verifyPackageContent.mjs @@ -14,6 +14,7 @@ export const REQUIRED_PACKAGE_FILES = [ 'dist/wikiPageWorker.js', 'dist/build.json', 'src/assets/book.svg', + 'src/assets/learning.svg', 'skills/codebook/SKILL.md', 'skills/codebook/publish-wiki.mjs', 'skills/codebook/render-wiki.mjs', diff --git a/extension/src/tests/describeWikiGeneration.test.ts b/extension/src/tests/describeWikiGeneration.test.ts new file mode 100644 index 000000000..2e5a231a2 --- /dev/null +++ b/extension/src/tests/describeWikiGeneration.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { describeWikiGeneration } from '../utils/describeWikiGeneration' + +const revision = '1936e41913729663cc99081e3224db89bb2d8e56' +const formatDate = (): string => 'Oct 26, 2026' + +// Value: protects=AC 10 (accepted X1), the four tooltip forms; fails_when=a form names the wrong parts, a branch name counts as a revision, or a Wiki with neither shows a line; no_claim=the date's locale; seam=the pure function +test('describeWikiGeneration words the four forms of a Wiki’s generation', () => { + // Arrange + const date = 1_790_000_000_000 + + // Act + const forms = [ + describeWikiGeneration({ revision, publishedAtMs: date }, formatDate), + describeWikiGeneration({ publishedAtMs: date }, formatDate), + describeWikiGeneration( + { revision: 'main', publishedAtMs: date }, + formatDate, + ), + describeWikiGeneration({ revision }, formatDate), + describeWikiGeneration({}, formatDate), + ] + + // Assert + assert.deepEqual(forms, [ + 'Generated from 1936e41 on Oct 26, 2026', + 'Generated on Oct 26, 2026 (no Git revision)', + 'Generated on Oct 26, 2026 (no Git revision)', + 'Generated from 1936e41', + undefined, + ]) +}) diff --git a/extension/src/tests/findNearestExistingAncestor.test.ts b/extension/src/tests/findNearestExistingAncestor.test.ts new file mode 100644 index 000000000..30048c0f9 --- /dev/null +++ b/extension/src/tests/findNearestExistingAncestor.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' + +import { findNearestExistingAncestor } from '../utils/findNearestExistingAncestor' + +// Value: protects=Eng decision 78, the watcher of a fresh project; fails_when=a path whose parents are missing is returned as it is, so the watcher stands on a folder that does not exist; no_claim=the watcher; seam=a temporary folder +test('findNearestExistingAncestor returns the folder itself when it exists and the nearest existing parent when it does not', async (context) => { + // Arrange + const root = await realpath( + await mkdtemp(join(tmpdir(), 'codebook-ancestor-')), + ) + context.after(() => rm(root, { recursive: true, force: true })) + await mkdir(join(root, '.codebook')) + + // Act + const found = [ + await findNearestExistingAncestor(join(root, '.codebook')), + await findNearestExistingAncestor(join(root, '.codebook', 'wiki', 'deep')), + await findNearestExistingAncestor(join(root, 'docs', 'wiki')), + ] + + // Assert + assert.deepEqual(found, [ + join(root, '.codebook'), + join(root, '.codebook'), + root, + ]) +}) diff --git a/extension/src/tests/llmWikiManifest.test.ts b/extension/src/tests/llmWikiManifest.test.ts new file mode 100644 index 000000000..d592775f5 --- /dev/null +++ b/extension/src/tests/llmWikiManifest.test.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { test } from 'node:test' + +import { LLM_WIKI_ROW_ACTIONS } from '../constants' + +type Manifest = { + contributes: { + viewsContainers: { activitybar: Array<{ id: string; icon: string }> } + views: { codebook: Array<{ id: string; name: string }> } + viewsWelcome: Array<{ view: string; when: string; contents: string }> + commands: Array<{ command: string; title: string }> + menus: Record< + string, + Array<{ command: string; when?: string; group?: string }> + > + } +} + +const readManifest = async (): Promise => + JSON.parse(await readFile(resolve(__dirname, '../../package.json'), 'utf8')) + +// Value: protects=AC 1, the empty state's welcome content by host; fails_when=a host's entry loses its none-state condition, the Generate LLM Wiki button on its own line, the host's runner sentence or the inline Change location settings link; no_claim=how the editor renders it (N55 step 1); seam=the packaged manifest +test('the LLM Wiki view’s welcome content shows only in the none state, per host, with a Generate button on its own line and a Change location link', async () => { + // Arrange + const manifest = await readManifest() + + // Act + const entries = manifest.contributes.viewsWelcome.filter( + (entry) => entry.view === 'codebook.llmWiki', + ) + + // Assert + assert.deepEqual( + entries.map((entry) => ({ + when: entry.when, + lines: entry.contents.split('\n'), + })), + [ + { + when: 'codebook.llmWiki.state == none && codebook.host == vscode', + lines: [ + "Explore this project through source-linked chapters, diagrams and questions, written by your editor's AI.", + '[Generate LLM Wiki](command:codebook.generateDeepWiki)', + 'It runs in a new Copilot Chat session and uses your chat plan. The chat asks you to approve its commands and may ask you to Continue; keep the Chat view open.', + 'The Wiki is written to the folder the setting codebook.wikiOutputDirectory names (default .codebook/wiki). [Change location](command:workbench.action.openSettings?%5B%22%40id%3Acodebook.wikiOutputDirectory%22%5D)', + ], + }, + { + when: 'codebook.llmWiki.state == none && codebook.host == cursor', + lines: [ + "Explore this project through source-linked chapters, diagrams and questions, written by your editor's AI.", + '[Generate LLM Wiki](command:codebook.generateDeepWiki)', + "It runs through Cursor's agent CLI (cursor-agent), uses your Cursor plan and can take several minutes. You can keep working.", + 'The Wiki is written to the folder the setting codebook.wikiOutputDirectory names (default .codebook/wiki). [Change location](command:workbench.action.openSettings?%5B%22%40id%3Acodebook.wikiOutputDirectory%22%5D)', + ], + }, + ], + ) +}) + +// Value: protects=task 5b and AC-ENTRY, two Activity Bar entries with one icon; fails_when=the Learning summary container uses the same icon file as the codebook container, or its file is missing; no_claim=how the icon looks (N55 step 1); seam=the packaged manifest +test('the two Activity Bar containers use different icon files', async () => { + // Arrange + const manifest = await readManifest() + + // Act + const icons = Object.fromEntries( + manifest.contributes.viewsContainers.activitybar.map((container) => [ + container.id, + container.icon, + ]), + ) + const learningIcon = await readFile( + resolve(__dirname, '../../src/assets/learning.svg'), + 'utf8', + ) + + // Assert + assert.deepEqual(icons, { + codebook: 'src/assets/book.svg', + codebookLearning: 'src/assets/learning.svg', + }) + assert.match(learningIcon, /viewBox="0 0 24 24"/) + assert.match(learningIcon, /currentColor/) +}) + +// Value: protects=AC 2 (the view sits after Repository Wiki in the codebook container) and keyboard access; fails_when=the view is missing, comes before Repository Wiki, or a row action has no command, no inline entry or no context-menu entry; no_claim=the rows' when conditions at runtime (host tests); seam=the packaged manifest +test('the LLM Wiki view follows Repository Wiki and every row action is a contributed command with an inline and a context-menu entry', async () => { + // Arrange + const manifest = await readManifest() + const commandIds = new Set( + manifest.contributes.commands.map((command) => command.command), + ) + const itemMenu = manifest.contributes.menus['view/item/context'] ?? [] + + // Act + const missing = Object.entries(LLM_WIKI_ROW_ACTIONS).flatMap( + ([key, action]) => { + const entries = itemMenu.filter( + (entry) => + entry.command === action.command && + entry.when?.includes(`\\[${key}\\]`) === true, + ) + return [ + commandIds.has(action.command) ? [] : [`${key}: no command`], + entries.some((entry) => entry.group?.startsWith('inline@')) + ? [] + : [`${key}: no inline entry`], + entries.some((entry) => entry.group?.startsWith('llmWiki@')) + ? [] + : [`${key}: no context-menu entry`], + ].flat() + }, + ) + + // Assert + assert.deepEqual( + manifest.contributes.views.codebook.map((view) => view.id), + ['codebook.repositoryWiki', 'codebook.llmWiki', 'codebook.learningOutline'], + ) + assert.deepEqual(missing, []) + assert.deepEqual( + manifest.contributes.menus['view/title']?.filter( + (entry) => entry.command === 'codebook.refreshLlmWiki', + ), + [ + { + command: 'codebook.refreshLlmWiki', + when: 'view == codebook.llmWiki', + group: 'navigation', + }, + ], + ) +}) diff --git a/extension/src/tests/resolveManageTrustCommand.test.ts b/extension/src/tests/resolveManageTrustCommand.test.ts new file mode 100644 index 000000000..95fd6e828 --- /dev/null +++ b/extension/src/tests/resolveManageTrustCommand.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { resolveManageTrustCommand } from '../utils/resolveManageTrustCommand' + +// Value: protects=the manage-trust id decision (assumed ids workbench.trust.manage and workbench.action.manageTrust); fails_when=the first known id is not preferred, an id the editor does not list is returned, or an editor with none gets one; no_claim=that either id exists in Cursor (native trial); seam=the pure function +test('the manage-trust command is the first known id the editor lists, and none when it lists none', () => { + // Arrange + const both = ['x', 'workbench.action.manageTrust', 'workbench.trust.manage'] + const onlySecond = ['workbench.action.manageTrust'] + + // Act + const picks = [ + resolveManageTrustCommand(both), + resolveManageTrustCommand(onlySecond), + resolveManageTrustCommand(['other']), + ] + + // Assert + assert.deepEqual(picks, [ + 'workbench.trust.manage', + 'workbench.action.manageTrust', + undefined, + ]) +}) diff --git a/extension/src/tests/wikiUpdateRun.test.ts b/extension/src/tests/wikiUpdateRun.test.ts index eafe7ae77..16f818446 100644 --- a/extension/src/tests/wikiUpdateRun.test.ts +++ b/extension/src/tests/wikiUpdateRun.test.ts @@ -499,3 +499,33 @@ test('a start refused because somebody is still updating the Wiki, and a run tha site: 'kept', }) }) + +// Value: protects=the LLM Wiki view's refresh on a change of this window's run state; fails_when=a start, an end, a refused start or a stopped listener's later change is reported wrongly; no_claim=what the view does with it; seam=the run state object +test('onDidChange reports every change of the state and nothing after the listener is removed', () => { + // Arrange + const run = new WikiUpdateRun() + const output = { + outputDirectory: '/repo/.codebook/wiki', + outputLabel: '.codebook/wiki', + } + let changes = 0 + const stopListening = run.onDidChange(() => { + changes += 1 + }) + + // Act + run.begin('cursor', output, 900_000, false) + run.markCandidateStaged() + run.end({ state: 'cancelled', site: 'kept' }) + run.refuseStart('cursor', output, { + kind: 'conflict', + paths: ['index.html'], + cause: 'edited-page', + }) + const changesWhileListening = changes + stopListening() + run.begin('cursor', output, 900_000, false) + + // Assert + assert.deepEqual([changesWhileListening, changes], [4, 4]) +}) diff --git a/extension/src/utils/readLlmWikiViewState.ts b/extension/src/utils/readLlmWikiViewState.ts index 6553a379c..2795d2e2e 100644 --- a/extension/src/utils/readLlmWikiViewState.ts +++ b/extension/src/utils/readLlmWikiViewState.ts @@ -132,7 +132,7 @@ const LOCAL_WORDING: LlmWikiViewWording = { } /** The message above the tree while a VS Code watch is shown: Stop watching ends Codebook's watch only. */ -export const LLM_WIKI_WATCHING_MESSAGE = +const LLM_WIKI_WATCHING_MESSAGE = 'Codebook is watching the chat. Stop watching does not stop the chat; stop it in the chat.' /** How an attention cause is worded and what its row offers (the plan's attention table). */ From b2b9212e921a41487516e58900cc690a1bccde26 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Mon, 26 Oct 2026 19:01:14 +0900 Subject: [PATCH 07/18] feat: Cycle 0055 pt2: README describes the LLM Wiki view, its rows and attention rows Refs #132 --- extension/README.md | 55 +++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/extension/README.md b/extension/README.md index c3f68597d..80b32dedf 100644 --- a/extension/README.md +++ b/extension/README.md @@ -150,7 +150,7 @@ A test runs the way its package's own `vitest` command would: in its **Vitest ro The **LLM Wiki** is the browser site your editor's AI writes for the whole repository: overview, architecture and module chapters with diagrams, citations into your editor and repository questions. It is not the **Repository Wiki** view in Codebook's Activity Bar, which lists repository articles an MCP agent wrote ([Learn this repository](#learn-this-repository)). No runtime MCP connection, debugger or running application is needed. 1. Prerequisites. VS Code: Copilot Chat enabled and signed in. Cursor: the `cursor-agent` CLI installed and signed in (`cursor-agent --version` answers; Codebook looks for `~/.local/bin/cursor-agent`, then `cursor-agent` on the editor's PATH). -2. Open the project folder and run **Codebook: Generate LLM Wiki** from the Command Palette, or press **Generate LLM Wiki** in the notice **Open LLM Wiki in Browser** or **Update LLM Wiki** shows in a folder without a Wiki (`Codebook found no LLM Wiki in .`). +2. Open the project folder and press **Generate LLM Wiki** in the **LLM Wiki** view of Codebook's Activity Bar, run **Codebook: Generate LLM Wiki** from the Command Palette, or press **Generate LLM Wiki** in the notice **Open LLM Wiki in Browser** or **Update LLM Wiki** shows in a folder without a Wiki (`Codebook found no LLM Wiki in .`). 3. Confirm. The confirmation names the folder the Wiki is written to and where the request goes. **Change location** opens the `codebook.wikiOutputDirectory` setting and starts nothing; dismissing it starts nothing either. 4. Wait for `LLM Wiki generated in .` and press **Open in Browser**. Codebook waits up to 55 minutes. @@ -184,25 +184,25 @@ To write the Wiki elsewhere, set the folder in your settings (a path inside the **Codebook: Open LLM Wiki in Browser** serves the generated site from a loopback reader in your browser. The reader answers only on this computer: a request whose Host is its own loopback address, or any loopback address the editor has forwarded it to (each stays accepted if the editor forwards it again). A window whose forward goes to another host (a tunnel, a browser-based editor) gets no page, only a 403 that says so. The reader removes the effect of a page's own `referrerpolicy` attributes and `` element, so a Wiki page cannot send the reader's private address to another site. **Update LLM Wiki** (the button in the right rail of that page, or **Codebook: Update LLM Wiki** in the Command Palette) asks the editor agent to regenerate the site. The button asks for confirmation in the editor first; in VS Code the confirmation says where the request goes: `Copilot Chat receives the update request for this project in a new chat; the chat you have open keeps its place in the chat list. If the Chat view has not been opened in this window yet, the request may go into the chat it restores. A draft you typed into an empty chat is replaced by the request; a draft in a chat with messages stays with that chat.` The command starts at once and asks nothing: its request goes into a new chat in the same way (into the chat the Chat view restores, if that view opens only now) and replaces a draft in an empty chat in the same way; the Chat view shows the chat with the request. When the watch ends and what to do after you refused a step of the chat are in the row **Updating LLM Wiki…** below. The page then shows the run and its result under the button without a reload; the site stays readable the whole time. -| The page says | What happened | What to do | -| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Confirm the update in your editor | You pressed the button and the editor is asking whether to start. | Confirm in the editor. | -| No update started. | Two minutes passed and no run began. | Confirm in the editor, or press the button again. | -| Updating LLM Wiki… | VS Code: the request went to a new chat. Codebook waits up to 30 seconds for the first sign that the chat works on it (its reply or its wait for a confirmation, a file of the Wiki or of the Codebook skill that it opens, the publisher it runs in a terminal, or a folder the publisher prepares beside the Wiki) and then watches the output folder for up to 15 minutes. The watch ends when the new Wiki is published, when a file of your own in the Wiki folder is in the way of the publish (**Update stopped**), when a new site is on disk whose manifest Codebook still cannot read 10 seconds after it appeared, when the chat's reply ends before the chat asked you to approve a step, when you press **Stop watching** in the status bar, or after those 15 minutes. Cursor: Cursor Agent is running, for up to 15 minutes. The button reads **Updating…** and is held back. | VS Code: follow the chat and approve its steps. If you refuse a step, press **Stop watching** in the status bar: once the chat has asked you, Codebook does not see it stop, and would watch for the 15 minutes. Cursor: cancel from the notification if you need to. | -| Updated at … | A new site was published and matches the source. | Press **Reload to read the new pages** when the page offers it. | -| Already current | The Wiki already matched the source, so no run started. In a folder without a Git repository only the indexed sources are compared, and the sentence says that added files are not checked. | Nothing. | +| The page says | What happened | What to do | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Confirm the update in your editor | You pressed the button and the editor is asking whether to start. | Confirm in the editor. | +| No update started. | Two minutes passed and no run began. | Confirm in the editor, or press the button again. | +| Updating LLM Wiki… | VS Code: the request went to a new chat. Codebook waits up to 30 seconds for the first sign that the chat works on it (its reply or its wait for a confirmation, a file of the Wiki or of the Codebook skill that it opens, the publisher it runs in a terminal, or a folder the publisher prepares beside the Wiki) and then watches the output folder for up to 15 minutes. The watch ends when the new Wiki is published, when a file of your own in the Wiki folder is in the way of the publish (**Update stopped**), when a new site is on disk whose manifest Codebook still cannot read 10 seconds after it appeared, when the chat's reply ends before the chat asked you to approve a step, when you press **Stop watching** in the status bar, or after those 15 minutes. Cursor: Cursor Agent is running, for up to 15 minutes. The button reads **Updating…** and is held back. | VS Code: follow the chat and approve its steps. If you refuse a step, press **Stop watching** in the status bar: once the chat has asked you, Codebook does not see it stop, and would watch for the 15 minutes. Cursor: cancel from the notification if you need to. | +| Updated at … | A new site was published and matches the source. | Press **Reload to read the new pages** when the page offers it. | +| Already current | The Wiki already matched the source, so no run started. In a folder without a Git repository only the indexed sources are compared, and the sentence says that added files are not checked. | Nothing. | | Update failed | The run ended without a new site, or it was not started. In VS Code an update without such a sign within 30 seconds ends with `Codebook saw no sign of the update in the editor chat within 30 seconds and left its request in the chat without running any chat command on it, so the request may still be there. Stop or remove it in the Chat view, check that Copilot Chat is enabled and signed in, then retry, with Update anyway if Codebook still waits for that chat.` A chat that answers the request with an error ends it at once with `The editor chat answered the update request with an error and stopped (…). Read its message in the Chat view, then press Update LLM Wiki again.`, the chat's message in the brackets; a chat that finishes its reply without publishing ends it with `The editor chat finished its reply without publishing a new Wiki. Read its reply in the Chat view, then press Update LLM Wiki again.` The sentence then says what became of the site: `Nothing had been replaced by then.` where the chat can still publish after that end (the request Codebook left in the chat after the 30 seconds, a watch of Codebook's that broke off with an error), and `The last usable site is kept.` where it cannot, as after the chat's error or its finished reply. Where no Wiki is there to keep, it names no kept site: in a folder that has no Wiki yet it says `Codebook found no LLM Wiki in .` and offers **Generate LLM Wiki**, and where a publish that did not finish left no Wiki it names the recovery. | Follow the sentence, then update again. | -| Update failed, and the sentence names **Update anyway** | An earlier chat may still be updating this Wiki, so no agent was asked: the VS Code chat of this window or of another window after its watch stopped or after a request Codebook left in the chat, or a candidate folder written within the last 15 minutes. The sentence names the time Codebook waits until. The wait for a chat whose watch stopped ends earlier when that chat publishes: once a site was published after the watch stopped, the next update starts without this sentence (in the window that watched only when the candidate folder its watch saw is gone too). | Wait until that time, or, if that chat was stopped, press **Update anyway** in the editor's notification. It starts the update past what that notification named; anything else in the way gets a new notification of its own. Another window's running update offers no **Update anyway**: wait for it to finish. | -| Update needs another run | A new site was published, but it does not match the source (for example a file changed while the agent worked). | Follow the sentence; usually press the button again. | -| Stopped watching (VS Code) | You cancelled: only Codebook's watch stopped. The chat may still be working and can still publish. | Stop the chat itself to cancel the update. If it has already stopped (you refused a step), press **Update anyway** when you update again. | -| Update cancelled (Cursor) | Cursor Agent was stopped. | Update again when ready. | -| No new site appeared (VS Code) | Nothing was published within 15 minutes, so Codebook stopped watching; nothing had been replaced by then. The chat was not stopped with the watch: it may still be working and can still publish, and the page says when it has (**A new site was published**). | Check the chat. If it waits for you, answer it. If it is still working, let it finish. If it stopped or reported a problem, update again, then press **Update anyway** in the editor's notification if Codebook still waits for that chat. | -| Update timed out (Cursor) | Cursor Agent did not finish within 15 minutes and was stopped. | Update again. | -| Update stopped | The new site would overwrite a file of your own in the Wiki folder. In VS Code only Codebook's watch stopped: the chat that prepared the site was not stopped and can still publish once the file is out of the way. | Move or rename the file. Cursor: update again. VS Code: answer the chat if it waits for you, or stop it. To start again, update again and press **Update anyway** if Codebook still waits for that chat. | -| Update not started | A generated page or the Wiki's manifest was changed by hand, a publish of this Wiki holds its publish lock (it may still be running, or it was stopped), an earlier publish did not finish, or the Wiki folder, or a folder on its path inside the project, is a link or a file, which the publisher refuses. No agent ran. | Follow the sentence, then update again. | -| A new site was published | After a run that ended without success, a later publish replaced the site on disk. The sentence says that it matches the source, or points to the freshness label when it does not or cannot be checked. | Press **Reload to read the new pages** when the page offers it. | -| Lost contact with the editor | The editor window that served the page closed or reloaded. The pages you are reading are unchanged. | Run **Codebook: Open LLM Wiki in Browser** again. | -| Update status unknown | The editor answered a state this page does not know. | Reopen the Wiki from the editor. | +| Update failed, and the sentence names **Update anyway** | An earlier chat may still be updating this Wiki, so no agent was asked: the VS Code chat of this window or of another window after its watch stopped or after a request Codebook left in the chat, or a candidate folder written within the last 15 minutes. The sentence names the time Codebook waits until. The wait for a chat whose watch stopped ends earlier when that chat publishes: once a site was published after the watch stopped, the next update starts without this sentence (in the window that watched only when the candidate folder its watch saw is gone too). | Wait until that time, or, if that chat was stopped, press **Update anyway** in the editor's notification. It starts the update past what that notification named; anything else in the way gets a new notification of its own. Another window's running update offers no **Update anyway**: wait for it to finish. | +| Update needs another run | A new site was published, but it does not match the source (for example a file changed while the agent worked). | Follow the sentence; usually press the button again. | +| Stopped watching (VS Code) | You cancelled: only Codebook's watch stopped. The chat may still be working and can still publish. | Stop the chat itself to cancel the update. If it has already stopped (you refused a step), press **Update anyway** when you update again. | +| Update cancelled (Cursor) | Cursor Agent was stopped. | Update again when ready. | +| No new site appeared (VS Code) | Nothing was published within 15 minutes, so Codebook stopped watching; nothing had been replaced by then. The chat was not stopped with the watch: it may still be working and can still publish, and the page says when it has (**A new site was published**). | Check the chat. If it waits for you, answer it. If it is still working, let it finish. If it stopped or reported a problem, update again, then press **Update anyway** in the editor's notification if Codebook still waits for that chat. | +| Update timed out (Cursor) | Cursor Agent did not finish within 15 minutes and was stopped. | Update again. | +| Update stopped | The new site would overwrite a file of your own in the Wiki folder. In VS Code only Codebook's watch stopped: the chat that prepared the site was not stopped and can still publish once the file is out of the way. | Move or rename the file. Cursor: update again. VS Code: answer the chat if it waits for you, or stop it. To start again, update again and press **Update anyway** if Codebook still waits for that chat. | +| Update not started | A generated page or the Wiki's manifest was changed by hand, a publish of this Wiki holds its publish lock (it may still be running, or it was stopped), an earlier publish did not finish, or the Wiki folder, or a folder on its path inside the project, is a link or a file, which the publisher refuses. No agent ran. | Follow the sentence, then update again. | +| A new site was published | After a run that ended without success, a later publish replaced the site on disk. The sentence says that it matches the source, or points to the freshness label when it does not or cannot be checked. | Press **Reload to read the new pages** when the page offers it. | +| Lost contact with the editor | The editor window that served the page closed or reloaded. The pages you are reading are unchanged. | Run **Codebook: Open LLM Wiki in Browser** again. | +| Update status unknown | The editor answered a state this page does not know. | Reopen the Wiki from the editor. | In VS Code the update's request does not go into the chat you are using, with the one exception named below: Codebook reveals the chat, runs the editor's **New Chat** and sends the request into that new chat, so a request of yours that is in progress, waits for a confirmation or is queued is neither stopped nor held back, and the update does not wait behind it. Your chat is not closed: with a request in progress or waiting for a confirmation it goes on in the background, and you return to it from the Chat view's session list (its chat history). The update asks nothing about edits of that chat you have not decided on yet: they stay pending in their chat, with Keep and Undo, and the **Suggested Edits** editor that shows them stays open. Two limits, the first of them that exception. When the Chat view has not been opened in the window yet, it may still be restoring your last chat while New Chat runs: New Chat then does nothing, and the request goes into that restored chat; the confirmation names this case. When the chat you focused last is a chat in an editor tab, the new chat starts in that tab, and the chat that was shown there keeps its place in the chat list. When Codebook sees no sign within those 30 seconds, it leaves its request in the chat and runs none of the chat's commands: the chat's Cancel and its Remove All Queued act on the chat you focused last and on the session it shows, and the editor tells Codebook neither which session that is nor which request is its own, so they could stop or remove a request of yours instead. Your own work elsewhere in the window (a file you open, a command you run) is not taken for a sign. What the update's own steps touch counts whoever does it, because Codebook cannot tell you from the chat there: a file opened in the Wiki's folder, in a folder prepared beside it or in the Codebook skill's folder, a terminal command that names `publish-wiki.mjs` or the Wiki's folder, and a change in the Wiki's folder or in a folder prepared beside it. When the chat's command has not returned by then, the sentence reads `Codebook saw no sign of the update in the editor chat within 30 seconds and left its request in the chat without running any chat command on it, so the request may still be there. Stop or remove it in the Chat view, check that Copilot Chat is enabled and signed in, then retry, with Update anyway if Codebook still waits for that chat.` Codebook then waits for that chat as it does after a stopped watch, for 15 minutes: a retry in that time is answered with the wait and **Update anyway**, not with a second request beside the first. If the chat's command had returned without the chat taking a request, the sentence reads `Codebook saw no sign of the update in the editor chat within 30 seconds and the chat holds no request of it, so no update is running. Check that Copilot Chat is enabled and signed in, then retry.` Every supported VS Code (1.120 and later) has New Chat. An editor without that command, and one in which the reveal of the chat or New Chat fails or does not return within 5 seconds, gets the request in the chat that is open, where it acts like a message you typed: it waits in the queue behind a request in progress, the editor cancels a request that waits for a confirmation, and a draft in the chat's input is replaced. The confirmation says so before the press where the editor lacks the command; where the reveal or New Chat failed, nothing says so before, and the Chat view shows the request in the chat that was open. At the 30 seconds Codebook leaves its request in that chat as well, also where it waits in the queue, and removes none of yours. @@ -238,6 +238,23 @@ A stopped VS Code watch's reservation describes a chat that can still publish. I For tools: `GET /api/update-status` answers the current state as JSON (`Cache-Control: no-store`). `state` is one of `idle`, `running`, `succeeded`, `already-current`, `failed`, `cancelled`, `timed-out` or `conflict`; only `running` is not final. An `already-current` answer carries `addedFilesUnchecked: true` when the folder has no Git repository, so only the indexed sources were compared. Any other method answers `405` with `Allow: GET`. +### The LLM Wiki view + +The **LLM Wiki** view in Codebook's Activity Bar shows where the generated site of the opened project stands and the next step; it sits below the **Repository Wiki** view, which lists repository articles, not the generated site. Its rows come and go with the facts they show, in a fixed order, and the tooltip of a row has its full text: + +- With no folder open the view says `Open a folder to use the LLM Wiki` with **Open Folder**; in a folder you have not trusted it says `Trust this folder to use the LLM Wiki` with **Manage Workspace Trust**. +- In a folder with no Wiki and no run the view shows a welcome with a **Generate LLM Wiki** button, what runs it in your editor (Copilot Chat in VS Code, `cursor-agent` in Cursor) and the output folder with a **Change location** link. +- **` Wiki`** is the published Wiki. A click opens it in the browser. Its description is the freshness label of the reader page (`Wiki source current`, `Source changed · Update available`, `Wiki freshness unknown: …`, or `Checking freshness…` while the check runs), and its tooltip names the generation: `Generated from on `, `Generated on (no Git revision)` in a folder without Git, or `Generated from ` when the date cannot be read. **Update LLM Wiki** is its action while no run shows. The row stays while a run works, so you can read the Wiki during an update. +- **`Generating…` or `Updating…`** is this window's run. In VS Code it reads `Waiting for the chat to start` and then `In chat · min of `, with the actions **Stop watching**, **Open Chat** (the chat that waits for an approval or never started is one click away) and **Show Output**; the message above the tree says that Stop watching does not stop the chat. In Cursor it reads `cursor-agent · min of ` with **Cancel** and **Show Output**. The description is read again every 30 seconds. +- **`Generating in another window`** or **`Updating in another window`** shows a run of another editor window on the same folder, with its start time. +- **`Waiting for chat`** shows the reservation a stopped VS Code watch leaves, from any window: `retry after