Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,16 @@
const sourceVersion = reactive<Record<string, number>>({});

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.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.windows.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;
Expand Down Expand Up @@ -82,6 +84,9 @@
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');
Expand Down Expand Up @@ -112,12 +117,12 @@
openBuiltin('chat');

if (import.meta.client) {
(window as any).__dominicReset = () => {

Check warning on line 120 in app/app.vue

View workflow job for this annotation

GitHub Actions / ESLint

Unexpected any. Specify a different type
settings.resetOs();
window.location.reload();
};
(window as any).__dominic = {

Check warning on line 124 in app/app.vue

View workflow job for this annotation

GitHub Actions / ESLint

Unexpected any. Specify a different type
reset: (window as any).__dominicReset,

Check warning on line 125 in app/app.vue

View workflow job for this annotation

GitHub Actions / ESLint

Unexpected any. Specify a different type
installApp,
updateApp,
openApp,
Expand Down
46 changes: 42 additions & 4 deletions app/features/os/components/Taskbar.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Code2, Trash2 } from "lucide-vue-next";
import { computed } from "vue";
import { Code2, Plus, Trash2 } from "lucide-vue-next";
import { useOsStore } from "../stores/os";

defineOptions({ name: "OsTaskbar" });
Expand All @@ -13,7 +14,9 @@ const props = defineProps<{
}>();

function isRunning(appId: string): boolean {
return store.windows.some((win) => win.appId === appId);
return store.windows.some(
(win) => win.appId === appId && win.workspace === store.activeWorkspace,
);
}

function onTaskbarClick(id: string): void {
Expand All @@ -25,6 +28,10 @@ function onTaskbarClick(id: string): void {
store.toggleMinimize(id);
}
}

const windowButtons = computed(() =>
store.windows.filter((win) => win.workspace === store.activeWorkspace),
);
</script>

<template>
Expand Down Expand Up @@ -69,7 +76,7 @@ function onTaskbarClick(id: string): void {
</div>
<span v-if="props.apps.length" class="h-6 w-px bg-white/10 max-md:hidden" />
<button
v-for="win in store.windows"
v-for="win in windowButtons"
:key="win.id"
class="max-w-48 truncate rounded border px-3 max-md:px-4 py-1 max-md:py-2.5 text-xs max-md:text-sm transition-colors shrink-0"
:class="
Expand All @@ -84,5 +91,36 @@ function onTaskbarClick(id: string): void {
{{ win.title }}
</button>
</div>
<div
class="flex items-center gap-1 shrink-0 pl-2 max-md:pl-1 border-l border-white/10 max-md:border-l-0"
role="tablist"
aria-label="Workspaces"
>
<button
v-for="i in store.workspaceCount"
:key="i"
class="flex h-8 max-md:h-11 w-8 max-md:w-9 items-center justify-center rounded border text-xs max-md:text-sm font-semibold transition-colors"
:class="
store.activeWorkspace === i - 1
? 'border-indigo-400/60 bg-indigo-500/20 text-indigo-100'
: 'border-white/10 bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-100'
"
role="tab"
:aria-selected="store.activeWorkspace === i - 1"
:aria-label="`Workspace ${i}`"
@click="store.setWorkspace(i - 1)"
>
{{ i }}
</button>
<button
v-if="store.workspaceCount < 4"
class="flex h-8 max-md:h-11 w-8 max-md:w-9 items-center justify-center rounded border border-white/10 bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-100"
aria-label="Add workspace"
title="Add workspace"
@click="store.createWorkspace"
>
<Plus :size="14" aria-hidden="true" />
</button>
</div>
</footer>
</template>
</template>
2 changes: 1 addition & 1 deletion app/features/os/components/WindowFrame.vue
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ watch(dragging, (active) => {
/>
</Teleport>
<section
v-show="!win.minimized && win.workspace === store.activeWorkspace"
ref="el"
v-show="!win.minimized"
class="absolute flex flex-col overflow-hidden rounded-lg border border-white/10 bg-slate-900/95 shadow-2xl backdrop-blur max-md:!static max-md:!mx-2 max-md:!mb-3 max-md:!mt-4 first:max-md:!mt-0 max-md:!h-[70vh] max-md:!w-auto"
:class="store.focusedId === win.id ? 'ring-2 ring-indigo-400' : ''"
:style="style"
Expand Down
40 changes: 40 additions & 0 deletions app/features/os/stores/os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,26 @@ 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;

export const useOsStore = defineStore("os", () => {
const windows = ref<OsWindow[]>([]);
const focusedId = ref<string | null>(null);
const activeWorkspace = ref(0);
const workspaceCount = ref(1);
const topZ = computed(() =>
windows.value.reduce((max, w) => Math.max(max, w.z), 0),
);
// 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),
);

function openWindow(init: Partial<OsWindow> & { title: string }): OsWindow {
const id = `win-${nextId++}`;
Expand All @@ -43,6 +53,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;
Expand Down Expand Up @@ -153,9 +164,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<OsWindow | null>(
(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,
Expand All @@ -165,5 +203,7 @@ export const useOsStore = defineStore("os", () => {
unsnapWindow,
toggleMaximize,
toggleMinimize,
setWorkspace,
createWorkspace,
};
});
9 changes: 9 additions & 0 deletions tests/e2e/support/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
121 changes: 121 additions & 0 deletions tests/e2e/workspaces.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { expect, test } from '@playwright/test'
import {
appWindow,
dominicHooks,
keepRequestsLocal,
openDesktop,
shownWindow,
} from './support/desktop'
import { stubAgentReply } from './support/agent-stub'

const REPAIRED_APP = `<template>
<div class="p-6">
<h2>{{ status }}</h2>
</div>
</template>

<script setup>
import { ref } from 'vue'
const status = ref('Repaired by the agent')
</script>
`

// 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 (they stay mounted) 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 = shownWindow(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',
)
// 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 () => {
await page.getByRole('tab', { name: 'Workspace 1' }).click()
await expect(page.getByRole('tab', { name: 'Workspace 1' })).toHaveAttribute(
'aria-selected',
'true',
)
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 = shownWindow(page, 'Settings')
await expect(settings2).toBeVisible()
await expect(shownWindow(page, 'Settings')).toHaveCount(1)

await page.getByRole('tab', { name: 'Workspace 1' }).click()
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()
})
})
Loading