diff --git a/e2e/agent-review.spec.ts b/e2e/agent-review.spec.ts index 2f4a78b..ad5f928 100644 --- a/e2e/agent-review.spec.ts +++ b/e2e/agent-review.spec.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { closeElectronApp, ensureFilesViewOpenInLeftPanel, + expectWorkspaceSessionOpenFilePaths, fileTreeFile, launchDevElectronAppWithArgs, registerRendererConsoleLogging, @@ -20,6 +21,7 @@ test("Atelier reveals a review after Codex edits restored markdown", async ({ const workspaceDir = testInfo.outputPath("workspace"); const welcomeFilePath = path.join(workspaceDir, "welcome.md"); const changelogFilePath = path.join(workspaceDir, "changelog.md"); + const fallbackFilePath = path.join(workspaceDir, "fallback.md"); const createdFilePath = path.join(workspaceDir, "codex-created.md"); const fakeBinDir = testInfo.outputPath("fake-bin"); const fakeCodexCompletionPath = testInfo.outputPath("fake-codex-complete"); @@ -49,10 +51,15 @@ test("Atelier reveals a review after Codex edits restored markdown", async ({ registerRendererConsoleLogging(page); await openWelcomeMarkdown(page); - await expectOpenFilePersisted(page, "/welcome.md"); + await expectWorkspaceSessionOpenFilePaths(userDataDir, workspaceDir, [ + "welcome.md", + ]); await closeElectronApp(electronApp); electronApp = undefined; + const fallbackMarkdownTime = new Date(newestMarkdownTime.getTime() + 1_000); + await writeFile(fallbackFilePath, "# Fallback\n", "utf8"); + await utimes(fallbackFilePath, fallbackMarkdownTime, fallbackMarkdownTime); electronApp = await launchDevElectronAppWithArgs([], { userDataDir }); page = await electronApp.firstWindow(); @@ -84,7 +91,9 @@ test("Atelier reveals a review after Codex edits restored markdown", async ({ .toContain("Codex created file"); await expect( - page.getByRole("group", { name: /^Review change 1 of \d+$/ }), + page.getByRole("group", { + name: /^Review change 1 of \d+(?:, \d+ remaining)?$/, + }), ).toBeVisible(); await expect( page.getByRole("button", { name: "Keep change" }), @@ -129,23 +138,6 @@ async function openWelcomeMarkdown(page: Page): Promise { ).toBeVisible(); } -async function expectOpenFilePersisted( - page: Page, - filePath: string, -): Promise { - await expect - .poll(async () => { - return await page.evaluate(async (key) => { - const result = await window.flashtypeDesktop?.lix.execute({ - sql: "SELECT value FROM lix_key_value_by_branch WHERE key = $1 AND lixcol_branch_id = 'global'", - params: [key], - }); - return JSON.stringify(result?.rows?.[0]?.[0] ?? null); - }, "atelier_ui_state"); - }) - .toContain(filePath); -} - async function writeFakeCodex(binDir: string): Promise { await mkdir(binDir, { recursive: true }); const scriptPath = path.join(binDir, "codex"); diff --git a/e2e/branch-switcher.spec.ts b/e2e/branch-switcher.spec.ts index f4019d2..5f9a3c6 100644 --- a/e2e/branch-switcher.spec.ts +++ b/e2e/branch-switcher.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; import type { ElectronApplication } from "playwright"; +import type { AtelierSessionUiState } from "@opral/atelier"; import { LocalFilesystem, openLix } from "@lix-js/sdk"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -871,6 +872,21 @@ type ActiveCentralDocumentIdentity = { readonly filePath: string; }; +async function atelierSessionStateFromUi( + page: Page, +): Promise { + return await page.evaluate(() => { + const e2e = ( + window as Window & { + __flashtypeE2E?: { + getAtelierSessionState: () => AtelierSessionUiState | null; + }; + } + ).__flashtypeE2E; + return e2e?.getAtelierSessionState() ?? null; + }); +} + async function expectActiveCentralDocumentIdentityForPath( page: Page, appPath: string, @@ -904,73 +920,34 @@ async function expectActiveCentralDocumentIdentity( async function activeCentralDocumentIdentityFromUi( page: Page, ): Promise { - return await page.evaluate(async () => { - const result = await window.flashtypeDesktop?.lix.execute({ - sql: "SELECT value FROM lix_key_value_by_branch WHERE key = $1 AND lixcol_branch_id = $2", - params: ["atelier_ui_state", "global"], - }); - const state = result?.rows?.[0]?.[0] as - | { - panels?: { - central?: { - activeInstance?: string | null; - views?: Array<{ - instance?: unknown; - kind?: unknown; - state?: { fileId?: unknown; filePath?: unknown }; - }>; - }; - }; - } - | undefined; - const central = state?.panels?.central; - const views = central?.views ?? []; - const active = - views.find((view) => view.instance === central?.activeInstance) ?? - views[0]; - const instance = active?.instance; - const kind = active?.kind; - const fileId = active?.state?.fileId; - const filePath = active?.state?.filePath; - if ( - typeof instance !== "string" || - typeof kind !== "string" || - typeof fileId !== "string" || - typeof filePath !== "string" - ) { - return null; - } - return { instance, kind, fileId, filePath }; - }); + const state = await atelierSessionStateFromUi(page); + const central = state?.panels.central; + const views = central?.views ?? []; + const active = + views.find((view) => view.instance === central?.activeInstance) ?? views[0]; + const instance = active?.instance; + const kind = active?.kind; + const fileId = active?.state?.fileId; + const filePath = active?.state?.filePath; + if ( + typeof instance !== "string" || + typeof kind !== "string" || + typeof fileId !== "string" || + typeof filePath !== "string" + ) { + return null; + } + return { instance, kind, fileId, filePath }; } async function activeCentralFilePathFromUi(page: Page): Promise { - return await page.evaluate(async () => { - const result = await window.flashtypeDesktop?.lix.execute({ - sql: "SELECT value FROM lix_key_value_by_branch WHERE key = $1 AND lixcol_branch_id = $2", - params: ["atelier_ui_state", "global"], - }); - const state = result?.rows?.[0]?.[0] as - | { - panels?: { - central?: { - activeInstance?: string | null; - views?: Array<{ - instance?: string; - state?: { filePath?: unknown }; - }>; - }; - }; - } - | undefined; - const central = state?.panels?.central; - const views = central?.views ?? []; - const active = - views.find((view) => view.instance === central?.activeInstance) ?? - views[0]; - const filePath = active?.state?.filePath; - return typeof filePath === "string" ? filePath : null; - }); + const state = await atelierSessionStateFromUi(page); + const central = state?.panels.central; + const views = central?.views ?? []; + const active = + views.find((view) => view.instance === central?.activeInstance) ?? views[0]; + const filePath = active?.state?.filePath; + return typeof filePath === "string" ? filePath : null; } async function expectActiveEditorRevisionState( @@ -996,113 +973,66 @@ async function expectSingleCentralDocumentSlot(page: Page): Promise { } async function documentSlotViolationsFromUi(page: Page): Promise { - return await page.evaluate(async () => { - const result = await window.flashtypeDesktop?.lix.execute({ - sql: "SELECT value FROM lix_key_value_by_branch WHERE key = $1 AND lixcol_branch_id = $2", - params: ["atelier_ui_state", "global"], - }); - type ViewState = { - readonly fileId?: unknown; - }; - type View = { - readonly instance?: unknown; - readonly kind?: unknown; - readonly state?: ViewState; - }; - type Panel = { - readonly activeInstance?: unknown; - readonly views?: View[]; - }; - const state = result?.rows?.[0]?.[0] as - | { - panels?: { - left?: Panel; - central?: Panel; - right?: Panel; - }; - } - | undefined; - const panels = state?.panels ?? {}; - const violations: string[] = []; - const isDocumentView = (view: View): boolean => { - const fileId = view.state?.fileId; - return ( - typeof view.kind === "string" && - typeof view.instance === "string" && - typeof fileId === "string" && - view.instance === `${view.kind}:${fileId}` - ); - }; - for (const side of ["left", "right"] as const) { - const documentViews = panels[side]?.views?.filter(isDocumentView) ?? []; - if (documentViews.length > 0) { - violations.push(`${side} has ${documentViews.length} document view(s)`); - } - } - const central = panels.central; - const centralViews = central?.views ?? []; - if (centralViews.length > 1) { - violations.push(`central has ${centralViews.length} views`); - } - const centralView = centralViews[0]; - if (centralView && !isDocumentView(centralView)) { - violations.push("central view is not a document view"); - } - if ( - centralView && - typeof centralView.instance === "string" && - central?.activeInstance !== centralView.instance - ) { - violations.push("central document view is not active"); + const panels = (await atelierSessionStateFromUi(page))?.panels; + const violations: string[] = []; + const isDocumentView = ( + view: AtelierSessionUiState["panels"]["central"]["views"][number], + ): boolean => { + const fileId = view.state?.fileId; + return ( + typeof view.kind === "string" && + typeof view.instance === "string" && + typeof fileId === "string" && + view.instance === `${view.kind}:${fileId}` + ); + }; + for (const side of ["left", "right"] as const) { + const documentViews = panels?.[side].views.filter(isDocumentView) ?? []; + if (documentViews.length > 0) { + violations.push(`${side} has ${documentViews.length} document view(s)`); } - return violations; - }); + } + const central = panels?.central; + const centralViews = central?.views ?? []; + if (centralViews.length > 1) { + violations.push(`central has ${centralViews.length} views`); + } + const centralView = centralViews[0]; + if (centralView && !isDocumentView(centralView)) { + violations.push("central view is not a document view"); + } + if ( + centralView && + typeof centralView.instance === "string" && + central?.activeInstance !== centralView.instance + ) { + violations.push("central document view is not active"); + } + return violations; } async function activeEditorRevisionStateFromUi(page: Page): Promise<{ beforeCommitId: string | null; afterCommitId: string | null; } | null> { - return await page.evaluate(async () => { - const result = await window.flashtypeDesktop?.lix.execute({ - sql: "SELECT value FROM lix_key_value_by_branch WHERE key = $1 AND lixcol_branch_id = $2", - params: ["atelier_ui_state", "global"], - }); - const state = result?.rows?.[0]?.[0] as - | { - panels?: { - central?: { - activeInstance?: string | null; - views?: Array<{ - instance?: string; - state?: { - beforeCommitId?: unknown; - afterCommitId?: unknown; - }; - }>; - }; - }; - } - | undefined; - const central = state?.panels?.central; - const views = central?.views ?? []; - const active = - views.find((view) => view.instance === central?.activeInstance) ?? - views[0]; - if (!active) return null; - const beforeCommitId = active.state?.beforeCommitId; - const afterCommitId = active.state?.afterCommitId; - return { - beforeCommitId: - typeof beforeCommitId === "string" && beforeCommitId.length > 0 - ? beforeCommitId - : null, - afterCommitId: - typeof afterCommitId === "string" && afterCommitId.length > 0 - ? afterCommitId - : null, - }; - }); + const state = await atelierSessionStateFromUi(page); + const central = state?.panels.central; + const views = central?.views ?? []; + const active = + views.find((view) => view.instance === central?.activeInstance) ?? views[0]; + if (!active) return null; + const beforeCommitId = active.state?.beforeCommitId; + const afterCommitId = active.state?.afterCommitId; + return { + beforeCommitId: + typeof beforeCommitId === "string" && beforeCommitId.length > 0 + ? beforeCommitId + : null, + afterCommitId: + typeof afterCommitId === "string" && afterCommitId.length > 0 + ? afterCommitId + : null, + }; } async function expectFileTreeStatuses( diff --git a/e2e/electron-test-utils.ts b/e2e/electron-test-utils.ts index 755f716..a57c27a 100644 --- a/e2e/electron-test-utils.ts +++ b/e2e/electron-test-utils.ts @@ -233,6 +233,31 @@ export async function expectPathMissing(filePath: string): Promise { .toBe(true); } +export async function expectWorkspaceSessionOpenFilePaths( + userDataDir: string, + workspacePath: string, + openFilePaths: string[], +): Promise { + await expect + .poll(async () => { + try { + const store = JSON.parse( + await readFile( + path.join(userDataDir, "workspace-session.json"), + "utf8", + ), + ); + const workspace = Array.isArray(store.workspaces) + ? store.workspaces.find((entry: any) => entry.path === workspacePath) + : undefined; + return workspace?.openFilePaths ?? null; + } catch { + return null; + } + }) + .toEqual(openFilePaths); +} + export async function closeElectronApp( electronApp: ElectronApplication | undefined, ): Promise { diff --git a/e2e/files-click-tour.spec.ts b/e2e/files-click-tour.spec.ts index c5055e4..eebd1f0 100644 --- a/e2e/files-click-tour.spec.ts +++ b/e2e/files-click-tour.spec.ts @@ -41,12 +41,7 @@ test("left files panel survives a seeded random file click tour", async ({ const csvFile = fileTreeFile(page, "/metrics.csv"); await csvFile.click(); await expect(csvFile).toHaveAttribute("data-item-selected", "true"); - await expect( - page.locator('[data-active="true"][data-view-key="atelier_csv"]'), - ).toBeVisible(); - await expect( - page.getByRole("banner").getByText("metrics.csv", { exact: true }), - ).toBeVisible(); + await expectActiveFileView(page, "metrics.csv"); await expectCsvGridCanvasToRender(page); for (let index = 0; index < clickCount; index += 1) { @@ -68,13 +63,7 @@ test("left files panel survives a seeded random file click tour", async ({ await test.step(`click ${index + 1}/${clickCount}: file index ${fileIndex}, delay ${delayMs}ms`, async () => { await file.click(); await expect(file).toHaveAttribute("data-item-selected", "true"); - await expect( - page - .locator( - '[data-panel-side="central"][data-active="true"][data-view-key="atelier_file"]:visible, [data-panel-side="central"][data-active="true"][data-view-key="atelier_csv"]:visible', - ) - .first(), - ).toBeVisible(); + await expectActiveFileView(page, treePath); if (treePath === "metrics.csv") { await expectCsvGridCanvasToRender(page); } @@ -86,6 +75,25 @@ test("left files panel survives a seeded random file click tour", async ({ } }); +async function expectActiveFileView( + page: Page, + treePath: string, +): Promise { + const fileName = treePath.split("/").at(-1); + if (!fileName) { + throw new Error(`File path '${treePath}' did not include a file name`); + } + const viewKey = treePath.endsWith(".csv") ? "atelier_csv" : "atelier_file"; + await expect( + page.locator( + `[data-panel-side="central"][data-active="true"][data-view-key="${viewKey}"]`, + ), + ).toBeVisible(); + await expect( + page.getByRole("banner").getByText(fileName, { exact: true }), + ).toBeVisible(); +} + async function expectCsvGridCanvasToRender(page: Page): Promise { const canvas = page .locator('[data-active="true"][data-view-key="atelier_csv"] canvas') diff --git a/e2e/workspace-change-stress.spec.ts b/e2e/workspace-change-stress.spec.ts index 0ad0f83..764cb48 100644 --- a/e2e/workspace-change-stress.spec.ts +++ b/e2e/workspace-change-stress.spec.ts @@ -138,23 +138,20 @@ test("stress tests workspace changes through manual edits and fake agent turns", page, proposedMarkdown, }), + { cause: error }, ); } await timeProfile(profile, "agent:click-review", index, async () => { - await page - .getByRole("button", { - name: keep ? "Keep change" : "Undo change", - }) - .click(); + await resolveReview(page, keep); }); await timeProfile( profile, "agent:wait-review-hidden", index, async () => { - await expect( - page.getByRole("button", { name: "Keep change" }), - ).toBeHidden({ timeout: 30_000 }); + await expect(reviewUndoButton(page)).toBeHidden({ + timeout: 30_000, + }); }, ); @@ -582,10 +579,64 @@ async function runFakeAgentTurn( } async function waitForReviewControls(page: Page): Promise { - await expect(page.getByRole("button", { name: "Keep change" })).toBeVisible({ + await expect(reviewControls(page)).toBeVisible({ timeout: 30_000, }); - await expect(page.getByRole("button", { name: "Undo change" })).toBeVisible(); + await expect(reviewKeepButton(page)).toBeVisible(); + await expect(reviewUndoButton(page)).toBeVisible(); +} + +async function resolveReview(page: Page, keep: boolean): Promise { + const remainingCount = await reviewRemainingCount(page); + if (keep) { + if (remainingCount > 1) { + await expect(reviewKeepAllButton(page)).toBeVisible(); + await reviewKeepAllButton(page).click(); + return; + } + await reviewKeepButton(page).click(); + return; + } + + for ( + let resolvedCount = 0; + resolvedCount < remainingCount; + resolvedCount += 1 + ) { + await reviewUndoButton(page).click(); + if (resolvedCount + 1 < remainingCount) { + await expect + .poll(async () => await reviewRemainingCount(page), { + timeout: 30_000, + }) + .toBe(remainingCount - resolvedCount - 1); + } + } +} + +function reviewControls(page: Page) { + return page.locator('[role="group"][aria-label^="Review change "]'); +} + +function reviewKeepButton(page: Page) { + return page.locator('[data-attr="review-change-keep"]'); +} + +function reviewKeepAllButton(page: Page) { + return page.locator('[data-attr="review-change-keep-all"]'); +} + +function reviewUndoButton(page: Page) { + return page.locator('[data-attr="review-change-undo"]'); +} + +async function reviewRemainingCount(page: Page): Promise { + const label = await reviewControls(page).getAttribute("aria-label"); + const match = /^Review change \d+ of \d+, (\d+) remaining$/.exec(label ?? ""); + if (!match) { + throw new Error(`Could not read remaining review changes from ${label}.`); + } + return Number(match[1]); } async function buildAgentReviewTimeoutMessage(args: { diff --git a/e2e/workspace-windows.spec.ts b/e2e/workspace-windows.spec.ts index 7b430db..b7e55bd 100644 --- a/e2e/workspace-windows.spec.ts +++ b/e2e/workspace-windows.spec.ts @@ -10,6 +10,7 @@ import { fileTreeFile, launchDevElectronAppWithArgs, registerRendererConsoleLogging, + expectWorkspaceSessionOpenFilePaths, } from "./electron-test-utils"; test("launching with multiple workspace args creates independent windows", async ({ @@ -505,6 +506,14 @@ test("macOS open-file events create workspace windows for folders and files", as .locator('[data-view-key="atelier_file"][data-active="true"]') .first(), ).toBeVisible(); + await expect(filePage.getByLabel("Toggle left panel")).toHaveAttribute( + "aria-pressed", + "false", + ); + await expect(filePage.getByLabel("Toggle right panel")).toHaveAttribute( + "aria-pressed", + "false", + ); await expect(filePage.getByTestId("central-panel-empty-state")).toHaveCount( 0, ); @@ -542,6 +551,15 @@ test("macOS open-file events open standalone files as transient workspaces", asy .first(), ).toBeVisible(); await expect(filePage.getByRole("heading", { name: "Solo" })).toBeVisible(); + await expect(filePage.getByLabel("Toggle left panel")).toHaveAttribute( + "aria-pressed", + "false", + ); + await expect(filePage.getByLabel("Toggle right panel")).toHaveAttribute( + "aria-pressed", + "false", + ); + await filePage.getByLabel("Toggle left panel").click(); await expect(fileTreeFile(filePage, "/sibling.md")).toBeVisible(); await expectPathMissing(path.join(directory, ".lix")); await expectPathMissing(path.join(directory, ".lix_system")); @@ -616,6 +634,12 @@ test("launching with multiple standalone markdown files creates one grouped tran await expect( groupedPage.getByRole("heading", { name: "Alpha" }), ).toBeVisible(); + await expect(groupedPage.getByLabel("Toggle left panel")).toHaveAttribute( + "aria-pressed", + "false", + ); + await groupedPage.getByLabel("Toggle left panel").click(); + await fileTreeDirectory(groupedPage, "/standalone-markdown-alpha/").click(); await expect( fileTreeFile(groupedPage, "/standalone-markdown-alpha/alpha.md"), ).toBeVisible(); @@ -723,6 +747,10 @@ test("mixed folder and standalone markdown args create folder and grouped file w await expect( groupedFilePage.getByRole("heading", { name: "One" }), ).toBeVisible(); + await expect( + groupedFilePage.getByLabel("Toggle left panel"), + ).toHaveAttribute("aria-pressed", "false"); + await groupedFilePage.getByLabel("Toggle left panel").click(); await expect( fileTreeFile(groupedFilePage, "/folder-marker.md"), ).toHaveCount(0); @@ -1025,28 +1053,6 @@ async function expectWorkspaceSessionPaths( .toEqual(workspacePaths); } -async function expectWorkspaceSessionOpenFilePaths( - userDataDir: string, - workspacePath: string, - openFilePaths: string[], -): Promise { - await expect - .poll(async () => { - try { - const store = JSON.parse( - await readFile(workspaceSessionPath(userDataDir), "utf8"), - ); - const workspace = Array.isArray(store.workspaces) - ? store.workspaces.find((entry: any) => entry.path === workspacePath) - : undefined; - return workspace?.openFilePaths ?? null; - } catch { - return null; - } - }) - .toEqual(openFilePaths); -} - function workspaceSessionPath(userDataDir: string): string { return path.join(userDataDir, "workspace-session.json"); } diff --git a/electron/agent-executable-paths.mjs b/electron/agent-executable-paths.mjs index 8101767..3fe1b59 100644 --- a/electron/agent-executable-paths.mjs +++ b/electron/agent-executable-paths.mjs @@ -5,6 +5,7 @@ import pty from "node-pty"; const AGENT_EXECUTABLE_NAMES = ["claude", "codex"]; const AGENT_PATH_RESOLUTION_TIMEOUT_MS = 8_000; const MAX_PROBE_OUTPUT_LENGTH = 16 * 1024; +const PROBE_EXIT_DRAIN_GRACE_MS = 50; let cachedAgentExecutablePaths = emptyAgentExecutablePaths(); @@ -39,6 +40,7 @@ export function runAgentExecutablePathProbe(args) { env: { ...args.env, FLASHTYPE_AGENT_PATH_RESOLVE_END: markers.end, + FLASHTYPE_AGENT_PATH_RESOLVE_SCRIPT: buildProbeScript(), FLASHTYPE_AGENT_PATH_RESOLVE_START: markers.start, }, }); @@ -49,12 +51,24 @@ export function runAgentExecutablePathProbe(args) { let output = ""; let settled = false; + let exitDrainTimeout; + let exitResult; + const armExitDrain = () => { + if (settled || !exitResult) { + return; + } + clearTimeout(exitDrainTimeout); + exitDrainTimeout = setTimeout(() => { + finish({ ...exitResult, timedOut: false }); + }, PROBE_EXIT_DRAIN_GRACE_MS); + }; const finish = (result) => { if (settled) { return; } settled = true; clearTimeout(timeout); + clearTimeout(exitDrainTimeout); const extracted = extractProbeResult(output, markers); resolve({ ...result, @@ -74,18 +88,26 @@ export function runAgentExecutablePathProbe(args) { terminal.onData((data) => { output = appendBoundedOutput(output, data); - }); - terminal.onExit(({ exitCode, signal }) => { - setTimeout(() => { + if (output.includes(markers.end)) { finish({ - exitCode: exitCode ?? null, - signal: signal ?? null, + ...(exitResult ?? { exitCode: null, signal: null }), timedOut: false, }); - }, 0); + return; + } + armExitDrain(); }); - terminal.write(`${buildProbeCommandLine()}\r`); - terminal.write("exit\r"); + terminal.onExit(({ exitCode, signal }) => { + if (settled) { + return; + } + exitResult = { + exitCode: exitCode ?? null, + signal: signal ?? null, + }; + armExitDrain(); + }); + terminal.write('/bin/sh -c "$FLASHTYPE_AGENT_PATH_RESOLVE_SCRIPT"; exit\r'); }); } @@ -97,8 +119,8 @@ function createProbeMarkers() { }; } -function buildProbeCommandLine() { - const script = [ +function buildProbeScript() { + return [ 'printf "%s\\n" "$FLASHTYPE_AGENT_PATH_RESOLVE_START"', "__flashtype_resolve_agent_path() {", ' __flashtype_agent_name="$1"', @@ -125,7 +147,6 @@ function buildProbeCommandLine() { 'printf "%s\\n" "$FLASHTYPE_AGENT_PATH_RESOLVE_END"', "exit 0", ].join("\n"); - return `/bin/sh -c ${shellQuote(script)}`; } function extractProbeResult(output, markers) { @@ -204,10 +225,6 @@ function appendBoundedOutput(current, next) { return output.slice(output.length - MAX_PROBE_OUTPUT_LENGTH); } -function shellQuote(value) { - return `'${String(value).replace(/'/gu, `'\\''`)}'`; -} - function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } diff --git a/electron/agent-executable-paths.test.mjs b/electron/agent-executable-paths.test.mjs index 334737a..0c0a7e7 100644 --- a/electron/agent-executable-paths.test.mjs +++ b/electron/agent-executable-paths.test.mjs @@ -47,6 +47,47 @@ describe("agent executable path resolution", () => { } }); + unixTest( + "resolves paths through a shell that rejects long pasted commands", + async () => { + const rootDir = await mkdtemp( + path.join(tmpdir(), "flashtype-agent-path-test-"), + ); + try { + resetAgentExecutablePathsForTests(); + const binDir = path.join(rootDir, "bin"); + const shellPath = path.join(rootDir, "short-command-shell"); + await mkdir(binDir); + await writeExecutable(path.join(binDir, "claude"), "exit 0"); + await writeExecutable(path.join(binDir, "codex"), "exit 0"); + await writeExecutable( + shellPath, + [ + "IFS= read -r command || exit 1", + '[ "${#command}" -le 80 ] || exit 1', + '/bin/sh -c "$command"', + ].join("\n"), + ); + + const paths = await refreshAgentExecutablePaths( + resolverArgs({ + cwd: rootDir, + PATH: binDir, + shell: shellPath, + }), + ); + + expect(paths).toEqual({ + claude: await realpath(path.join(binDir, "claude")), + codex: await realpath(path.join(binDir, "codex")), + }); + } finally { + await rm(rootDir, { recursive: true, force: true }); + resetAgentExecutablePathsForTests(); + } + }, + ); + unixTest("updates cached paths when agents are missing", async () => { const rootDir = await mkdtemp( path.join(tmpdir(), "flashtype-agent-path-test-"), @@ -122,7 +163,7 @@ function resolverArgs(options = {}) { PATH: options.PATH ?? process.env.PATH, TERM: "xterm-256color", }, - shell: "/bin/sh", + shell: options.shell ?? "/bin/sh", shellArgs: [], timeoutMs: options.timeoutMs, }; diff --git a/electron/checkpoint-name.test.mjs b/electron/checkpoint-name.test.mjs index a3b74bc..7210dca 100644 --- a/electron/checkpoint-name.test.mjs +++ b/electron/checkpoint-name.test.mjs @@ -171,9 +171,10 @@ function generatorArgs(options = {}) { }, shell: "/bin/sh", shellArgs: [], - // Shell startup can exceed one second when the full Vitest suite is - // saturating the host. Keep this well below the production timeout while - // avoiding a machine-speed-dependent timestamp fallback. + // The node-pty executable-path probe can be delayed when the full Vitest + // suite is saturating the host. Keep the fake agent command short while + // giving that independent probe enough headroom to find the test binary. + pathResolutionTimeoutMs: 20_000, timeoutMs: 5_000, }; } diff --git a/electron/types.d.ts b/electron/types.d.ts index 4d4d6ef..9093fa8 100644 --- a/electron/types.d.ts +++ b/electron/types.d.ts @@ -241,12 +241,14 @@ export type DesktopWorkspace = ephemeral: false; path: string; name: string; + initialPanelMode?: "document"; openFilePaths?: never; } | { ephemeral: true; path: string; name: string; + initialPanelMode?: "document"; openFilePaths: string[]; }; diff --git a/electron/workspace.mjs b/electron/workspace.mjs index b0dca81..619dc3f 100644 --- a/electron/workspace.mjs +++ b/electron/workspace.mjs @@ -63,7 +63,7 @@ export async function resolveWorkspaceTarget(requestedPath) { }; } return { - workspace: createPersistentWorkspace(workspaceDir), + workspace: createPersistentWorkspace(workspaceDir, "document"), pendingOpenFilePaths: [ workspaceRelativeFilePath(workspaceDir, resolved), ].filter(Boolean), @@ -1074,22 +1074,28 @@ async function resolveStandaloneFile(resolvedPath) { return workspaceDir ? null : resolvedPath; } -function createPersistentWorkspace(workspacePath) { +function createPersistentWorkspace(workspacePath, initialPanelMode) { const resolvedPath = path.resolve(workspacePath); return { ephemeral: false, path: resolvedPath, name: path.basename(resolvedPath) || resolvedPath, + ...(initialPanelMode ? { initialPanelMode } : {}), }; } -function createEphemeralWorkspace(workspacePath, openFilePaths = []) { +function createEphemeralWorkspace( + workspacePath, + openFilePaths = [], + initialPanelMode, +) { const resolvedPath = path.resolve(workspacePath); return { ephemeral: true, path: resolvedPath, openFilePaths: uniqueWorkspaceRelativeFilePaths(openFilePaths), name: path.basename(resolvedPath) || resolvedPath, + ...(initialPanelMode ? { initialPanelMode } : {}), }; } @@ -1220,6 +1226,7 @@ function createTransientDirectoryWorkspace(filePaths) { normalizedFilePaths .map((filePath) => workspaceRelativeFilePath(workspacePath, filePath)) .filter(Boolean), + "document", ); } diff --git a/electron/workspace.test.ts b/electron/workspace.test.ts index 19a268f..925920f 100644 --- a/electron/workspace.test.ts +++ b/electron/workspace.test.ts @@ -268,6 +268,7 @@ describe("workspace resolution", () => { await expect(resolveWorkspace(filePath)).resolves.toEqual({ ephemeral: false, + initialPanelMode: "document", path: directory, name: "workspace", }); @@ -289,6 +290,7 @@ describe("workspace resolution", () => { await expect(resolveWorkspaceTarget(filePath)).resolves.toEqual({ workspace: { ephemeral: false, + initialPanelMode: "document", path: directory, name: "workspace", }, @@ -312,6 +314,7 @@ describe("workspace resolution", () => { await expect(resolveWorkspaceTarget(filePath)).resolves.toEqual({ workspace: { ephemeral: false, + initialPanelMode: "document", path: directory, name: "workspace", }, @@ -333,6 +336,7 @@ describe("workspace resolution", () => { await expect(resolveWorkspace(filePath)).resolves.toEqual({ ephemeral: false, + initialPanelMode: "document", path: directory, name: "workspace", }); @@ -354,6 +358,7 @@ describe("workspace resolution", () => { await expect(resolveWorkspaceTarget(filePath)).resolves.toEqual({ workspace: { ephemeral: false, + initialPanelMode: "document", path: directory, name: "workspace", }, @@ -377,6 +382,7 @@ describe("workspace resolution", () => { { workspace: { ephemeral: false, + initialPanelMode: "document", path: directory, name: "workspace", }, @@ -401,6 +407,7 @@ describe("workspace resolution", () => { { workspace: { ephemeral: false, + initialPanelMode: "document", path: directory, name: "workspace", }, @@ -447,6 +454,7 @@ describe("workspace resolution", () => { { workspace: { ephemeral: false, + initialPanelMode: "document", path: firstDirectory, name: "first", }, @@ -455,6 +463,7 @@ describe("workspace resolution", () => { { workspace: { ephemeral: false, + initialPanelMode: "document", path: secondDirectory, name: "second", }, @@ -477,6 +486,7 @@ describe("workspace resolution", () => { await expect(resolveWorkspaceTarget(filePath)).resolves.toEqual({ workspace: { ephemeral: true, + initialPanelMode: "document", path: directory, openFilePaths: ["readme.md"], name: "workspace", @@ -504,6 +514,7 @@ describe("workspace resolution", () => { { workspace: { ephemeral: true, + initialPanelMode: "document", path: directory, openFilePaths: ["alpha.md", "nested/beta.markdown"], name: "workspace", @@ -551,6 +562,7 @@ describe("workspace resolution", () => { { workspace: { ephemeral: false, + initialPanelMode: "document", path: firstDirectory, name: "first", }, @@ -559,6 +571,7 @@ describe("workspace resolution", () => { { workspace: { ephemeral: false, + initialPanelMode: "document", path: secondDirectory, name: "second", }, @@ -586,6 +599,7 @@ describe("workspace resolution", () => { { workspace: { ephemeral: true, + initialPanelMode: "document", path: directory, openFilePaths: ["alpha.txt", "beta.csv"], name: "workspace", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 395d9d3..6c87491 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,7 +217,7 @@ importers: version: 7.3.0(@types/node@24.10.15)(jiti@2.7.0)(lightningcss@1.32.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.0.3 - version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.15)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.4.0)(lightningcss@1.32.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.15)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(lightningcss@1.32.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) wait-on: specifier: ^9.0.4 version: 9.0.4 @@ -344,6 +344,9 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + unicode-emoji-json: + specifier: 0.9.0 + version: 0.9.0 use-sync-external-store: specifier: ^1.6.0 version: 1.6.0(react@19.2.7) @@ -387,6 +390,9 @@ importers: happy-dom: specifier: ^20.10.6 version: 20.10.6 + jsdom: + specifier: ^29.1.1 + version: 29.1.1(@noble/hashes@2.2.0) oxfmt: specifier: ^0.58.0 version: 0.58.0 @@ -416,27 +422,30 @@ importers: version: 8.1.4(@types/node@24.10.15)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.15)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@8.1.4(@types/node@24.10.15)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.15)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.4(@types/node@24.10.15)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) wrangler: specifier: ^4.110.0 version: 4.110.0 packages: - '@acemir/cssom@0.9.30': - resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} - '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@asamuzakjp/css-color@4.1.1': - resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - '@asamuzakjp/dom-selector@6.7.6': - resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} @@ -659,6 +668,10 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@bufbuild/protobuf@2.10.2': resolution: {integrity: sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==} @@ -754,37 +767,41 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@csstools/color-helpers@5.1.0': - resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} - engines: {node: '>=18'} + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} - '@csstools/css-calc@2.1.4': - resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} - engines: {node: '>=18'} + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-color-parser@3.1.0': - resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} - engines: {node: '>=18'} + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-parser-algorithms@3.0.5': - resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} - engines: {node: '>=18'} + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.0.22': - resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} - engines: {node: '>=18'} + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true - '@csstools/css-tokenizer@3.0.4': - resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} - engines: {node: '>=18'} + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} @@ -1187,13 +1204,13 @@ packages: cpu: [x64] os: [win32] - '@exodus/bytes@1.8.0': - resolution: {integrity: sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: - '@exodus/crypto': ^1.0.0-rc.4 + '@noble/hashes': ^1.8.0 || ^2.0.0 peerDependenciesMeta: - '@exodus/crypto': + '@noble/hashes': optional: true '@floating-ui/core@1.7.3': @@ -3969,17 +3986,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-tree@3.1.0: - resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - cssstyle@5.3.6: - resolution: {integrity: sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==} - engines: {node: '>=20'} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -4139,9 +4152,9 @@ packages: dagre-d3-es@7.0.14: resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} - data-urls@6.0.0: - resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} - engines: {node: '>=20'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} dayjs@1.11.21: resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} @@ -4309,6 +4322,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -4725,9 +4742,9 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true - jsdom@27.4.0: - resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 peerDependenciesMeta: @@ -4965,8 +4982,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.4: - resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -5054,8 +5071,8 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - mdn-data@2.12.2: - resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} mermaid@11.16.0: resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} @@ -5402,8 +5419,8 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parse5@8.0.0: - resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -6169,8 +6186,8 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - tough-cookie@6.0.0: - resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} tr46@6.0.0: @@ -6234,6 +6251,9 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-emoji-json@0.9.0: + resolution: {integrity: sha512-HdrQW/7SdbXUsTd8uTATv7LvQJkCSgbAgRCDCtOhQ6xn3EAx+xZHQtRniOXWJK3ywIqPegkNhP4U4EbuFsiQng==} + unique-filename@4.0.0: resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -6496,14 +6516,14 @@ packages: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - - whatwg-url@15.1.0: - resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -6618,9 +6638,6 @@ packages: snapshots: - '@acemir/cssom@0.9.30': - optional: true - '@adobe/css-tools@4.4.4': {} '@antfu/install-pkg@1.1.0': @@ -6628,26 +6645,25 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@asamuzakjp/css-color@4.1.1': + '@asamuzakjp/css-color@5.1.11': dependencies: - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 11.2.4 - optional: true + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - '@asamuzakjp/dom-selector@6.7.6': + '@asamuzakjp/dom-selector@7.1.1': dependencies: + '@asamuzakjp/generational-cache': 1.0.1 '@asamuzakjp/nwsapi': 2.3.9 bidi-js: 1.0.3 - css-tree: 3.1.0 + css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.4 - optional: true - '@asamuzakjp/nwsapi@2.3.9': - optional: true + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} '@babel/code-frame@7.27.1': dependencies: @@ -6904,6 +6920,10 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@bufbuild/protobuf@2.10.2': optional: true @@ -7026,33 +7046,29 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@csstools/color-helpers@5.1.0': - optional: true + '@csstools/color-helpers@6.1.0': {} - '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - optional: true + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/color-helpers': 5.1.0 - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - optional: true + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/css-tokenizer': 3.0.4 - optional: true + '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.0.22': - optional: true + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 - '@csstools/css-tokenizer@3.0.4': - optional: true + '@csstools/css-tokenizer@4.0.0': {} '@dnd-kit/accessibility@3.1.1(react@19.2.0)': dependencies: @@ -7405,8 +7421,9 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@exodus/bytes@1.8.0': - optional: true + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 '@floating-ui/core@1.7.3': dependencies: @@ -9674,7 +9691,6 @@ snapshots: bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 - optional: true binaryen@130.0.0: {} @@ -9912,22 +9928,13 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-tree@3.1.0: + css-tree@3.2.1: dependencies: - mdn-data: 2.12.2 + mdn-data: 2.27.1 source-map-js: 1.2.1 - optional: true css.escape@1.5.1: {} - cssstyle@5.3.6: - dependencies: - '@asamuzakjp/css-color': 4.1.1 - '@csstools/css-syntax-patches-for-csstree': 1.0.22 - css-tree: 3.1.0 - lru-cache: 11.2.4 - optional: true - csstype@3.2.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): @@ -10114,11 +10121,12 @@ snapshots: d3: 7.9.0 lodash-es: 4.18.1 - data-urls@6.0.0: + data-urls@7.0.0(@noble/hashes@2.2.0): dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 15.1.0 - optional: true + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' dayjs@1.11.21: {} @@ -10126,8 +10134,7 @@ snapshots: dependencies: ms: 2.1.3 - decimal.js@10.6.0: - optional: true + decimal.js@10.6.0: {} decode-named-character-reference@1.3.0: dependencies: @@ -10329,6 +10336,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} err-code@2.0.3: {} @@ -10673,12 +10682,11 @@ snapshots: html-element-attributes@1.3.1: {} - html-encoding-sniffer@6.0.0: + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): dependencies: - '@exodus/bytes': 1.8.0 + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) transitivePeerDependencies: - - '@exodus/crypto' - optional: true + - '@noble/hashes' http-cache-semantics@4.2.0: {} @@ -10752,8 +10760,7 @@ snapshots: dependencies: isobject: 3.0.1 - is-potential-custom-element-name@1.0.1: - optional: true + is-potential-custom-element-name@1.0.1: {} is-unicode-supported@0.1.0: {} @@ -10803,34 +10810,31 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@27.4.0: + jsdom@29.1.1(@noble/hashes@2.2.0): dependencies: - '@acemir/cssom': 0.9.30 - '@asamuzakjp/dom-selector': 6.7.6 - '@exodus/bytes': 1.8.0 - cssstyle: 5.3.6 - data-urls: 6.0.0 + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.2.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) is-potential-custom-element-name: 1.0.1 - parse5: 8.0.0 + lru-cache: 11.5.2 + parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 6.0.0 + tough-cookie: 6.0.2 + undici: 7.28.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 15.1.0 - ws: 8.21.0 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - - '@exodus/crypto' - - bufferutil - - supports-color - - utf-8-validate - optional: true + - '@noble/hashes' jsesc@3.1.0: {} @@ -11000,8 +11004,7 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.4: - optional: true + lru-cache@11.5.2: {} lru-cache@5.1.1: dependencies: @@ -11167,8 +11170,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - mdn-data@2.12.2: - optional: true + mdn-data@2.27.1: {} mermaid@11.16.0: dependencies: @@ -11685,10 +11687,9 @@ snapshots: dependencies: entities: 6.0.1 - parse5@8.0.0: + parse5@8.0.1: dependencies: - entities: 6.0.1 - optional: true + entities: 8.0.0 path-data-parser@0.1.0: {} @@ -11911,8 +11912,7 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 - punycode@2.3.1: - optional: true + punycode@2.3.1: {} pvtsutils@1.3.6: dependencies: @@ -12283,7 +12283,6 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 - optional: true scheduler@0.27.0: {} @@ -12443,8 +12442,7 @@ snapshots: dependencies: has-flag: 4.0.0 - symbol-tree@3.2.4: - optional: true + symbol-tree@3.2.4: {} sync-child-process@1.0.2: dependencies: @@ -12520,13 +12518,11 @@ snapshots: tinyrainbow@3.1.0: {} - tldts-core@7.0.19: - optional: true + tldts-core@7.0.19: {} tldts@7.0.19: dependencies: tldts-core: 7.0.19 - optional: true tmp-promise@3.0.3: dependencies: @@ -12539,15 +12535,13 @@ snapshots: is-number: 7.0.0 optional: true - tough-cookie@6.0.0: + tough-cookie@6.0.2: dependencies: tldts: 7.0.19 - optional: true tr46@6.0.0: dependencies: punycode: 2.3.1 - optional: true tree-kill@1.2.2: {} @@ -12613,6 +12607,8 @@ snapshots: dependencies: pathe: 2.0.3 + unicode-emoji-json@0.9.0: {} + unique-filename@4.0.0: dependencies: unique-slug: 5.0.0 @@ -12742,7 +12738,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.15)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@27.4.0)(lightningcss@1.32.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.15)(happy-dom@18.0.1)(jiti@2.7.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(lightningcss@1.32.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.16 '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@24.10.15)(jiti@2.7.0)(lightningcss@1.32.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -12768,7 +12764,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@types/node': 24.10.15 happy-dom: 18.0.1 - jsdom: 27.4.0 + jsdom: 29.1.1(@noble/hashes@2.2.0) transitivePeerDependencies: - jiti - less @@ -12782,7 +12778,7 @@ snapshots: - tsx - yaml - vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.15)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@8.1.4(@types/node@24.10.15)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.15)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.4(@types/node@24.10.15)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@24.10.15)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -12808,7 +12804,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@types/node': 24.10.15 happy-dom: 20.10.6 - jsdom: 27.4.0 + jsdom: 29.1.1(@noble/hashes@2.2.0) transitivePeerDependencies: - msw @@ -12817,7 +12813,6 @@ snapshots: w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 - optional: true wait-on@9.0.4: dependencies: @@ -12843,19 +12838,19 @@ snapshots: asn1js: 3.0.10 tslib: 2.8.1 - webidl-conversions@8.0.1: - optional: true + webidl-conversions@8.0.1: {} whatwg-mimetype@3.0.0: {} - whatwg-mimetype@4.0.0: - optional: true + whatwg-mimetype@5.0.0: {} - whatwg-url@15.1.0: + whatwg-url@16.0.1(@noble/hashes@2.2.0): dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) tr46: 6.0.0 webidl-conversions: 8.0.1 - optional: true + transitivePeerDependencies: + - '@noble/hashes' which@2.0.2: dependencies: @@ -12914,13 +12909,11 @@ snapshots: ws@8.21.0: {} - xml-name-validator@5.0.0: - optional: true + xml-name-validator@5.0.0: {} xmlbuilder@15.1.1: {} - xmlchars@2.2.0: - optional: true + xmlchars@2.2.0: {} y18n@5.0.8: {} diff --git a/src/extension-runtime/types.ts b/src/extension-runtime/types.ts index 7a6132b..35cbf79 100644 --- a/src/extension-runtime/types.ts +++ b/src/extension-runtime/types.ts @@ -24,11 +24,13 @@ export type WorkspaceContext = readonly ephemeral: false; readonly path: string; readonly name: string; + readonly initialPanelMode?: "document"; } | { readonly ephemeral: true; readonly path: string; readonly name: string; + readonly initialPanelMode?: "document"; readonly openFilePaths: readonly string[]; }; diff --git a/src/extensions/files/host-extension.test.tsx b/src/extensions/files/host-extension.test.tsx index 851f898..4bbb0f4 100644 --- a/src/extensions/files/host-extension.test.tsx +++ b/src/extensions/files/host-extension.test.tsx @@ -93,4 +93,90 @@ describe("createFilesExtensionRegistration", () => { mounted?.dispose?.(); }); }); + + test("does not let a slow watched-file import supersede a newer file open", async () => { + const query = { + selectFrom: vi.fn(), + select: vi.fn(), + where: vi.fn(), + executeTakeFirst: executeTakeFirstMock, + }; + query.selectFrom.mockReturnValue(query); + query.select.mockReturnValue(query); + query.where.mockReturnValue(query); + qbMock.mockReturnValue(query); + executeTakeFirstMock + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ id: "imported-welcome" }); + + let resolveImport!: () => void; + const importPromise = new Promise((resolve) => { + resolveImport = resolve; + }); + const importFilesystemPaths = vi.fn(() => importPromise); + const open = vi.fn().mockResolvedValue(undefined); + const atelier = { + lix: { importFilesystemPaths }, + documents: { open, closeActive: vi.fn().mockResolvedValue(undefined) }, + revisions: { + current: null, + show: vi.fn(), + clear: vi.fn(), + }, + } as unknown as AtelierExtensionRuntime; + const registration = createFilesExtensionRegistration({ + ephemeral: true, + path: "/workspace", + name: "workspace", + openFilePaths: [], + }); + const element = document.createElement("div"); + document.body.append(element); + + let mounted: ReturnType; + await act(async () => { + mounted = registration.entry.mount({ + element, + atelier, + view: { + instanceId: "files-1", + state: {}, + panel: "left", + isActive: true, + isFocused: true, + registerNewFileDraftHandler: () => () => {}, + }, + signal: new AbortController().signal, + }); + }); + + const context = filesViewMock.mock.calls.at(-1)?.[0] + .context as ExtensionContext; + if (!context.openFile) { + throw new Error("Files extension did not provide openFile"); + } + const staleOpen = context.openFile({ + panel: "central", + fileId: "watched:/welcome.md", + filePath: "/welcome.md", + }); + await vi.waitFor(() => { + expect(importFilesystemPaths).toHaveBeenCalledWith(["welcome.md"]); + }); + + await context.openFile({ + panel: "central", + fileId: "file_metrics", + filePath: "/metrics.csv", + }); + resolveImport(); + await staleOpen; + + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith("/metrics.csv", {}); + + await act(async () => { + mounted?.dispose?.(); + }); + }); }); diff --git a/src/extensions/files/host-extension.tsx b/src/extensions/files/host-extension.tsx index cc79919..5efb6f6 100644 --- a/src/extensions/files/host-extension.tsx +++ b/src/extensions/files/host-extension.tsx @@ -32,12 +32,22 @@ export function createFilesExtensionRegistration( icon: Files, mount: ({ element, atelier, view }) => { const root = createRoot(element); - renderFilesView(root, workspace, atelier, view); + const openRequest = { current: 0 }; + renderFilesView(root, workspace, atelier, view, openRequest); return { update: ({ atelier: nextAtelier, view: nextView }) => { - renderFilesView(root, workspace, nextAtelier, nextView); + renderFilesView( + root, + workspace, + nextAtelier, + nextView, + openRequest, + ); + }, + dispose: () => { + openRequest.current += 1; + root.unmount(); }, - dispose: () => root.unmount(), }; }, }, @@ -49,20 +59,27 @@ function renderFilesView( workspace: WorkspaceContext, atelier: AtelierExtensionRuntime, view: AtelierExtensionView, + openRequest: { current: number }, ) { const lix = atelier.lix as unknown as Lix; const context: ExtensionContext = { lix, workspace, - openFile: ({ fileId, filePath, state, focus, pending, documentOrigin }) => - openWorkspaceFile(atelier, { - fileId, - filePath, - state, - focus, - pending, - documentOrigin, - }), + openFile: ({ fileId, filePath, state, focus, pending, documentOrigin }) => { + const requestId = ++openRequest.current; + return openWorkspaceFile( + atelier, + { + fileId, + filePath, + state, + focus, + pending, + documentOrigin, + }, + () => requestId === openRequest.current, + ); + }, closeFileViews: () => { void atelier.documents.closeActive(); }, @@ -93,6 +110,7 @@ async function openWorkspaceFile( readonly pending?: boolean; readonly documentOrigin?: "existing" | "new"; }, + isCurrentRequest: () => boolean, ): Promise { const lix = atelier.lix as unknown as Lix; if (args.fileId.startsWith("watched:")) { @@ -101,18 +119,22 @@ async function openWorkspaceFile( .select("id") .where("path", "=", args.filePath) .executeTakeFirst(); + if (!isCurrentRequest()) return; if (!importedFile?.id) { await lix.importFilesystemPaths([args.filePath.replace(/^\/+/, "")]); + if (!isCurrentRequest()) return; importedFile = await qb(lix) .selectFrom("lix_file") .select("id") .where("path", "=", args.filePath) .executeTakeFirst(); + if (!isCurrentRequest()) return; } if (!importedFile?.id) { throw new Error(`Imported file id not found for '${args.filePath}'.`); } } + if (!isCurrentRequest()) return; await atelier.documents.open(args.filePath, { ...(args.state ? { state: args.state } : {}), ...(args.focus !== undefined ? { focus: args.focus } : {}), diff --git a/src/extensions/files/index.test.tsx b/src/extensions/files/index.test.tsx index 39741f9..dd1f423 100644 --- a/src/extensions/files/index.test.tsx +++ b/src/extensions/files/index.test.tsx @@ -7,7 +7,7 @@ import { FilesView } from "./index"; import type { ExtensionContext } from "../../extension-runtime/types"; import { qb } from "@/lib/lix-kysely"; import { - appendAgentTurnCommitRange, + AGENT_TURN_COMMIT_RANGE_KEY_PREFIX, type AgentTurnCommitRange, } from "@/shell/agent-turn-review-range"; import type { @@ -1198,14 +1198,22 @@ describe("FilesView", () => { await writeReviewFile(lix, "file_review", "/docs/review.md", "after"); await writeReviewFile(lix, "file_clean", "/docs/clean.md", "same"); const afterCommitId = await activeCommitId(lix); - await appendAgentTurnCommitRange( - lix, - agentRange({ - id: "range-files-tree-review", - beforeCommitId, - afterCommitId, - }), - ); + const range = agentRange({ + id: "range-files-tree-review", + beforeCommitId, + afterCommitId, + }); + const activeBranchId = await lix.activeBranchId(); + await qb(lix) + .insertInto("lix_key_value_by_branch") + .values({ + key: `${AGENT_TURN_COMMIT_RANGE_KEY_PREFIX}${encodeURIComponent(range.id)}`, + value: range, + lixcol_branch_id: activeBranchId, + lixcol_global: activeBranchId === "global", + lixcol_untracked: true, + }) + .execute(); let utils: ReturnType; await act(async () => { diff --git a/src/extensions/files/index.tsx b/src/extensions/files/index.tsx index 9e34e5d..4b50777 100644 --- a/src/extensions/files/index.tsx +++ b/src/extensions/files/index.tsx @@ -25,7 +25,7 @@ import type { import type { Lix } from "@/lib/lix-types"; import { AGENT_TURN_COMMIT_RANGE_KEY, - isAgentTurnCommitRangeStore, + agentTurnCommitRangesFromValues, } from "@/shell/agent-turn-review-range"; import { getPendingExternalWriteReviewPaths, @@ -1165,8 +1165,8 @@ function usePendingExternalWriteReviewPaths( const reviewRangeEvents = lix.observe( `SELECT value, lixcol_branch_id FROM lix_key_value_by_branch - WHERE key = ?`, - [AGENT_TURN_COMMIT_RANGE_KEY], + WHERE key LIKE ?`, + [`${AGENT_TURN_COMMIT_RANGE_KEY}%`], ); const watchEvents = async ( events: ReturnType, @@ -1202,16 +1202,15 @@ function usePendingExternalWriteReviewPaths( .executeTakeFirst(); const activeBranchId = typeof activeBranch?.value === "string" ? activeBranch.value : ""; - const rangeRow = await qb(lix) + const rangeRows = await qb(lix) .selectFrom("lix_key_value_by_branch") .select("value") - .where("key", "=", AGENT_TURN_COMMIT_RANGE_KEY) + .where("key", "like", `${AGENT_TURN_COMMIT_RANGE_KEY}%`) .where("lixcol_branch_id", "=", activeBranchId) - .limit(1) - .executeTakeFirst(); - const ranges = isAgentTurnCommitRangeStore(rangeRow?.value) - ? rangeRow.value.ranges - : []; + .execute(); + const ranges = agentTurnCommitRangesFromValues( + rangeRows.map((row) => row.value), + ); if (ranges.length === 0) { if (!cancelled) { setPendingPaths((prev) => (prev.size === 0 ? prev : new Set())); diff --git a/src/lib/atelier-document-state.test.ts b/src/lib/atelier-document-state.test.ts index 8415cea..3bd1c82 100644 --- a/src/lib/atelier-document-state.test.ts +++ b/src/lib/atelier-document-state.test.ts @@ -51,6 +51,33 @@ describe("readCurrentAtelierDocumentPath", () => { }); }); + test("reads host session state without consulting the legacy Lix key", async () => { + const lix = createTestLix({ + state: null, + files: { file_one: "/current.md" }, + }); + + await expect( + readAtelierDocumentSessionState( + lix, + uiState( + [documentView("file_one", "/original.md")], + "atelier_file:file_one", + ), + ), + ).resolves.toEqual({ + activePath: "/current.md", + openPaths: ["/current.md"], + }); + expect( + vi + .mocked(lix.execute) + .mock.calls.some(([sql]) => + String(sql).includes("lix_key_value_by_branch"), + ), + ).toBe(false); + }); + test("returns null for the Files landing view", async () => { const lix = createTestLix({ state: uiState( diff --git a/src/lib/atelier-document-state.ts b/src/lib/atelier-document-state.ts index a5b4ad2..ec816a7 100644 --- a/src/lib/atelier-document-state.ts +++ b/src/lib/atelier-document-state.ts @@ -20,9 +20,13 @@ export type AtelierDocumentSessionState = { /** Reads the central documents Atelier has persisted in Lix. */ export async function readAtelierDocumentSessionState( lix: Lix, + uiState?: unknown, ): Promise { - const stateResult = await readAtelierUiState(lix); - const candidates = documentCandidates(readResultValue(stateResult, "value")); + const rawState = + uiState === undefined + ? readResultValue(await readAtelierUiState(lix), "value") + : uiState; + const candidates = documentCandidates(rawState); if (candidates.views.length === 0) { return { activePath: null, openPaths: [] }; } @@ -57,8 +61,9 @@ export async function readAtelierDocumentSessionState( /** Reads Atelier's current central document from its persisted Lix UI state. */ export async function readCurrentAtelierDocumentPath( lix: Lix, + uiState?: unknown, ): Promise { - return (await readAtelierDocumentSessionState(lix)).activePath; + return (await readAtelierDocumentSessionState(lix, uiState)).activePath; } function readAtelierUiState(lix: Lix): Promise { diff --git a/src/lib/atelier-workspace-bridge.test.ts b/src/lib/atelier-workspace-bridge.test.ts index 6ad90a2..e482106 100644 --- a/src/lib/atelier-workspace-bridge.test.ts +++ b/src/lib/atelier-workspace-bridge.test.ts @@ -126,6 +126,44 @@ describe("connectAtelierWorkspace", () => { connection.dispose(); }); + test("persists host session-store document changes", async () => { + const harness = createHarness({ activeDocumentPath: "/first.md" }); + let snapshot = sessionUiState("active-file", "/first.md"); + const listeners = new Set<() => void>(); + const unsubscribe = vi.fn(); + const connection = connectAtelierWorkspace({ + ...harness.options, + sessionStateStore: { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + unsubscribe(); + }; + }, + }, + }); + await connection.ready; + await waitFor(() => + expect(harness.workspace.setSessionOpenFilePaths).toHaveBeenCalledWith({ + filePaths: ["first.md"], + }), + ); + + harness.setActiveDocument("/second.md"); + snapshot = sessionUiState("active-file", "/second.md"); + for (const listener of listeners) listener(); + + await waitFor(() => + expect( + harness.workspace.setSessionOpenFilePaths, + ).toHaveBeenLastCalledWith({ filePaths: ["second.md"] }), + ); + connection.dispose(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + test("persists external active-file renames and deletions", async () => { const harness = createHarness({ activeDocumentPath: "/first.md" }); const connection = connectAtelierWorkspace(harness.options); @@ -269,6 +307,7 @@ function createHarness( open: vi.fn(async () => {}), startNew: vi.fn(async () => {}), closeActive: vi.fn(async () => {}), + closeAll: vi.fn(async () => {}), }; let newFileListener: (() => void) | undefined; let closeFileListener: (() => void) | undefined; @@ -341,6 +380,17 @@ function uiState(fileId: string, filePath: string) { }; } +function sessionUiState(fileId: string, filePath: string) { + return { + focusedPanel: "central" as const, + panels: { + left: { views: [], activeInstance: null }, + central: uiState(fileId, filePath).panels.central, + right: { views: [], activeInstance: null }, + }, + }; +} + function queryResult( row: readonly unknown[], columns: readonly string[], diff --git a/src/lib/atelier-workspace-bridge.ts b/src/lib/atelier-workspace-bridge.ts index 5c2d3e7..09dd233 100644 --- a/src/lib/atelier-workspace-bridge.ts +++ b/src/lib/atelier-workspace-bridge.ts @@ -1,4 +1,7 @@ -import type { AtelierDocumentsApi } from "@opral/atelier"; +import type { + AtelierDocumentsApi, + AtelierSessionStateStore, +} from "@opral/atelier"; import type { Lix } from "@/lib/lix-types"; import { qb } from "@/lib/lix-kysely"; import { @@ -18,6 +21,10 @@ type DesktopWorkspaceBridge = Pick< type ConnectAtelierWorkspaceOptions = { readonly documents: AtelierDocumentsApi; readonly lix: Lix; + readonly sessionStateStore?: Pick< + AtelierSessionStateStore, + "getSnapshot" | "subscribe" + >; readonly workspace: DesktopWorkspaceBridge; readonly onError?: (error: unknown) => void; readonly openWorkspacePath?: (path: string) => Promise; @@ -58,13 +65,15 @@ export function connectAtelierWorkspace( const unsubscribeCloseFile = workspace.onCloseFile(() => { void documents.closeActive().catch(reportError); }); - const uiStateEvents = options.lix.observe( - `SELECT value - FROM lix_key_value_by_branch - WHERE key = $1 - AND lixcol_branch_id = $2`, - ["atelier_ui_state", "global"], - ); + const uiStateEvents = options.sessionStateStore + ? null + : options.lix.observe( + `SELECT value + FROM lix_key_value_by_branch + WHERE key = $1 + AND lixcol_branch_id = $2`, + ["atelier_ui_state", "global"], + ); const filePathEvents = options.lix.observe( `SELECT id, path FROM lix_file @@ -79,7 +88,10 @@ export function connectAtelierWorkspace( .catch(() => undefined) .then(async () => { if (abortController.signal.aborted) return; - const state = await readAtelierDocumentSessionState(options.lix); + const state = await readAtelierDocumentSessionState( + options.lix, + options.sessionStateStore?.getSnapshot(), + ); if (abortController.signal.aborted) return; await workspace.setSessionOpenFilePaths({ filePaths: state.openPaths.map((path) => path.replace(/^\/+/, "")), @@ -96,8 +108,11 @@ export function connectAtelierWorkspace( persistSessionDocuments(); } }; - void watchSessionEvents(uiStateEvents).catch(reportError); + if (uiStateEvents) void watchSessionEvents(uiStateEvents).catch(reportError); void watchSessionEvents(filePathEvents).catch(reportError); + const unsubscribeSessionState = options.sessionStateStore?.subscribe( + persistSessionDocuments, + ); const ready = (async () => { const pendingOpenFiles = await workspace.consumePendingOpenFiles(); @@ -114,13 +129,25 @@ export function connectAtelierWorkspace( return; } - if (await readCurrentAtelierDocumentPath(options.lix)) return; + if ( + await readCurrentAtelierDocumentPath( + options.lix, + options.sessionStateStore?.getSnapshot(), + ) + ) + return; const recent = await workspace.getMostRecentMarkdownFile(); if (abortController.signal.aborted || !recent?.path) return; // The filesystem scan is asynchronous. Re-read the persisted source of // truth so a user open that landed meanwhile wins over the fallback. - if (await readCurrentAtelierDocumentPath(options.lix)) return; + if ( + await readCurrentAtelierDocumentPath( + options.lix, + options.sessionStateStore?.getSnapshot(), + ) + ) + return; if (!abortController.signal.aborted) { await openWorkspacePath(recent.path); } @@ -135,8 +162,9 @@ export function connectAtelierWorkspace( dispose: () => { if (abortController.signal.aborted) return; abortController.abort(); - uiStateEvents.close(); + uiStateEvents?.close(); filePathEvents.close(); + unsubscribeSessionState?.(); unsubscribeNewFile(); unsubscribeCloseFile(); }, diff --git a/src/main.tsx b/src/main.tsx index ed2e3ff..e7e8415 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,8 +11,10 @@ import { createRoot } from "react-dom/client"; import { Atelier, createAtelier, + createMemorySessionStateStore, type AtelierInstance, type AtelierExtensionRegistration, + type AtelierSessionUiState, } from "@opral/atelier"; import "@opral/atelier/style.css"; import "./index.css"; @@ -44,7 +46,16 @@ type WorkspaceRecovery = Awaited< > >; -const DEFAULT_OPEN_ATELIER_PANELS = ["right"] as const; +declare global { + interface Window { + __flashtypeE2E?: { + getAtelierSessionState: () => AtelierSessionUiState | null; + }; + } +} + +const DEFAULT_OPEN_ATELIER_PANELS = ["left", "right"] as const; +const DOCUMENT_OPEN_ATELIER_PANELS = [] as const; /** * The workspace gates the app: without a folder, only the first-run screen @@ -76,6 +87,27 @@ export const AppRoot = () => { () => (lix ? createAtelierTelemetryHandler(lix) : undefined), [lix], ); + const defaultOpenAtelierPanels = + workspace?.initialPanelMode === "document" + ? DOCUMENT_OPEN_ATELIER_PANELS + : DEFAULT_OPEN_ATELIER_PANELS; + const atelierSession = useMemo( + () => ({ lix, store: createMemorySessionStateStore() }), + [lix], + ); + const atelierSessionStateStore = atelierSession.store; + useEffect(() => { + if (!import.meta.env.DEV) return; + const e2e = { + getAtelierSessionState: () => atelierSessionStateStore.getSnapshot(), + }; + window.__flashtypeE2E = e2e; + return () => { + if (window.__flashtypeE2E === e2e) { + delete window.__flashtypeE2E; + } + }; + }, [atelierSessionStateStore]); const atelier = useMemo( () => lix @@ -85,11 +117,18 @@ export const AppRoot = () => { lix: lix as unknown as AtelierInstance["lix"], extensions: atelierExtensions, filesViewMode: "sidebar", - defaultOpenPanels: DEFAULT_OPEN_ATELIER_PANELS, + defaultOpenPanels: defaultOpenAtelierPanels, + sessionStateStore: atelierSessionStateStore, onEvent: handleAtelierEvent, }) : null, - [lix, atelierExtensions, handleAtelierEvent], + [ + lix, + atelierExtensions, + defaultOpenAtelierPanels, + atelierSessionStateStore, + handleAtelierEvent, + ], ); useEffect(() => { @@ -257,6 +296,7 @@ export const AppRoot = () => { const connection = connectAtelierWorkspace({ documents: atelier.documents, lix, + sessionStateStore: atelierSessionStateStore, workspace: desktopWorkspace, onError: setError, }); @@ -267,7 +307,7 @@ export const AppRoot = () => { cancelled = true; connection.dispose(); }; - }, [atelier, lix]); + }, [atelier, atelierSessionStateStore, lix]); useEffect(() => { if (!lix || !atelier) return; diff --git a/src/shell/agent-turn-review-range.ts b/src/shell/agent-turn-review-range.ts index bde856b..ca973ba 100644 --- a/src/shell/agent-turn-review-range.ts +++ b/src/shell/agent-turn-review-range.ts @@ -3,6 +3,8 @@ import { qb } from "@/lib/lix-kysely"; export const AGENT_TURN_COMMIT_RANGE_KEY = "atelier_agent_turn_commit_range" as const; +export const AGENT_TURN_COMMIT_RANGE_KEY_PREFIX = + `${AGENT_TURN_COMMIT_RANGE_KEY}:` as const; const agentTurnCommitRangeMutationQueues = new WeakMap>(); @@ -31,16 +33,16 @@ export function agentTurnReviewId( export async function readAgentTurnCommitRanges( lix: Lix, + branchId?: string, ): Promise { - const branchId = await lix.activeBranchId(); - const row = await qb(lix) + const resolvedBranchId = branchId ?? (await lix.activeBranchId()); + const rows = await qb(lix) .selectFrom("lix_key_value_by_branch") .select("value") - .where("key", "=", AGENT_TURN_COMMIT_RANGE_KEY) - .where("lixcol_branch_id", "=", branchId) - .limit(1) - .executeTakeFirst(); - return isAgentTurnCommitRangeStore(row?.value) ? row.value.ranges : []; + .where("key", "like", `${AGENT_TURN_COMMIT_RANGE_KEY}%`) + .where("lixcol_branch_id", "=", resolvedBranchId) + .execute(); + return agentTurnCommitRangesFromValues(rows.map((row) => row.value)); } export async function appendAgentTurnCommitRange( @@ -149,7 +151,43 @@ export function isAgentTurnCommitRangeStore( ); } -function isAgentTurnCommitRange(value: unknown): value is AgentTurnCommitRange { +export function agentTurnCommitRangesFromValues( + values: readonly unknown[], +): readonly AgentTurnCommitRange[] { + const rangesById = new Map(); + for (const value of values) { + const ranges = isAgentTurnCommitRangeStore(value) + ? value.ranges + : isAgentTurnCommitRange(value) + ? [value] + : []; + for (const range of ranges) { + const existing = rangesById.get(range.id); + if (!existing) { + rangesById.set(range.id, serializeAgentTurnCommitRange(range)); + continue; + } + const clearedFileIds = [ + ...new Set([ + ...(existing.clearedFileIds ?? []), + ...(range.clearedFileIds ?? []), + ]), + ]; + rangesById.set(range.id, { + ...existing, + ...(clearedFileIds.length > 0 ? { clearedFileIds } : {}), + }); + } + } + return [...rangesById.values()].sort( + (left, right) => + left.completedAt - right.completedAt || left.id.localeCompare(right.id), + ); +} + +export function isAgentTurnCommitRange( + value: unknown, +): value is AgentTurnCommitRange { if (!value || typeof value !== "object") { return false; } diff --git a/src/shell/external-write-review-history.test.ts b/src/shell/external-write-review-history.test.ts index 67cc435..4ff69da 100644 --- a/src/shell/external-write-review-history.test.ts +++ b/src/shell/external-write-review-history.test.ts @@ -609,7 +609,15 @@ function gateInitialReviewRangeSnapshot(lix: Lix): { ...lix, observe(sql, params = []) { const events = originalObserve(sql, params); - if (!params.includes(AGENT_TURN_COMMIT_RANGE_KEY)) return events; + if ( + !params.some( + (param) => + typeof param === "string" && + param.startsWith(AGENT_TURN_COMMIT_RANGE_KEY), + ) + ) { + return events; + } let isFirstSnapshot = true; return { async next() { diff --git a/src/shell/external-write-review-history.ts b/src/shell/external-write-review-history.ts index 54a6745..f2b5d03 100644 --- a/src/shell/external-write-review-history.ts +++ b/src/shell/external-write-review-history.ts @@ -9,8 +9,8 @@ import type { } from "@/extension-runtime/external-write-review"; import { AGENT_TURN_COMMIT_RANGE_KEY, + agentTurnCommitRangesFromValues, agentTurnReviewId, - isAgentTurnCommitRangeStore, readAgentTurnCommitRanges, type AgentTurnCommitRange, } from "./agent-turn-review-range"; @@ -97,8 +97,8 @@ export function useExternalWriteReview(args: { const reviewRangeEvents = lix.observe( `SELECT value, lixcol_branch_id FROM lix_key_value_by_branch - WHERE key = ?`, - [AGENT_TURN_COMMIT_RANGE_KEY], + WHERE key LIKE ?`, + [`${AGENT_TURN_COMMIT_RANGE_KEY}%`], ); const watchEvents = async ( events: ReturnType, @@ -169,16 +169,13 @@ async function readActiveBranchReviewRanges( .executeTakeFirst(); const activeBranchId = typeof activeBranch?.value === "string" ? activeBranch.value : ""; - const rangeRow = await qb(lix) + const rangeRows = await qb(lix) .selectFrom("lix_key_value_by_branch") .select("value") - .where("key", "=", AGENT_TURN_COMMIT_RANGE_KEY) + .where("key", "like", `${AGENT_TURN_COMMIT_RANGE_KEY}%`) .where("lixcol_branch_id", "=", activeBranchId) - .limit(1) - .executeTakeFirst(); - return isAgentTurnCommitRangeStore(rangeRow?.value) - ? rangeRow.value.ranges - : []; + .execute(); + return agentTurnCommitRangesFromValues(rangeRows.map((row) => row.value)); } export function useExternalWriteReviewData( diff --git a/submodule/atelier b/submodule/atelier index 1190767..fdd588b 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit 1190767fe85695a39653b21b4b3b1b94d901eb5e +Subproject commit fdd588bac79ceedfb5fa7f7f615405ac2cc670d6