From 66c59c1bbb113b38f296b6d13765a4f5bc138816 Mon Sep 17 00:00:00 2001 From: Zackary Lowery Date: Sun, 4 Oct 2026 22:46:28 -0400 Subject: [PATCH 1/2] feat(os): multi-desktop workspaces switchable from the taskbar (NEXT.md 3.2b) --- app/app.vue | 6 ++- app/features/os/components/Taskbar.vue | 46 +++++++++++++++++-- app/features/os/stores/os.ts | 39 ++++++++++++++++ tests/e2e/workspaces.spec.ts | 62 ++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/workspaces.spec.ts diff --git a/app/app.vue b/app/app.vue index 75983ac..1983431 100644 --- a/app/app.vue +++ b/app/app.vue @@ -22,14 +22,16 @@ const now = ref(null); const sourceVersion = reactive>({}); function openBuiltin(appId: 'chat' | 'settings') { - if (os.windows.some((win) => win.appId === appId)) return; + // The same app may exist on several workspaces; open one on the active + // desktop instead of focusing a window on another one. + if (os.visibleWindows.some((win) => win.appId === appId)) return; os.openWindow({ appId, title: appId === 'chat' ? 'Agent Chat' : 'Settings', width: appId === 'chat' ? 520 : 480, height: 560 }); } function openApp(appId: string) { const app = getApp(appId); if (!app) return; - const existing = os.windows.find((win) => win.appId === appId); + const existing = os.visibleWindows.find((win) => win.appId === appId); if (existing) { os.focusWindow(existing.id); return; diff --git a/app/features/os/components/Taskbar.vue b/app/features/os/components/Taskbar.vue index 258aa3c..c620eb9 100644 --- a/app/features/os/components/Taskbar.vue +++ b/app/features/os/components/Taskbar.vue @@ -1,5 +1,6 @@ + \ No newline at end of file diff --git a/app/features/os/stores/os.ts b/app/features/os/stores/os.ts index 01a6fec..f77a75d 100644 --- a/app/features/os/stores/os.ts +++ b/app/features/os/stores/os.ts @@ -18,6 +18,8 @@ export interface OsWindow { minimized: boolean; // Geometry to restore when a snapped window is dragged away (NEXT.md 3.2). preSnap?: { x: number; y: number; width: number; height: number } | null; + // Multi-desktop workspaces (NEXT.md 3.2b): window lives on exactly one. + workspace: number; } let nextId = 1; @@ -25,9 +27,16 @@ let nextId = 1; export const useOsStore = defineStore("os", () => { const windows = ref([]); const focusedId = ref(null); + const activeWorkspace = ref(0); + const workspaceCount = ref(1); const topZ = computed(() => windows.value.reduce((max, w) => Math.max(max, w.z), 0), ); + // Visible windows are scoped to the active workspace; other desktops keep + // their windows alive but out of view (NEXT.md 3.2b). + const visibleWindows = computed(() => + windows.value.filter((w) => w.workspace === activeWorkspace.value), + ); function openWindow(init: Partial & { title: string }): OsWindow { const id = `win-${nextId++}`; @@ -43,6 +52,7 @@ export const useOsStore = defineStore("os", () => { z: topZ.value + 1, state: init.state ?? "normal", minimized: false, + workspace: activeWorkspace.value, }; windows.value.push(win); focusedId.value = id; @@ -153,9 +163,36 @@ export const useOsStore = defineStore("os", () => { if (!win.minimized) focusWindow(id); } + // Switching desktops keeps focused windows on their own desktop; a focus + // is only kept when it is visible on the one becoming active. + function setWorkspace(index: number): void { + const target = Math.min(Math.max(index, 0), workspaceCount.value - 1); + activeWorkspace.value = target; + const focused = windows.value.find((w) => w.id === focusedId.value); + if (focused && focused.workspace !== target) { + const visible = windows.value.filter( + (w) => w.workspace === target && !w.minimized, + ); + const top = visible.reduce( + (best, w) => (!best || w.z > best.z ? w : best), + null, + ); + focusedId.value = top?.id ?? null; + } + } + + // Workspaces grow on demand; switching to an empty desktop is allowed. + function createWorkspace(): void { + workspaceCount.value += 1; + setWorkspace(workspaceCount.value - 1); + } + return { windows, focusedId, + activeWorkspace, + workspaceCount, + visibleWindows, openWindow, closeWindow, focusWindow, @@ -165,5 +202,7 @@ export const useOsStore = defineStore("os", () => { unsnapWindow, toggleMaximize, toggleMinimize, + setWorkspace, + createWorkspace, }; }); diff --git a/tests/e2e/workspaces.spec.ts b/tests/e2e/workspaces.spec.ts new file mode 100644 index 0000000..91109aa --- /dev/null +++ b/tests/e2e/workspaces.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from '@playwright/test' +import { + appWindow, + keepRequestsLocal, + openDesktop, +} from './support/desktop' + +// NEXT.md step 3.2b: workspaces are multiple virtual desktops switchable +// from the taskbar. Windows persist on the desktop they were opened on; +// switching hides the old desktop's windows and shows the new one's. + +test.beforeEach(async ({ page, baseURL }) => { + await keepRequestsLocal(page, baseURL) +}) + +test('taskbar workspaces: switch, isolate windows, add a desktop', async ({ page }) => { + await openDesktop(page) + + await test.step('the taskbar starts with a single workspace', async () => { + await expect(page.getByRole('tab', { name: 'Workspace 1' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Workspace 2' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Add workspace' })).toBeVisible() + }) + + await test.step('windows opened on desktop 1 stay on desktop 1', async () => { + // Settings opens from the System panel (top right), not the taskbar. + await page.getByRole('button', { name: 'Settings' }).click() + const settings = appWindow(page, 'Settings') + await expect(settings).toBeVisible() + }) + + await test.step('adding a workspace switches to an empty desktop 2', async () => { + await page.getByRole('button', { name: 'Add workspace' }).click() + await expect(page.getByRole('tab', { name: 'Workspace 2' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + await expect(appWindow(page, 'Settings')).toHaveCount(0) + }) + + await test.step('switching back to desktop 1 restores its windows', async () => { + await page.getByRole('tab', { name: 'Workspace 1' }).click() + await expect(page.getByRole('tab', { name: 'Workspace 1' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + await expect(appWindow(page, 'Settings')).toBeVisible() + }) + + await test.step('windows opened on desktop 2 appear only there', async () => { + await page.getByRole('tab', { name: 'Workspace 2' }).click() + await page.getByRole('button', { name: 'Settings' }).click() + const settings2 = appWindow(page, 'Settings') + await expect(settings2).toBeVisible() + + await page.getByRole('tab', { name: 'Workspace 1' }).click() + await expect(appWindow(page, 'Settings')).toBeVisible() + + await page.getByRole('tab', { name: 'Workspace 2' }).click() + await expect(settings2).toBeVisible() + }) +}) \ No newline at end of file From cec3166e8a8c882057039bead0f548432e544517 Mon Sep 17 00:00:00 2001 From: Zackary Lowery Date: Mon, 5 Oct 2026 13:53:25 -0400 Subject: [PATCH 2/2] fix(os): keep other-desktop windows mounted, askFix follows the chat (review) --- app/app.vue | 7 ++- app/features/os/components/WindowFrame.vue | 2 +- app/features/os/stores/os.ts | 5 +- tests/e2e/support/desktop.ts | 9 +++ tests/e2e/workspaces.spec.ts | 73 +++++++++++++++++++--- 5 files changed, 84 insertions(+), 12 deletions(-) diff --git a/app/app.vue b/app/app.vue index 1983431..433f0aa 100644 --- a/app/app.vue +++ b/app/app.vue @@ -24,14 +24,14 @@ const sourceVersion = reactive>({}); function openBuiltin(appId: 'chat' | 'settings') { // The same app may exist on several workspaces; open one on the active // desktop instead of focusing a window on another one. - if (os.visibleWindows.some((win) => win.appId === appId)) return; + if (os.windows.some((win) => win.appId === appId && win.workspace === os.activeWorkspace)) return; os.openWindow({ appId, title: appId === 'chat' ? 'Agent Chat' : 'Settings', width: appId === 'chat' ? 520 : 480, height: 560 }); } function openApp(appId: string) { const app = getApp(appId); if (!app) return; - const existing = os.visibleWindows.find((win) => win.appId === appId); + const existing = os.windows.find((win) => win.appId === appId && win.workspace === os.activeWorkspace); if (existing) { os.focusWindow(existing.id); return; @@ -84,6 +84,9 @@ function getAppSource(id: string) { async function askFix(payload: { appId: string; error: string; sourceCode?: string }) { const chatWin = os.windows.find((w) => w.appId === 'chat'); if (chatWin) { + // The chat window stays mounted on its own desktop; bring that desktop, + // then the window, forward so the reply is visible. + os.setWorkspace(chatWin.workspace); os.focusWindow(chatWin.id); } else { openBuiltin('chat'); diff --git a/app/features/os/components/WindowFrame.vue b/app/features/os/components/WindowFrame.vue index 6b30420..e8b79b2 100644 --- a/app/features/os/components/WindowFrame.vue +++ b/app/features/os/components/WindowFrame.vue @@ -188,8 +188,8 @@ watch(dragging, (active) => { />
{ const topZ = computed(() => windows.value.reduce((max, w) => Math.max(max, w.z), 0), ); - // Visible windows are scoped to the active workspace; other desktops keep - // their windows alive but out of view (NEXT.md 3.2b). + // Windows on other desktops stay mounted but hidden by WindowFrame's + // v-show (their component state — chat drafts, running apps — must + // survive a desktop switch; NEXT.md 3.2b). const visibleWindows = computed(() => windows.value.filter((w) => w.workspace === activeWorkspace.value), ); diff --git a/tests/e2e/support/desktop.ts b/tests/e2e/support/desktop.ts index 4e12544..fd0d691 100644 --- a/tests/e2e/support/desktop.ts +++ b/tests/e2e/support/desktop.ts @@ -57,3 +57,12 @@ export function appWindow(page: Page, title: string): Locator { .locator('section') .filter({ has: page.locator('header').getByText(title, { exact: true }) }) } + +/** + * The window of `title` that is currently shown (its workspace is active). + * Windows on other desktops stay mounted but hidden, so title-only + * locators match several records once more than one desktop has windows. + */ +export function shownWindow(page: Page, title: string): Locator { + return appWindow(page, title).filter({ visible: true }) +} diff --git a/tests/e2e/workspaces.spec.ts b/tests/e2e/workspaces.spec.ts index 91109aa..4da7012 100644 --- a/tests/e2e/workspaces.spec.ts +++ b/tests/e2e/workspaces.spec.ts @@ -1,13 +1,29 @@ -import { expect, test } from '@playwright/test' +import { expect, test } from '@playwright/test' import { appWindow, + dominicHooks, keepRequestsLocal, openDesktop, + shownWindow, } from './support/desktop' +import { stubAgentReply } from './support/agent-stub' + +const REPAIRED_APP = ` + + +` // NEXT.md step 3.2b: workspaces are multiple virtual desktops switchable // from the taskbar. Windows persist on the desktop they were opened on; -// switching hides the old desktop's windows and shows the new one's. +// switching hides the old desktop's windows (they stay mounted) and shows +// the new one's. test.beforeEach(async ({ page, baseURL }) => { await keepRequestsLocal(page, baseURL) @@ -25,7 +41,7 @@ test('taskbar workspaces: switch, isolate windows, add a desktop', async ({ page await test.step('windows opened on desktop 1 stay on desktop 1', async () => { // Settings opens from the System panel (top right), not the taskbar. await page.getByRole('button', { name: 'Settings' }).click() - const settings = appWindow(page, 'Settings') + const settings = shownWindow(page, 'Settings') await expect(settings).toBeVisible() }) @@ -35,7 +51,8 @@ test('taskbar workspaces: switch, isolate windows, add a desktop', async ({ page 'aria-selected', 'true', ) - await expect(appWindow(page, 'Settings')).toHaveCount(0) + // Windows stay mounted (contents survive switches), just hidden. + await expect(appWindow(page, 'Settings')).toBeHidden() }) await test.step('switching back to desktop 1 restores its windows', async () => { @@ -44,19 +61,61 @@ test('taskbar workspaces: switch, isolate windows, add a desktop', async ({ page 'aria-selected', 'true', ) - await expect(appWindow(page, 'Settings')).toBeVisible() + await expect(shownWindow(page, 'Settings')).toBeVisible() }) await test.step('windows opened on desktop 2 appear only there', async () => { await page.getByRole('tab', { name: 'Workspace 2' }).click() await page.getByRole('button', { name: 'Settings' }).click() - const settings2 = appWindow(page, 'Settings') + const settings2 = shownWindow(page, 'Settings') await expect(settings2).toBeVisible() + await expect(shownWindow(page, 'Settings')).toHaveCount(1) await page.getByRole('tab', { name: 'Workspace 1' }).click() - await expect(appWindow(page, 'Settings')).toBeVisible() + await expect(shownWindow(page, 'Settings')).toBeVisible() + await expect(shownWindow(page, 'Settings')).toHaveCount(1) await page.getByRole('tab', { name: 'Workspace 2' }).click() await expect(settings2).toBeVisible() }) +}) + +test('window contents survive a desktop switch and Ask Agent to Fix follows the chat', async ({ page }) => { + const chat = appWindow(page, 'Agent Chat') + await openDesktop(page) + + await test.step('a draft typed into Agent Chat survives switching away and back', async () => { + const input = chat.getByPlaceholder(/Ask DOMinic to build/) + await input.fill('please remember this draft') + await page.getByRole('button', { name: 'Add workspace' }).click() + await page.getByRole('tab', { name: 'Workspace 1' }).click() + await expect(input).toHaveValue('please remember this draft') + }) + + await test.step('Ask Agent to Fix switches to the chat desktop and reaches the agent', async () => { + const dominic = dominicHooks(page) + // Broken app opens on desktop 2, while the chat lives on desktop 1. + await page.getByRole('tab', { name: 'Workspace 2' }).click() + await dominic.injectBrokenApp('compile') + const broken = appWindow(page, 'Broken (Syntax Error)') + await expect(broken.getByText('Component Compilation Error')).toBeVisible() + + const requests = await stubAgentReply(page, { + text: 'Found the bug; sending a fixed version.', + updateApp: { id: 'broken-app', vueSfcCode: REPAIRED_APP, summary: 'Fix the deliberate error' }, + }) + await broken.getByRole('button', { name: 'Ask Agent to Fix' }).click() + + await expect(page.getByRole('tab', { name: 'Workspace 1' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + await expect(chat).toBeVisible() + expect(requests).toHaveLength(1) + // The broken app stays mounted on desktop 2; its repaired state is + // visible after switching back there. + await expect(broken).toBeHidden() + await page.getByRole('tab', { name: 'Workspace 2' }).click() + await expect(broken.getByRole('heading', { name: 'Repaired by the agent' })).toBeVisible() + }) }) \ No newline at end of file