diff --git a/.railwayignore b/.railwayignore new file mode 100644 index 00000000..70b2b7aa --- /dev/null +++ b/.railwayignore @@ -0,0 +1,4 @@ +.theorem/ +.git/ +test-results/ +**/*.spec.ts-snapshots/ diff --git a/apps/console/e2e/cards.spec.ts b/apps/console/e2e/cards.spec.ts new file mode 100644 index 00000000..0935140b --- /dev/null +++ b/apps/console/e2e/cards.spec.ts @@ -0,0 +1,253 @@ +// SOURCING: @playwright/test. The cards/actions/mentions oracles +// (HANDOFF-CARDS-ACTIONS-MENTIONS K7): live person and task cards render +// through their templates, relation chips navigate, the three sheet entries +// open one identical sheet, the submitted pack equals the visible chip set +// exactly (the named invariant probe of the round), and the mentions +// round-trip confirms and suppresses through the seam. Baselines capture the +// grid, the full card, and the sheet. + +import { expect, test, type Page } from '@playwright/test'; + +async function settled(page: Page) { + await page.waitForSelector('[data-shell]'); + await page.waitForTimeout(600); +} + +async function freshLoad(page: Page) { + await page.goto('/'); + await page.evaluate(() => window.localStorage.removeItem('commonplace.console.surface.v1')); + await page.reload(); + await settled(page); +} + +async function openSurface(page: Page, surfaceId: string) { + // Screen navigation is the leftmost stripe's surfaces group. + await page.locator(`[data-surface-nav="${surfaceId}"]`).click(); + await expect(page.locator('[data-shell]')).toHaveAttribute('data-active-surface', surfaceId); +} + +test.describe('cards, actions, mentions', () => { + test.beforeEach(async ({ page }) => { + await freshLoad(page); + }); + + test('the surface rail is the primary nav: far-left, switches screens', async ({ page }) => { + const rail = page.locator('[data-surface-rail]'); + await expect(rail).toBeVisible(); + // Every seeded surface has a rail entry; the active one marks aria-current. + await expect(rail.locator('[data-surface-nav]')).toHaveCount(5); + await expect(rail.locator('[data-surface-nav="console-workspace"]')).toHaveAttribute( + 'aria-current', + 'page', + ); + // Clicking a rail entry switches the surface without the toolbar dropdown. + await rail.locator('[data-surface-nav="console-cards"]').click(); + await expect(page.locator('[data-shell]')).toHaveAttribute('data-active-surface', 'console-cards'); + await expect(rail.locator('[data-surface-nav="console-cards"]')).toHaveAttribute( + 'aria-current', + 'page', + ); + }); + + test('the grid renders live person and task cards through their templates', async ({ page }) => { + await openSurface(page, 'console-cards'); + const person = page.locator('[data-card-kind="person"]'); + await expect(person).toBeVisible(); + await expect(person.getByText('Ada Lovelace')).toBeVisible(); + await expect(person.getByText('Analyst')).toBeVisible(); + await expect(page.locator('[data-card-kind="task"]')).toBeVisible(); + // Objects of a kind with no template render the generic card, never an + // error: the org and project cells are present as generic faces. + await expect(page.locator('[data-card-cell="org-braintrust"] [data-card-kind="generic"]')).toBeVisible(); + }); + + test('relation chips are live objects: a chip opens the related card', async ({ page }) => { + await openSurface(page, 'console-cards'); + await page.locator('[data-card-chip="WORKS_AT"]').first().click(); + const inspector = page.getByLabel('Record inspector'); + await expect(inspector).toBeVisible(); + await expect(inspector.getByText('Braintrust').first()).toBeVisible(); + // The related org has no template of its own: generic card, never an error. + await expect(inspector.locator('[data-card-kind="generic"]')).toBeVisible(); + }); + + test('the full card renders through the descriptor registry with gauge and facts', async ({ page }) => { + // The arrangement is data: seed a surface hosting card.full over the live + // task query, exactly as a user arrangement would. + await page.evaluate(() => { + const raw = window.localStorage.getItem('commonplace.console.surface.v1'); + const objects = raw ? JSON.parse(raw) : []; + const filtered = objects.filter( + (o: { id: string }) => !['e2e-card-surface', 'e2e.region', 'e2e.vi-card'].includes(o.id), + ); + for (const object of filtered) { + if (object.type === 'surface') object.properties.active = false; + } + filtered.push( + { id: 'e2e-card-surface', type: 'surface', properties: { name: 'CardProof', kind: 'workspace', active: true }, relations: { CONTAINS: ['e2e.region'] } }, + { id: 'e2e.region', type: 'region', properties: { kind: 'editor', size: 100, active_tab: 'e2e.vi-card' }, relations: { CONTAINS: ['e2e.vi-card'] } }, + { id: 'e2e.vi-card', type: 'view-instance', properties: { descriptor_id: 'card.full', title: 'Task card', query: { types: ['task'], page: { limit: 1 } } }, relations: { CONTAINS: [] } }, + ); + window.localStorage.setItem('commonplace.console.surface.v1', JSON.stringify(filtered)); + }); + await page.reload(); + await settled(page); + const card = page.locator('[data-card="full"]'); + // The injected surface's card.full queries the task over the live wire on + // a freshly compiled route; allow headroom so a cold, parallel-loaded dev + // server does not flake this behavioral assertion. + await expect(card).toBeVisible({ timeout: 15000 }); + await expect(card.getByText('Send the compliance report')).toBeVisible(); + await expect(card.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '40'); + await expect(card.getByText('high')).toBeVisible(); + await expect(card.locator('[data-card-chip="IN_PROJECT"]')).toBeVisible(); + }); + + test('the grid virtualizes past 200 cards', async ({ page }) => { + await page.evaluate(() => { + const raw = window.localStorage.getItem('commonplace.console.surface.v1'); + const objects = raw ? JSON.parse(raw) : []; + for (const object of objects) { + if (object.type === 'surface') object.properties.active = false; + } + objects.push( + { id: 'e2e-grid-surface', type: 'surface', properties: { name: 'GridProof', kind: 'workspace', active: true }, relations: { CONTAINS: ['e2e.grid-region'] } }, + { id: 'e2e.grid-region', type: 'region', properties: { kind: 'editor', size: 100, active_tab: 'e2e.vi-grid' }, relations: { CONTAINS: ['e2e.vi-grid'] } }, + { id: 'e2e.vi-grid', type: 'view-instance', properties: { descriptor_id: 'cards.grid', title: 'Grid proof', query: { types: ['record'], page: { limit: 400 } } }, relations: { CONTAINS: [] } }, + ); + window.localStorage.setItem('commonplace.console.surface.v1', JSON.stringify(objects)); + }); + await page.reload(); + await settled(page); + await expect(page.locator('[data-cards-grid]')).toBeVisible(); + const rendered = await page.locator('[data-card-cell]').count(); + expect(rendered).toBeGreaterThan(0); + expect(rendered).toBeLessThan(400); + // Keyboard focus order is row-major: the first two cells are adjacent in + // tab order because the DOM order is row-major. + await page.locator('[data-card-cell]').first().focus(); + await page.keyboard.press('Tab'); + const secondId = await page.locator('[data-card-cell]').nth(1).getAttribute('data-card-cell'); + const focusedId = await page.evaluate(() => + document.activeElement?.getAttribute('data-card-cell'), + ); + expect(focusedId).toBe(secondId); + }); + + test('all three entries open the identical sheet; the pack equals the chips', async ({ page }) => { + // Entry 1: the Action verb from the inspector. Click the title text: a + // center-click can land on a relation chip, which is its own navigation. + await openSurface(page, 'console-cards'); + await page.locator('[data-card-cell="person-ada"]').getByText('Ada Lovelace').click(); + await page.locator('[data-inspector-action]').click(); + const sheet = page.locator('[data-action-sheet]'); + await expect(sheet).toBeVisible(); + await expect(sheet.locator('[data-context-chip="origin"]')).toContainText('Ada Lovelace'); + + // The no-silent-context probe (the named invariant test of the round): + // capture the submitted pack and compare it to the visible chips exactly. + await sheet.getByLabel('Instruction').fill('review the memoir margins'); + const visibleChips = await sheet.locator('[data-context-chip]').allTextContents(); + const packPromise = page.waitForRequest('**/api/harness/delegate'); + await sheet.getByRole('button', { name: 'Hand off' }).click(); + const request = await packPromise; + const pack = request.postDataJSON() as { + instruction: string; + context: Array<{ label: string }>; + }; + expect(pack.instruction).toBe('review the memoir margins'); + expect(pack.context.length).toBe(visibleChips.length); + expect(pack.context[0].label).toBe('Ada Lovelace'); + for (const entry of pack.context) { + expect(visibleChips.some((text) => text.includes(entry.label))).toBe(true); + } + // With the harness delegate unconfigured in e2e, For me renders the + // named unavailable state and With me remains available (K4). + await expect(sheet.locator('[data-delegate-refused]')).toContainText('CONSOLE_HARNESS_URL'); + await sheet.locator('[data-destination="with-me"]').click(); + await sheet.getByRole('button', { name: 'Stage in thread' }).click(); + await expect(page.locator('[data-action-sheet]')).toHaveCount(0); + // Close the inspector: it overlays the right edge of every surface and + // would intercept the docs entry's todo affordance below. + await page.getByLabel('Close inspector').click(); + + // Entry 2: /do in the composer opens the same sheet, pre-filled. + await openSurface(page, 'console-workspace'); + const composer = page.locator('[data-thread-composer-input]'); + await expect(composer).toBeVisible(); + // The With-me staging from entry 1 is visible above the composer. + await expect(page.locator('[data-thread-staged-ref]').first()).toContainText('Ada Lovelace'); + await composer.fill('/do triage the inbox'); + await composer.press('Enter'); + await expect(page.locator('[data-action-sheet]')).toBeVisible(); + await expect(page.getByLabel('Instruction')).toHaveValue('triage the inbox'); + await page.keyboard.press('Escape'); + + // Entry 3: the todo-block action icon in a document, and Alt+Enter. + await openSurface(page, 'console-docs'); + await page.locator('[data-doc-id="doc-console-punch-list"]').click(); + const todoButton = page.locator('[data-todo-action]').first(); + await expect(todoButton).toBeVisible(); + await todoButton.click(); + const todoSheet = page.locator('[data-action-sheet]'); + await expect(todoSheet).toBeVisible(); + await expect(todoSheet.locator('[data-context-chip="origin"]').first()).toContainText( + 'Console punch list', + ); + await page.keyboard.press('Escape'); + await page.locator('li.task-list-item').first().focus(); + await page.keyboard.press('Alt+Enter'); + await expect(page.locator('[data-action-sheet]')).toBeVisible(); + // Save as rule names its missing capability (IX6) instead of pretending. + await expect(page.locator('[data-save-as-rule-unavailable]')).toContainText('IX6'); + }); + + test('mentions: truthful counts, confirm writes through, dismiss suppresses', async ({ page }) => { + await openSurface(page, 'console-cards'); + const adaCell = page.locator('[data-card-cell="person-ada"]'); + await expect(adaCell.locator('[data-mentions-chip]')).toHaveText('2'); + // An object with no candidates shows no mentions chrome at all. + await expect( + page.locator('[data-card-cell="task-report"] [data-mentions-section]'), + ).toHaveCount(0); + + await adaCell.getByText('Ada Lovelace').click(); + const inspector = page.getByLabel('Record inspector'); + const summary = inspector.locator('[data-mentions-summary]'); + await expect(summary).toContainText('mentioned in 2 places, 2 unlinked'); + await inspector.locator('[data-mentions-section] button').first().click(); + // The passage highlight matches the recorded span exactly. + await expect(inspector.locator('[data-mention-span]').first()).toHaveText('Ada Lovelace'); + + await inspector.getByRole('button', { name: 'Confirm' }).first().click(); + await expect(summary).toContainText('1 unlinked'); + await expect(inspector.locator('[data-mention-candidate="confirmed"]')).toHaveCount(1); + + await inspector.getByRole('button', { name: 'Dismiss' }).first().click(); + await expect(summary).toContainText('mentioned in 1 places, 0 unlinked'); + }); + + test('baselines: grid, full card, and the sheet under reduced motion', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 1440, height: 900 }); + await freshLoad(page); + await openSurface(page, 'console-cards'); + await page.waitForTimeout(400); + await expect(page.locator('[data-cards-grid]')).toHaveScreenshot('cards-grid.png'); + + await page.locator('[data-card-cell="person-ada"]').click(); + await page.waitForTimeout(400); + await expect( + page.getByLabel('Record inspector').locator('[data-card="compact"]'), + ).toHaveScreenshot('card-compact-inspector.png'); + + await page.locator('[data-inspector-action]').click(); + const sheet = page.locator('[data-action-sheet]'); + await expect(sheet).toBeVisible(); + await page.waitForTimeout(300); + // Reduced motion renders the sheet without the material animation. + const transform = await sheet.evaluate((el) => getComputedStyle(el).transform); + expect(['none', 'matrix(1, 0, 0, 1, 0, 0)']).toContain(transform); + await expect(sheet).toHaveScreenshot('action-sheet.png'); + }); +}); diff --git a/apps/console/e2e/cards.spec.ts-snapshots/action-sheet-darwin.png b/apps/console/e2e/cards.spec.ts-snapshots/action-sheet-darwin.png new file mode 100644 index 00000000..d5fa8f2f Binary files /dev/null and b/apps/console/e2e/cards.spec.ts-snapshots/action-sheet-darwin.png differ diff --git a/apps/console/e2e/cards.spec.ts-snapshots/action-sheet-linux.png b/apps/console/e2e/cards.spec.ts-snapshots/action-sheet-linux.png new file mode 100644 index 00000000..87b92374 Binary files /dev/null and b/apps/console/e2e/cards.spec.ts-snapshots/action-sheet-linux.png differ diff --git a/apps/console/e2e/cards.spec.ts-snapshots/card-compact-inspector-darwin.png b/apps/console/e2e/cards.spec.ts-snapshots/card-compact-inspector-darwin.png new file mode 100644 index 00000000..006ad75e Binary files /dev/null and b/apps/console/e2e/cards.spec.ts-snapshots/card-compact-inspector-darwin.png differ diff --git a/apps/console/e2e/cards.spec.ts-snapshots/card-compact-inspector-linux.png b/apps/console/e2e/cards.spec.ts-snapshots/card-compact-inspector-linux.png new file mode 100644 index 00000000..003535de Binary files /dev/null and b/apps/console/e2e/cards.spec.ts-snapshots/card-compact-inspector-linux.png differ diff --git a/apps/console/e2e/cards.spec.ts-snapshots/cards-grid-darwin.png b/apps/console/e2e/cards.spec.ts-snapshots/cards-grid-darwin.png new file mode 100644 index 00000000..833ccae7 Binary files /dev/null and b/apps/console/e2e/cards.spec.ts-snapshots/cards-grid-darwin.png differ diff --git a/apps/console/e2e/cards.spec.ts-snapshots/cards-grid-linux.png b/apps/console/e2e/cards.spec.ts-snapshots/cards-grid-linux.png new file mode 100644 index 00000000..b30c508d Binary files /dev/null and b/apps/console/e2e/cards.spec.ts-snapshots/cards-grid-linux.png differ diff --git a/apps/console/e2e/hunk-review.spec.ts b/apps/console/e2e/hunk-review.spec.ts index 54c18c5f..5993aef3 100644 --- a/apps/console/e2e/hunk-review.spec.ts +++ b/apps/console/e2e/hunk-review.spec.ts @@ -1,16 +1,17 @@ // SOURCING: @playwright/test. Hunk visual milestone: the typed review route // resolves through the Greenfield surface registry and Int UI register. -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; -async function openReview(page: import('@playwright/test').Page) { +async function openReview(page: Page) { await page.goto('/'); await page.evaluate(() => window.localStorage.removeItem('commonplace.console.surface.v1')); await page.reload(); await page.setViewportSize({ width: 1440, height: 900 }); await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.getByRole('button', { name: 'Layout: Workspace' }).click(); - await page.getByRole('option', { name: 'Review' }).click(); + // Screen navigation is the leftmost stripe's surfaces group (the toolbar + // dropdown was replaced by the stripe surfaces group). + await page.locator('[data-surface-nav="console-review"]').click(); await expect(page.locator('[data-active-surface="console-review"]')).toBeVisible(); await expect(page.getByTestId('hunk-review')).toBeVisible(); } diff --git a/apps/console/e2e/omnibar.spec.ts b/apps/console/e2e/omnibar.spec.ts index 29b2f20b..252a31c3 100644 --- a/apps/console/e2e/omnibar.spec.ts +++ b/apps/console/e2e/omnibar.spec.ts @@ -35,7 +35,8 @@ test.describe('omnibar island', () => { await page.keyboard.press('Escape'); await expect(page.locator('[data-omnibar-island]')).toHaveCount(0); - // Command-palette convention: Ctrl+K opens Ask too. + // Command-palette convention: Ctrl+K opens Ask too (the browser-reliable + // key, since Ctrl/Cmd+L is reserved by browsers for the address bar). await page.keyboard.press('Control+k'); await expect(page.locator('[data-omnibar-island]')).toBeVisible(); await expect(page.locator('[data-omnibar-mode="ask"]')).toHaveAttribute('aria-pressed', 'true'); @@ -136,30 +137,54 @@ test.describe('omnibar island', () => { await expect(page.locator('[data-shell]')).toHaveAttribute('data-active-surface', 'console-index'); }); - test('layout switcher round-trips surfaces with their own arrangements', async ({ page }) => { + test('toggling a tool window reflows in place, never remounting the well', async ({ page }) => { + // The black-frame-drop regression: the PanelGroup was keyed on the visible + // panel set, so opening or closing a tool window tore the whole well down + // and rebuilt it (a flash to the frame, then back). Tag the editor panel + // and prove the node survives the toggle: reconcile, not remount. + const editor = page.locator('[data-panel-id="region-editor"]'); + await expect(editor).toBeVisible(); + await editor.evaluate((el) => (el.dataset.remountProbe = 'kept')); + const widthOpen = await editor.evaluate((el) => Math.round(el.getBoundingClientRect().width)); + // Close the thread window: the editor widens and the same node survives. + await page.keyboard.press('Alt+9'); + await expect(page.locator('nav button[aria-label="Thread tool window"]')).toHaveAttribute( + 'aria-pressed', + 'false', + ); + await expect(editor).toHaveAttribute('data-remount-probe', 'kept'); + const widthClosed = await editor.evaluate((el) => Math.round(el.getBoundingClientRect().width)); + expect(widthClosed).toBeGreaterThan(widthOpen); + // Reopen: the node is still the same one, and the arrangement returns. + await page.keyboard.press('Alt+9'); + await expect(page.locator('nav button[aria-label="Thread tool window"]')).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await expect(editor).toHaveAttribute('data-remount-probe', 'kept'); + }); + + test('surface rail round-trips surfaces with their own arrangements', async ({ page }) => { // Close the workspace's thread window so Workspace has a distinct shape. await page.keyboard.press('Alt+9'); await expect(page.locator('nav button[aria-label="Thread tool window"]')).toHaveAttribute( 'aria-pressed', 'false', ); - // Switch to Index from the toolbar widget. - await page.locator('[data-layout-switcher]').click(); - await page.locator('[data-layout-option="console-index"]').click(); + // Switch to Index from the leftmost stripe's surfaces group. + await page.locator('[data-surface-nav="console-index"]').click(); await expect(page.locator('[data-shell]')).toHaveAttribute('data-active-surface', 'console-index'); // The Index screen: destination rail naming its gap, live triage stream. await expect(page.getByText('destinations (connectors')).toBeVisible(); await expect.poll(() => page.locator('tbody tr').count()).toBeGreaterThanOrEqual(12); // Documents: list left, Galley reading view center. - await page.locator('[data-layout-switcher]').click(); - await page.locator('[data-layout-option="console-docs"]').click(); + await page.locator('[data-surface-nav="console-docs"]').click(); await expect(page.locator('[data-shell]')).toHaveAttribute('data-active-surface', 'console-docs'); await expect(page.locator('[data-doc-id]').first()).toBeVisible(); await expect(page.locator('.galley').first()).toBeVisible(); // Back to Workspace: the closed thread window survived the round trip // and a reload (per-surface arrangement snapshots, R3.3). - await page.locator('[data-layout-switcher]').click(); - await page.locator('[data-layout-option="console-workspace"]').click(); + await page.locator('[data-surface-nav="console-workspace"]').click(); await expect(page.locator('nav button[aria-label="Thread tool window"]')).toHaveAttribute( 'aria-pressed', 'false', diff --git a/apps/console/e2e/omnibar.spec.ts-snapshots/omnibar-expanded-ask-darwin.png b/apps/console/e2e/omnibar.spec.ts-snapshots/omnibar-expanded-ask-darwin.png index d91a87c1..ae5687a5 100644 Binary files a/apps/console/e2e/omnibar.spec.ts-snapshots/omnibar-expanded-ask-darwin.png and b/apps/console/e2e/omnibar.spec.ts-snapshots/omnibar-expanded-ask-darwin.png differ diff --git a/apps/console/e2e/omnibar.spec.ts-snapshots/omnibar-expanded-ask-linux.png b/apps/console/e2e/omnibar.spec.ts-snapshots/omnibar-expanded-ask-linux.png index 9ce3aa9e..cc706993 100644 Binary files a/apps/console/e2e/omnibar.spec.ts-snapshots/omnibar-expanded-ask-linux.png and b/apps/console/e2e/omnibar.spec.ts-snapshots/omnibar-expanded-ask-linux.png differ diff --git a/apps/console/e2e/proof-workspace.spec.ts b/apps/console/e2e/proof-workspace.spec.ts index ac5ac5bf..6130d099 100644 --- a/apps/console/e2e/proof-workspace.spec.ts +++ b/apps/console/e2e/proof-workspace.spec.ts @@ -41,8 +41,11 @@ test.describe('proof workspace', () => { await expect(page.getByRole('tab', { name: 'surface-tree.ts' })).toBeVisible(); // The brief reads through Galley inside the editor well. await expect(page.locator('.galley').first()).toBeVisible(); - // The thread names its missing capability instead of faking activity. - await expect(page.getByText('NEXT_PUBLIC_CONSOLE_CHAT_URL')).toBeVisible(); + // The chat wire is configured in e2e (the /do entry needs a live + // composer), so the thread renders its real composer; the named + // unavailable state for an unset NEXT_PUBLIC_CONSOLE_CHAT_URL lives in + // ThreadView and is exercised without the env in unit scope. + await expect(page.locator('[data-thread-composer-input]')).toBeVisible(); // Entrance settled: chrome is fully opaque after the budget. const opacity = await page .locator('section[aria-label="Records tool window"]') diff --git a/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1280-dark-darwin.png b/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1280-dark-darwin.png index 82b4df6f..dbc15873 100644 Binary files a/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1280-dark-darwin.png and b/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1280-dark-darwin.png differ diff --git a/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1280-dark-linux.png b/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1280-dark-linux.png index 5c7887a4..b8448b9c 100644 Binary files a/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1280-dark-linux.png and b/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1280-dark-linux.png differ diff --git a/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-dark-darwin.png b/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-dark-darwin.png index 83d3b2fc..d1c08ebf 100644 Binary files a/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-dark-darwin.png and b/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-dark-darwin.png differ diff --git a/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-dark-linux.png b/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-dark-linux.png index 7bcb43f2..bdf227e0 100644 Binary files a/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-dark-linux.png and b/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-dark-linux.png differ diff --git a/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-reduced-motion-darwin.png b/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-reduced-motion-darwin.png index 77be2a6e..24bd2066 100644 Binary files a/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-reduced-motion-darwin.png and b/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-reduced-motion-darwin.png differ diff --git a/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-reduced-motion-linux.png b/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-reduced-motion-linux.png index c35c30a6..47219971 100644 Binary files a/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-reduced-motion-linux.png and b/apps/console/e2e/proof-workspace.spec.ts-snapshots/workspace-1440-reduced-motion-linux.png differ diff --git a/apps/console/e2e/stub-data-api.mjs b/apps/console/e2e/stub-data-api.mjs index bd5c5547..c177c5d6 100644 --- a/apps/console/e2e/stub-data-api.mjs +++ b/apps/console/e2e/stub-data-api.mjs @@ -66,6 +66,138 @@ function seedRecords() { const RECORDS = seedRecords(); +// Domain fixtures for the card engine + mentions surface (K1/K2/K6 +// acceptance): a real person and task render through their templates against +// this seam, relation chips resolve to these objects, and the mention +// candidates drive the confirm/dismiss round trip. Types are the seam's +// canonical dash form. +const DOMAIN = [ + { + id: 'org-braintrust', + type: 'org', + properties: { title: 'Braintrust', kind: 'org' }, + relations: {}, + }, + { + id: 'project-porchfest', + type: 'project', + properties: { title: 'PorchFest 2026', kind: 'project', status: 'active' }, + relations: {}, + }, + { + id: 'skill-rust', + type: 'skill', + properties: { title: 'Rust', kind: 'skill' }, + relations: {}, + }, + { + id: 'person-ada', + type: 'person', + properties: { + title: 'Ada Lovelace', + kind: 'person', + role: 'Analyst', + email: 'ada@example.test', + location: 'London', + aliases: ['Countess of Lovelace'], + }, + relations: { + WORKS_AT: ['org-braintrust'], + HAS_SKILL: ['skill-rust'], + IN_PROJECT: ['project-porchfest'], + }, + }, + { + id: 'task-report', + type: 'task', + properties: { + title: 'Send the compliance report', + kind: 'task', + status: 'open', + priority: 'high', + due: '2026-07-21', + progress: 40, + }, + relations: { IN_PROJECT: ['project-porchfest'] }, + }, +]; + +const MENTION_CANDIDATES = [ + { + id: 'mention:person-ada:rec-1:ada-lovelace', + type: 'mention-candidate', + properties: { + title: 'Ada Lovelace in rec-1', + object_id: 'person-ada', + atom_id: 'rec-1', + matched_alias: 'Ada Lovelace', + tier: 'exact', + status: 'unlinked', + snippet: 'Filed after the sync: Ada Lovelace flagged the setback distance.', + snippet_start: 22, + snippet_end: 34, + }, + relations: {}, + }, + { + id: 'mention:person-ada:rec-2:countess-of-lovelace', + type: 'mention-candidate', + properties: { + title: 'Countess of Lovelace in rec-2', + object_id: 'person-ada', + atom_id: 'rec-2', + matched_alias: 'Countess of Lovelace', + tier: 'normalized', + status: 'unlinked', + snippet: 'The countess of lovelace annotated the memoir margins.', + snippet_start: 4, + snippet_end: 24, + }, + relations: {}, + }, +]; + +// Documents and code files ride the live wire now (the file-editing fix), so +// the stub serves them and applies edits in place, exercising the real +// browser -> proxy -> upstream path for persisted document editing. +const DOCS = [ + { + id: 'doc-console-brief', + type: 'doc', + properties: { + slug: 'console-brief', + title: 'The harness console', + markdown: + '# The harness console\n\nImagine Cursor had forked IntelliJ instead of VS Code, with sidebars that show code and markdown as easily as they show data models.\n\n## The mechanism\n\nThe chrome outside is Int UI: tool window stripes down the edges, a sunken editor well, a main toolbar with a run widget, a status bar.\n', + }, + relations: {}, + }, + { + id: 'doc-console-punch-list', + type: 'doc', + properties: { + slug: 'console-punch-list', + title: 'Console punch list', + markdown: + '# Console punch list\n\nWorking notes for the console itself. Each todo carries the action affordance.\n\n## Open items\n\n- [ ] Wire the destination rail to live connector counts\n- [ ] Capture a fresh visual baseline after the card engine lands\n- [x] Point the record table at the deployed object seam\n', + }, + relations: {}, + }, +]; + +const CODE_FILES = [ + { + id: 'code-surface-tree', + type: 'code-file', + properties: { + path: 'packages/block-view/src/surface-tree.ts', + language: 'typescript', + content: "export const CONTAINS_EDGE = 'CONTAINS';\n", + }, + relations: {}, + }, +]; + const HUNKS = [ { id: 'hunk-agent-run', @@ -128,6 +260,38 @@ const HUNKS = [ }, ]; +const POOLS = new Map([ + ['record', RECORDS], + ['person', DOMAIN.filter((o) => o.type === 'person')], + ['task', DOMAIN.filter((o) => o.type === 'task')], + ['org', DOMAIN.filter((o) => o.type === 'org')], + ['project', DOMAIN.filter((o) => o.type === 'project')], + ['skill', DOMAIN.filter((o) => o.type === 'skill')], + ['mention-candidate', MENTION_CANDIDATES], + ['doc', DOCS], + ['code-file', CODE_FILES], + ['hunk', HUNKS], +]); + +/** Every stored object across pools, for id-keyed update. */ +function allStored() { + return [...POOLS.values()].flat(); +} + +function poolFor(types) { + const requested = Array.isArray(types) && types.length > 0 ? types : ['record']; + const objects = []; + const seen = new Set(); + for (const type of requested) { + for (const object of POOLS.get(type) ?? []) { + if (!seen.has(object.id)) { + seen.add(object.id); + objects.push(object); + } + } + } + return objects; +} function matches(object, predicate) { if (!predicate) return true; switch (predicate.kind) { @@ -149,8 +313,7 @@ function matches(object, predicate) { } function runQuery(query) { - const pool = query.types?.includes('hunk') ? HUNKS : RECORDS; - let objects = pool.filter((object) => matches(object, query.where)); + let objects = poolFor(query.types).filter((object) => matches(object, query.where)); const ranker = query.rank?.[0]; if (ranker?.kind === 'field') { const direction = ranker.direction === 'desc' ? -1 : 1; @@ -202,6 +365,32 @@ const server = createServer((request, response) => { try { if (request.url === '/objects/action') { const action = JSON.parse(body); + // Update applies in place across every pool (mention confirm/dismiss + // K6, and persisted document/code edits): the surface's refetch sees + // the transition. + if (action.kind === 'update') { + const target = allStored().find((entry) => entry.id === action.id); + if (target) { + target.properties = { ...target.properties, ...action.patch }; + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ action_kind: 'update', status: 'applied', target_ids: [action.id] }), + ); + return; + } + } + // Create appends to the type's pool (the seed-content path); ids are + // deterministic for stable captures. + if (action.kind === 'create' && POOLS.has(action.type)) { + const pool = POOLS.get(action.type); + const id = `${action.type}-${pool.length + 1}`; + pool.push({ id, type: action.type, properties: { ...action.props }, relations: {} }); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ action_kind: 'create', status: 'applied', target_ids: [id] }), + ); + return; + } response.writeHead(200, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ action_kind: action.kind, status: 'accepted' })); return; diff --git a/apps/console/playwright.config.ts b/apps/console/playwright.config.ts index 14ec6f89..cd2c5258 100644 --- a/apps/console/playwright.config.ts +++ b/apps/console/playwright.config.ts @@ -37,6 +37,9 @@ export default defineConfig({ env: { CONSOLE_DATA_API_URL: 'http://localhost:50591', CONSOLE_DATA_API_KEY: 'dev-key', + // The composer must be live for the /do entry (K3); the sheet's + // interception happens before any network send. + NEXT_PUBLIC_CONSOLE_CHAT_URL: '/api/chat/stream', }, }, ], diff --git a/apps/console/src/app/api/harness/delegate/route.ts b/apps/console/src/app/api/harness/delegate/route.ts new file mode 100644 index 00000000..9390a19e --- /dev/null +++ b/apps/console/src/app/api/harness/delegate/route.ts @@ -0,0 +1,41 @@ +// SOURCING: none. Thin passthrough (K4): For me submits through the harness +// delegate path (handoff plus job with the pack; the room appears in the +// strip). Same env contract as presence/runs: unconfigured returns 404 and +// the sheet renders its named unavailable state; an upstream refusal (the +// identity refusal observable in production) passes its status through so the +// sheet can name it. No fixture rooms ever render. + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const base = process.env.CONSOLE_HARNESS_URL; + if (!base) { + return Response.json({ error: 'console_harness_unconfigured' }, { status: 404 }); + } + const tenant = process.env.CONSOLE_HARNESS_TENANT ?? 'Travis-Gilbert'; + const room = process.env.CONSOLE_HARNESS_ROOM ?? 'commonplace'; + const pack = await req.text(); + try { + const upstream = await fetch( + `${base.replace(/\/$/, '')}/harness/rooms/${encodeURIComponent(room)}/handoffs?tenant=${encodeURIComponent(tenant)}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(process.env.CONSOLE_HARNESS_TOKEN + ? { Authorization: `Bearer ${process.env.CONSOLE_HARNESS_TOKEN}` } + : {}), + }, + body: pack, + cache: 'no-store', + }, + ); + const body = await upstream.text(); + return new Response(body, { + status: upstream.status, + headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' }, + }); + } catch { + return Response.json({ error: 'harness_unreachable' }, { status: 502 }); + } +} diff --git a/apps/console/src/components/ConsoleApp.tsx b/apps/console/src/components/ConsoleApp.tsx index 361f38e1..8aca2a75 100644 --- a/apps/console/src/components/ConsoleApp.tsx +++ b/apps/console/src/components/ConsoleApp.tsx @@ -48,7 +48,17 @@ function RuntimeBoundary({ children }: { children: React.ReactNode }) { convertMessage, onNew: async (message: AppendMessage) => { const text = appendedText(message); - if (text) await send(text); + if (!text) return; + // The /do entry (K3): the composer's slash command opens the action + // sheet with the instruction pre-filled instead of sending a message. + if (/^\/do\b/i.test(text)) { + useShellStore.getState().openActionSheet({ + instruction: text.replace(/^\/do\b/i, '').trim(), + chips: [], + }); + return; + } + await send(text); }, onCancel: async () => cancel(), }); @@ -96,6 +106,9 @@ export function ConsoleApp() { // Transport health is real: the object-seam probe sets the connection // state, and presence renders only when the harness transport reports it. void host.probe(); + // Seed the backend's document fixtures once so the Documents surface has + // editable, persistent content (the file-editing wire). + void host.ensureSeedContent(); let active = true; void fetch('/api/harness/presence', { cache: 'no-store' }) .then(async (response) => { diff --git a/apps/console/src/components/shell/ActionSheet.tsx b/apps/console/src/components/shell/ActionSheet.tsx new file mode 100644 index 00000000..6d4a4dd1 --- /dev/null +++ b/apps/console/src/components/shell/ActionSheet.tsx @@ -0,0 +1,304 @@ +'use client'; + +// SOURCING: motion (motion/react; the sheet entrance is an inventory row) + +// zustand stores (shell + thread; With me stages visible refs into the +// thread). The action sheet (HANDOFF-CARDS-ACTIONS-MENTIONS K3/K4): instruction plus +// staged context, and context is never silent. All three entries (/do in the +// composer, the todo-block action icon, the Action verb on inspector and +// cards) open this same sheet. Staged chips show exactly what travels; +// auto-suggest adds visible removable chips through a real title query (the +// exact tier of the salience machinery reachable from the console today); +// the submitted pack equals the visible chip set exactly. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { motion } from 'motion/react'; +import type { BlockHost } from '@commonplace/block-view/types'; +import { + useShellStore, + type StagedContextChip, +} from '@/lib/shell-store'; +import { useThreadStore } from '@/lib/thread-store'; +import { + buildActionPack, + packEqualsChips, + type ActionDestination, + type ActionFollowUp, +} from '@/lib/action-pack'; +import { DUR, EASE_OUT, seconds, useMotionDurations } from '@/motion/motion-tokens'; + +type SubmitState = + | { readonly kind: 'idle' } + | { readonly kind: 'submitting' } + | { readonly kind: 'refused'; readonly message: string } + | { readonly kind: 'done'; readonly message: string }; + +export function ActionSheet({ host }: { host: BlockHost }) { + const origin = useShellStore((state) => state.actionSheetOrigin); + const close = useShellStore((state) => state.closeActionSheet); + if (!origin) return null; + return ; +} + +function ActionSheetOpen({ host, onClose }: { host: BlockHost; onClose: () => void }) { + const origin = useShellStore((state) => state.actionSheetOrigin); + const stageInThread = useThreadStore((state) => state.stage); + const durations = useMotionDurations(); + const [instruction, setInstruction] = useState(origin?.instruction ?? ''); + const [chips, setChips] = useState(origin?.chips ?? []); + const [destination, setDestination] = useState('for-me'); + const [followUp, setFollowUp] = useState('keep-open'); + const [submit, setSubmit] = useState({ kind: 'idle' }); + const [suggesting, setSuggesting] = useState(false); + const fieldRef = useRef(null); + const prevFocus = useRef(null); + + useEffect(() => { + prevFocus.current = document.activeElement as HTMLElement | null; + fieldRef.current?.focus(); + return () => prevFocus.current?.focus(); + }, []); + + const removeChip = useCallback((id: string) => { + setChips((current) => current.filter((chip) => chip.id !== id)); + }, []); + + /** Auto-suggest (named choice 4): candidates come from a real title query + * against the seam (the exact tier reachable from the console; the full + * salience machinery is the harness's). Every added chip is visible and + * removable; nothing submits unseen. */ + const suggest = useCallback(async () => { + const probe = instruction.trim().split(/\s+/).filter((word) => word.length > 3)[0]; + if (!probe) return; + setSuggesting(true); + try { + const set = await host.query({ + types: ['record', 'person', 'task', 'project', 'org'], + where: { kind: 'contains', field: 'title', value: probe }, + page: { limit: 5 }, + }); + setChips((current) => { + const seen = new Set(current.map((chip) => chip.objectId)); + const added = set.objects + .filter((object) => !seen.has(object.id)) + .slice(0, 3) + .map( + (object): StagedContextChip => ({ + id: `chip-auto-${object.id}`, + kind: 'object', + label: String(object.properties.title ?? object.id), + objectId: object.id, + objectType: object.type, + source: 'auto', + }), + ); + return [...current, ...added]; + }); + } catch { + // No candidates reachable: the chip list simply does not grow. + } finally { + setSuggesting(false); + } + }, [host, instruction]); + + const submitSheet = useCallback(async () => { + const pack = buildActionPack(instruction, chips, destination, followUp); + // The named invariant of the round: the pack equals the visible chips. + if (!packEqualsChips(pack, chips)) { + setSubmit({ kind: 'refused', message: 'pack drifted from the visible chips; not submitting' }); + return; + } + if (destination === 'with-me') { + // With me stays console-local (K4): chips become visible object + // references staged above the thread composer, and focus moves there. + stageInThread( + chips.map((chip) => ({ + id: chip.id, + label: chip.label, + objectId: chip.objectId, + })), + ); + onClose(); + requestAnimationFrame(() => { + document + .querySelector('[data-thread-composer-input]') + ?.focus(); + }); + return; + } + setSubmit({ kind: 'submitting' }); + try { + const response = await fetch('/api/harness/delegate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(pack), + }); + if (response.ok) { + setSubmit({ kind: 'done', message: 'handed off: the room appears in the strip' }); + return; + } + const message = + response.status === 404 + ? 'the harness delegate wire is not configured (CONSOLE_HARNESS_URL)' + : response.status === 401 || response.status === 403 + ? 'the harness refused this identity; With me still works' + : `the harness declined the handoff (${response.status})`; + setSubmit({ kind: 'refused', message }); + } catch { + setSubmit({ kind: 'refused', message: 'the harness is unreachable; With me still works' }); + } + }, [instruction, chips, destination, followUp, stageInThread, onClose]); + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + onClose(); + } + }} + > + +
+